From cc72c64d28d29145a5af8d152ee1556fc4836d52 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Wed, 7 Oct 2026 20:31:56 +0530 Subject: [PATCH 01/27] feat(core): add AGW v5 wallet and rules SDK Add wallet creation, labels and rules management, owner and agent execution, and EVM/Solana destination composition and tracking. Include independent token budgets, SPL source-mint resolution, raw-offset rule support and the READ.CHAIN.WEB2 alias. Keep runtime bindings, consumer documentation, ABI snapshots, local-contract tests, opt-in funded E2Es and validation scripts together. Align CI with the deployed v5 source. Exclude all planning-folder changes. --- .github/workflows/agw-sdk-validation.yml | 45 + .github/workflows/e2e.yml | 1 + .gitignore | 3 + packages/core/.eslintrc.json | 3 + packages/core/AGW.md | 366 +++ packages/core/README.md | 2 + .../__agw-local__/agw-local-advanced.spec.ts | 610 +++++ .../__agw-local__/agw-local-batch.spec.ts | 154 ++ .../__agw-local__/agw-local-races.spec.ts | 166 ++ packages/core/__agw-local__/agw-local.spec.ts | 447 ++++ .../core/__agw-local__/delayed-index.spec.ts | 257 ++ packages/core/__agw-local__/harness.ts | 444 ++++ packages/core/__agw-local__/labels.spec.ts | 141 ++ packages/core/__agw-local__/setup.ts | 18 + .../core/__agw-local__/svm-backend.spec.ts | 549 +++++ .../__agw-local__/svm-differential.spec.ts | 418 ++++ .../core/__agw-local__/svm-internals.spec.ts | 328 +++ .../core/__agw-local__/svm-public.spec.ts | 528 ++++ .../core/__agw-local__/v5-multiasset.spec.ts | 367 +++ packages/core/__agw-local__/vectors.spec.ts | 220 ++ .../__e2e__/agw-svm-wire/outbound.spec.ts | 174 ++ .../core/__e2e__/agw-svm-wire/replay.spec.ts | 59 + packages/core/__e2e__/agw/_fixture.ts | 138 ++ packages/core/__e2e__/agw/_manifest.ts | 203 ++ packages/core/__e2e__/agw/create.spec.ts | 87 + .../__e2e__/agw/funding-allowance.spec.ts | 47 + .../core/__e2e__/agw/identity-gas.spec.ts | 164 ++ packages/core/__e2e__/agw/labels.spec.ts | 461 ++++ packages/core/__e2e__/agw/lifecycle.spec.ts | 127 + .../__e2e__/agw/management-extended.spec.ts | 283 +++ .../core/__e2e__/agw/native-batch.spec.ts | 99 + packages/core/__e2e__/agw/native-exec.spec.ts | 101 + .../agw/native-policy-extended.spec.ts | 277 +++ .../core/__e2e__/agw/responses-hooks.spec.ts | 51 + packages/core/__e2e__/agw/svm-public.spec.ts | 445 ++++ .../core/__e2e__/agw/svm-spl-transfer.spec.ts | 224 ++ .../core/__e2e__/agw/universal-evm.spec.ts | 244 ++ .../core/__e2e__/agw/v5-multiasset.spec.ts | 344 +++ packages/core/__e2e__/ci/agw-svm-preflight.ts | 30 + packages/core/__e2e__/ci/preflight.ts | 36 +- packages/core/__e2e__/ci/run.ts | 50 +- packages/core/__e2e__/ci/suite.ts | 389 ++- packages/core/__e2e__/shared/agw-state.ts | 31 + .../__e2e__/shared/agw-svm-policy-probe.ts | 34 + .../core/__e2e__/shared/agw-svm-preflight.ts | 73 + packages/core/jest.agw-local.config.ts | 23 + packages/core/package.json | 6 + .../core/scripts/agw-local/abis/engine.json | 1957 +++++++++++++++ .../core/scripts/agw-local/abis/factory.json | 1105 +++++++++ .../core/scripts/agw-local/abis/manifest.json | 12 + .../core/scripts/agw-local/abis/policy.json | 2157 +++++++++++++++++ .../scripts/agw-local/abis/validator.json | 141 ++ .../core/scripts/agw-local/abis/wallet.json | 1606 ++++++++++++ .../agw-local/contracts/HarnessFixtures.sol | 197 ++ packages/core/scripts/agw-local/gen-abi.mjs | 72 + packages/core/scripts/agw-local/prepare.sh | 48 + packages/core/scripts/agw-verify.js | 74 + packages/core/scripts/check-agw-api.js | 424 ++++ packages/core/scripts/check-agw-docs.js | 50 + packages/core/scripts/check-agw-package.js | 74 + packages/core/scripts/check-agw-runtimes.js | 93 + .../lib/agentic/__fixtures__/v5-vectors.json | 181 ++ .../src/lib/agentic/__tests__/codec.spec.ts | 444 ++++ .../src/lib/agentic/__tests__/fake-chain.ts | 415 ++++ .../src/lib/agentic/__tests__/labels.spec.ts | 115 + .../lib/agentic/__tests__/management.spec.ts | 685 ++++++ .../src/lib/agentic/__tests__/mock-runtime.ts | 102 + .../__tests__/native-batch-response.spec.ts | 119 + .../__tests__/outbound-response.spec.ts | 300 +++ .../agentic/__tests__/reads-context.spec.ts | 548 +++++ .../__tests__/response-surface.spec.ts | 254 ++ .../agentic/__tests__/rules-policy.spec.ts | 348 +++ .../src/lib/agentic/__tests__/send.spec.ts | 680 ++++++ .../lib/agentic/__tests__/svm-backend.spec.ts | 323 +++ .../src/lib/agentic/__tests__/svm-idl.spec.ts | 177 ++ .../agentic/__tests__/svm-internals.spec.ts | 502 ++++ .../lib/agentic/__tests__/svm-quote.spec.ts | 251 ++ .../__tests__/svm-wire-fixture.spec.ts | 59 + packages/core/src/lib/agentic/agentic.ts | 49 + .../core/src/lib/agentic/agentic.types.ts | 281 +++ packages/core/src/lib/agentic/capabilities.ts | 79 + packages/core/src/lib/agentic/chain.ts | 13 + .../core/src/lib/agentic/codec/abi-layout.ts | 139 ++ .../core/src/lib/agentic/codec/defaults.ts | 59 + packages/core/src/lib/agentic/codec/ids.ts | 112 + packages/core/src/lib/agentic/codec/native.ts | 278 +++ packages/core/src/lib/agentic/codec/rules.ts | 198 ++ .../core/src/lib/agentic/codec/selectors.ts | 77 + .../core/src/lib/agentic/codec/session.ts | 116 + .../src/lib/agentic/codec/svm-accounts.ts | 104 + .../core/src/lib/agentic/codec/svm-idl.ts | 269 ++ .../core/src/lib/agentic/codec/svm-terms.ts | 303 +++ .../src/lib/agentic/codec/universal-terms.ts | 60 + .../core/src/lib/agentic/codec/universal.ts | 214 ++ packages/core/src/lib/agentic/constants.ts | 23 + packages/core/src/lib/agentic/context.ts | 78 + .../contracts/abi/v5/agent-validator.ts | 145 ++ .../agentic/contracts/abi/v5/agw-factory.ts | 1104 +++++++++ .../src/lib/agentic/contracts/abi/v5/agw.ts | 1584 ++++++++++++ .../src/lib/agentic/contracts/abi/v5/index.ts | 8 + .../agentic/contracts/abi/v5/smart-session.ts | 1961 +++++++++++++++ .../abi/v5/universal-rules-policy.ts | 2156 ++++++++++++++++ .../src/lib/agentic/contracts/donut-v5.ts | 13 + .../lib/agentic/contracts/prc20-metadata.ts | 128 + .../core/src/lib/agentic/contracts/reader.ts | 42 + packages/core/src/lib/agentic/contracts/v5.ts | 422 ++++ packages/core/src/lib/agentic/deployments.ts | 299 +++ packages/core/src/lib/agentic/errors.ts | 124 + .../core/src/lib/agentic/execution/guards.ts | 46 + .../src/lib/agentic/execution/outbound.ts | 263 ++ .../core/src/lib/agentic/execution/send.ts | 401 +++ .../lib/agentic/execution/svm-instruction.ts | 67 + .../src/lib/agentic/execution/svm-outbound.ts | 102 + .../src/lib/agentic/execution/svm-payload.ts | 146 ++ .../src/lib/agentic/execution/svm-public.ts | 278 +++ .../src/lib/agentic/execution/svm-quote.ts | 51 + .../src/lib/agentic/execution/svm-send.ts | 178 ++ packages/core/src/lib/agentic/index.ts | 33 + .../core/src/lib/agentic/management/common.ts | 145 ++ .../core/src/lib/agentic/management/create.ts | 403 +++ .../core/src/lib/agentic/management/label.ts | 21 + .../src/lib/agentic/management/prepare.ts | 144 ++ .../src/lib/agentic/management/rules-write.ts | 451 ++++ .../src/lib/agentic/management/svm-context.ts | 115 + .../lib/agentic/management/svm-metadata.ts | 75 + .../src/lib/agentic/management/svm-public.ts | 121 + .../core/src/lib/agentic/management/svm.ts | 254 ++ .../core/src/lib/agentic/reads/checkpoints.ts | 85 + packages/core/src/lib/agentic/reads/rules.ts | 398 +++ .../core/src/lib/agentic/reads/snapshot.ts | 112 + packages/core/src/lib/agentic/reads/svm.ts | 98 + .../core/src/lib/agentic/reads/wallets.ts | 198 ++ packages/core/src/lib/agentic/response.ts | 432 ++++ packages/core/src/lib/agentic/revert.ts | 131 + packages/core/src/lib/agentic/runtime.ts | 176 ++ packages/core/src/lib/agentic/utils.ts | 249 ++ packages/core/src/lib/agentic/wallet.ts | 196 ++ packages/core/src/lib/constants/enums.ts | 9 +- packages/core/src/lib/constants/index.ts | 20 +- packages/core/src/lib/constants/read-state.ts | 13 + packages/core/src/lib/index.ts | 45 + .../outbound-svm-confirmation.spec.ts | 127 + .../push-chain-native-multicall.spec.ts | 44 + .../src/lib/orchestrator/internals/errors.ts | 13 +- .../internals/execute-standard.ts | 7 +- .../orchestrator/internals/outbound-sync.ts | 69 +- .../orchestrator/internals/push-chain-tx.ts | 17 +- .../core/src/lib/orchestrator/orchestrator.ts | 23 +- .../lib/orchestrator/orchestrator.types.ts | 9 + .../src/lib/orchestrator/route-detector.ts | 5 +- .../src/lib/orchestrator/svm-idl/resolve.ts | 16 +- .../__tests__/response-invariant.spec.ts | 9 + .../src/lib/progress-hook/progress-hook.ts | 75 + .../lib/progress-hook/progress-hook.types.ts | 21 + .../core/src/lib/push-chain/push-chain.ts | 130 +- .../__tests__/public-surface.spec.ts | 10 +- .../read-state/__tests__/web2-compat.spec.ts | 69 + .../core/src/lib/read-state/destination.ts | 8 +- .../core/src/lib/read-state/read-params.ts | 13 +- .../src/lib/read-state/read-state.types.ts | 4 +- .../core/src/lib/read-state/read-tracker.ts | 6 +- .../core/src/lib/read-state/spec-builder.ts | 4 +- .../core/src/lib/universal/prc20-address.ts | 168 ++ packages/core/src/lib/utils.ts | 187 +- .../vm-client/evm-client.batch-gas.spec.ts | 75 + .../vm-client/evm-client.dynamic-gas.spec.ts | 51 +- packages/core/src/lib/vm-client/evm-client.ts | 44 +- packages/core/tsconfig.agw-local.json | 9 + 168 files changed, 39570 insertions(+), 317 deletions(-) create mode 100644 .github/workflows/agw-sdk-validation.yml create mode 100644 packages/core/AGW.md create mode 100644 packages/core/__agw-local__/agw-local-advanced.spec.ts create mode 100644 packages/core/__agw-local__/agw-local-batch.spec.ts create mode 100644 packages/core/__agw-local__/agw-local-races.spec.ts create mode 100644 packages/core/__agw-local__/agw-local.spec.ts create mode 100644 packages/core/__agw-local__/delayed-index.spec.ts create mode 100644 packages/core/__agw-local__/harness.ts create mode 100644 packages/core/__agw-local__/labels.spec.ts create mode 100644 packages/core/__agw-local__/setup.ts create mode 100644 packages/core/__agw-local__/svm-backend.spec.ts create mode 100644 packages/core/__agw-local__/svm-differential.spec.ts create mode 100644 packages/core/__agw-local__/svm-internals.spec.ts create mode 100644 packages/core/__agw-local__/svm-public.spec.ts create mode 100644 packages/core/__agw-local__/v5-multiasset.spec.ts create mode 100644 packages/core/__agw-local__/vectors.spec.ts create mode 100644 packages/core/__e2e__/agw-svm-wire/outbound.spec.ts create mode 100644 packages/core/__e2e__/agw-svm-wire/replay.spec.ts create mode 100644 packages/core/__e2e__/agw/_fixture.ts create mode 100644 packages/core/__e2e__/agw/_manifest.ts create mode 100644 packages/core/__e2e__/agw/create.spec.ts create mode 100644 packages/core/__e2e__/agw/funding-allowance.spec.ts create mode 100644 packages/core/__e2e__/agw/identity-gas.spec.ts create mode 100644 packages/core/__e2e__/agw/labels.spec.ts create mode 100644 packages/core/__e2e__/agw/lifecycle.spec.ts create mode 100644 packages/core/__e2e__/agw/management-extended.spec.ts create mode 100644 packages/core/__e2e__/agw/native-batch.spec.ts create mode 100644 packages/core/__e2e__/agw/native-exec.spec.ts create mode 100644 packages/core/__e2e__/agw/native-policy-extended.spec.ts create mode 100644 packages/core/__e2e__/agw/responses-hooks.spec.ts create mode 100644 packages/core/__e2e__/agw/svm-public.spec.ts create mode 100644 packages/core/__e2e__/agw/svm-spl-transfer.spec.ts create mode 100644 packages/core/__e2e__/agw/universal-evm.spec.ts create mode 100644 packages/core/__e2e__/agw/v5-multiasset.spec.ts create mode 100644 packages/core/__e2e__/ci/agw-svm-preflight.ts create mode 100644 packages/core/__e2e__/shared/agw-state.ts create mode 100644 packages/core/__e2e__/shared/agw-svm-policy-probe.ts create mode 100644 packages/core/__e2e__/shared/agw-svm-preflight.ts create mode 100644 packages/core/jest.agw-local.config.ts create mode 100644 packages/core/scripts/agw-local/abis/engine.json create mode 100644 packages/core/scripts/agw-local/abis/factory.json create mode 100644 packages/core/scripts/agw-local/abis/manifest.json create mode 100644 packages/core/scripts/agw-local/abis/policy.json create mode 100644 packages/core/scripts/agw-local/abis/validator.json create mode 100644 packages/core/scripts/agw-local/abis/wallet.json create mode 100644 packages/core/scripts/agw-local/contracts/HarnessFixtures.sol create mode 100644 packages/core/scripts/agw-local/gen-abi.mjs create mode 100755 packages/core/scripts/agw-local/prepare.sh create mode 100644 packages/core/scripts/agw-verify.js create mode 100644 packages/core/scripts/check-agw-api.js create mode 100644 packages/core/scripts/check-agw-docs.js create mode 100644 packages/core/scripts/check-agw-package.js create mode 100644 packages/core/scripts/check-agw-runtimes.js create mode 100644 packages/core/src/lib/agentic/__fixtures__/v5-vectors.json create mode 100644 packages/core/src/lib/agentic/__tests__/codec.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/fake-chain.ts create mode 100644 packages/core/src/lib/agentic/__tests__/labels.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/management.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/mock-runtime.ts create mode 100644 packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/reads-context.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/response-surface.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/send.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/svm-backend.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/svm-idl.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/svm-wire-fixture.spec.ts create mode 100644 packages/core/src/lib/agentic/agentic.ts create mode 100644 packages/core/src/lib/agentic/agentic.types.ts create mode 100644 packages/core/src/lib/agentic/capabilities.ts create mode 100644 packages/core/src/lib/agentic/chain.ts create mode 100644 packages/core/src/lib/agentic/codec/abi-layout.ts create mode 100644 packages/core/src/lib/agentic/codec/defaults.ts create mode 100644 packages/core/src/lib/agentic/codec/ids.ts create mode 100644 packages/core/src/lib/agentic/codec/native.ts create mode 100644 packages/core/src/lib/agentic/codec/rules.ts create mode 100644 packages/core/src/lib/agentic/codec/selectors.ts create mode 100644 packages/core/src/lib/agentic/codec/session.ts create mode 100644 packages/core/src/lib/agentic/codec/svm-accounts.ts create mode 100644 packages/core/src/lib/agentic/codec/svm-idl.ts create mode 100644 packages/core/src/lib/agentic/codec/svm-terms.ts create mode 100644 packages/core/src/lib/agentic/codec/universal-terms.ts create mode 100644 packages/core/src/lib/agentic/codec/universal.ts create mode 100644 packages/core/src/lib/agentic/constants.ts create mode 100644 packages/core/src/lib/agentic/context.ts create mode 100644 packages/core/src/lib/agentic/contracts/abi/v5/agent-validator.ts create mode 100644 packages/core/src/lib/agentic/contracts/abi/v5/agw-factory.ts create mode 100644 packages/core/src/lib/agentic/contracts/abi/v5/agw.ts create mode 100644 packages/core/src/lib/agentic/contracts/abi/v5/index.ts create mode 100644 packages/core/src/lib/agentic/contracts/abi/v5/smart-session.ts create mode 100644 packages/core/src/lib/agentic/contracts/abi/v5/universal-rules-policy.ts create mode 100644 packages/core/src/lib/agentic/contracts/donut-v5.ts create mode 100644 packages/core/src/lib/agentic/contracts/prc20-metadata.ts create mode 100644 packages/core/src/lib/agentic/contracts/reader.ts create mode 100644 packages/core/src/lib/agentic/contracts/v5.ts create mode 100644 packages/core/src/lib/agentic/deployments.ts create mode 100644 packages/core/src/lib/agentic/errors.ts create mode 100644 packages/core/src/lib/agentic/execution/guards.ts create mode 100644 packages/core/src/lib/agentic/execution/outbound.ts create mode 100644 packages/core/src/lib/agentic/execution/send.ts create mode 100644 packages/core/src/lib/agentic/execution/svm-instruction.ts create mode 100644 packages/core/src/lib/agentic/execution/svm-outbound.ts create mode 100644 packages/core/src/lib/agentic/execution/svm-payload.ts create mode 100644 packages/core/src/lib/agentic/execution/svm-public.ts create mode 100644 packages/core/src/lib/agentic/execution/svm-quote.ts create mode 100644 packages/core/src/lib/agentic/execution/svm-send.ts create mode 100644 packages/core/src/lib/agentic/index.ts create mode 100644 packages/core/src/lib/agentic/management/common.ts create mode 100644 packages/core/src/lib/agentic/management/create.ts create mode 100644 packages/core/src/lib/agentic/management/label.ts create mode 100644 packages/core/src/lib/agentic/management/prepare.ts create mode 100644 packages/core/src/lib/agentic/management/rules-write.ts create mode 100644 packages/core/src/lib/agentic/management/svm-context.ts create mode 100644 packages/core/src/lib/agentic/management/svm-metadata.ts create mode 100644 packages/core/src/lib/agentic/management/svm-public.ts create mode 100644 packages/core/src/lib/agentic/management/svm.ts create mode 100644 packages/core/src/lib/agentic/reads/checkpoints.ts create mode 100644 packages/core/src/lib/agentic/reads/rules.ts create mode 100644 packages/core/src/lib/agentic/reads/snapshot.ts create mode 100644 packages/core/src/lib/agentic/reads/svm.ts create mode 100644 packages/core/src/lib/agentic/reads/wallets.ts create mode 100644 packages/core/src/lib/agentic/response.ts create mode 100644 packages/core/src/lib/agentic/revert.ts create mode 100644 packages/core/src/lib/agentic/runtime.ts create mode 100644 packages/core/src/lib/agentic/utils.ts create mode 100644 packages/core/src/lib/agentic/wallet.ts create mode 100644 packages/core/src/lib/orchestrator/__tests__/outbound-svm-confirmation.spec.ts create mode 100644 packages/core/src/lib/read-state/__tests__/web2-compat.spec.ts create mode 100644 packages/core/src/lib/universal/prc20-address.ts create mode 100644 packages/core/src/lib/vm-client/evm-client.batch-gas.spec.ts create mode 100644 packages/core/tsconfig.agw-local.json diff --git a/.github/workflows/agw-sdk-validation.yml b/.github/workflows/agw-sdk-validation.yml new file mode 100644 index 000000000..af1d14ef7 --- /dev/null +++ b/.github/workflows/agw-sdk-validation.yml @@ -0,0 +1,45 @@ +name: AGW SDK validation (unfunded) +on: + pull_request: + paths: ['packages/core/**', 'tsconfig.base.json', 'yarn.lock', '.github/workflows/agw-sdk-validation.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: agw-validation-${{ github.ref }} + cancel-in-progress: true +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + NX_DAEMON: 'false' + AGW_REPO: ${{ github.workspace }}/.ci-contracts + AGW_LOCAL_DIR: ${{ runner.temp }}/agw-v5-build + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/checkout@v5 + with: + repository: pushchain/push-agentic-wallets + ref: 2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb + path: .ci-contracts + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24.18.0' + package-manager-cache: false + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + - run: corepack enable + - run: yarn install --immutable + - name: Unit, contracts, types, docs and package checks + run: node packages/core/scripts/agw-verify.js + - uses: actions/upload-artifact@v4 + if: always() + with: + name: agw-unfunded-validation + path: .agw-validation/ + if-no-files-found: ignore diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a4794fe4f..e4e2eee74 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,6 +25,7 @@ on: - cross-chain - read - known-fail + - agw dry_run: description: 'Pre-flight only — report balances, fund nothing, run no tests' required: true diff --git a/.gitignore b/.gitignore index 204990810..a3622c685 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ e2e-logs/ /packages/core/outbound-test-logs /packages/core/__e2e__/graphify-out /graphify-out +.agw-validation/ +.ci-contracts/ +.agw-runtime-validation/ diff --git a/packages/core/.eslintrc.json b/packages/core/.eslintrc.json index 4096b2865..cc65fdede 100644 --- a/packages/core/.eslintrc.json +++ b/packages/core/.eslintrc.json @@ -23,6 +23,9 @@ { "ignoredFiles": [ "{projectRoot}/eslint.config.{js,cjs,mjs}", + "{projectRoot}/scripts/check-agw-docs.js", + "{projectRoot}/scripts/check-agw-package.js", + "{projectRoot}/scripts/check-agw-api.js", "{projectRoot}/**/*.spec.ts" ], "ignoredDependencies": [ diff --git a/packages/core/AGW.md b/packages/core/AGW.md new file mode 100644 index 000000000..8945327ba --- /dev/null +++ b/packages/core/AGW.md @@ -0,0 +1,366 @@ +# Agentic wallets on Push Chain + +This guide describes AGW v5 in this SDK branch. Donut is the configured deployment. Native Push, EVM destination rules and named-IDL Solana devnet rules are supported. Solana-origin signers can already act through their Push UEA. + +An AGW holds assets. Its owner grants or revokes rules and can execute unrestricted owner calls. An agent executes through its own Push identity, and each agent call must pass the granted policy. A Push-native key's identity is its EOA; an external key's identity is its Push UEA for that origin chain. + +```mermaid +flowchart LR + Owner[Owner EOA or UEA] -->|create, grant, update, revoke| Wallet[AGW] + Owner -->|owner execution| Wallet + Agent[Agent EOA or UEA] -->|agent execution| Wallet + Wallet -->|agent calls checked| Policy[Engine and rules policy] + Policy --> Native[Push target] + Policy --> Gateway[Gateway and destination account] +``` + +## Connect a Push-native signer + +Pass a key from your application's key management. The functions below do not load `.env` files or run automatically. They use `viem` and the SDK's public exports. From the SDK checkout, check all TypeScript blocks together with `node packages/core/scripts/check-agw-docs.js` (or `yarn docs:check:agw` from packages/core). + +```ts +import { + PushChain, + AgenticError, + type NativeRule, + type UniversalRule, + type SolanaRule, +} from '@pushchain/core'; +import { + createWalletClient, + http, + parseEther, + encodeFunctionData, + erc20Abi, + type Address, + type Hex, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +type WalletTransaction = Awaited>; + +const NETWORK = PushChain.CONSTANTS.PUSH_NETWORK.TESTNET_DONUT; +const PUSH = PushChain.CONSTANTS.CHAIN.PUSH_TESTNET_DONUT; +const SEPOLIA = PushChain.CONSTANTS.CHAIN.ETHEREUM_SEPOLIA; +const ETH = PushChain.CONSTANTS.MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.ETH; + +async function connectPush(key: Hex, wallet?: Address): Promise { + const walletClient = createWalletClient({ + account: privateKeyToAccount(key), + transport: http('https://evm.donut.rpc.push.org/'), + }); + const signer = await PushChain.utils.signer.toUniversalFromKeypair( + walletClient, + { chain: PUSH, library: PushChain.CONSTANTS.LIBRARY.ETHEREUM_VIEM }, + ); + return PushChain.initialize(signer, { + network: NETWORK, + ...(wallet ? { agenticWallet: wallet } : {}), + }); +} +``` + +`connectPush(ownerKey)` creates an ordinary owner client for management and funding. `connectPush(key, wallet)` sends from that AGW. The SDK determines the owner or agent door from the connected signer; the caller does not select a door. + +## Create a wallet and grant a native rule + +This example permits ten value transfers to one recipient before expiry, with explicit per-call and total limits. Explicit limits avoid relying on native omission defaults that are still being finalized. + +```ts +async function createNativeWallet( + owner: PushChain, + agent: Address, + recipient: Address, +) { + const rule: NativeRule = { + agent, + target: recipient, + selector: 'value-only', + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxValuePerCall: parseEther('0.001'), + maxValueTotal: parseEther('0.01'), + maxCalls: 10, + }; + const prediction = await owner.agentic.derive(); + const created = await owner.agentic.create('native-transfers', { + rules: [rule], + }); + return { prediction, created, rule }; +} +``` + +Create confirms deployment/grant receipts and returns `wallet`, `index`, `rulesIds` and transaction metadata. It does not fund the wallet or agent. A concurrent owner creation can invalidate a predicted index; the index-bound deployment refuses that race instead of granting on another wallet. + +## Fund separately, then send as the agent + +The wallet pays the transfer value. The agent pays Push transaction gas from its own EOA/UEA. Cross-chain sends also need PC in the wallet for the outbound budget. These are separate balances. + +```ts +async function fundAndSendNative( + owner: PushChain, + wallet: Address, + agentKey: Hex, + recipient: Address, +) { + await (await owner.universal.sendTransaction({ + to: wallet, + value: parseEther('0.02'), + })).wait(); + // Fund the agent's Push identity separately if it needs transaction gas. + const agent = await connectPush(agentKey, wallet); + const response = await agent.universal.sendTransaction({ + to: recipient, + value: parseEther('0.001'), + }); + return response.wait(); +} +``` + +For an external signer, grant to its derived Push UEA rather than its origin-chain address. Use `PushChain.utils.account.deriveExecutorAccount` with the signer’s actual origin chain. First-use external UEA gas handling uses that origin; changing the origin chain changes the UEA identity. + +Native agent arrays use an atomic UEA/EIP-7702 transport that preserves the agent sender. Each action is checked individually. An unavailable atomic transport fails explicitly; the SDK does not submit the agent batch sequentially. + +## Read, replace and revoke rules + +A management handle is `owner.agentic.wallet(wallet)`. It reads and writes rules but does not switch the owner's transaction execution identity to the wallet. + +```ts +async function inspectWallet(owner: PushChain, wallet: Address) { + const handle = owner.agentic.wallet(wallet); + const [info, ownerRecord, rules] = await Promise.all([ + handle.info(), handle.owner(), handle.rules.list(), + ]); + return { info, owner: ownerRecord.owner, rules: rules.rules }; +} + +async function replaceNativeRule( + owner: PushChain, + wallet: Address, + rulesId: Hex, + replacement: NativeRule, +) { + return owner.agentic.wallet(wallet).rules.update({ + rules: [{ rulesId, rule: replacement }], + }); +} + +async function revokeRule(owner: PushChain, wallet: Address, rulesId: Hex) { + return (await owner.agentic.wallet(wallet).rules.revoke([rulesId])).wait(); +} +``` + +Replacement asserts the old spend, revokes and grants in one owner transaction. Intervening agent spending makes the whole replacement revert. The new rule gets a new ID and fresh counters. A single replacement records five checkpoints. Failed transactions unwind their checkpoints; agent calls do not tick the owner checkpoint count. + +`rules.list()` returns enabled rules, including enabled rules whose expiry has passed. Expiry is enforced on execution. Revoked-history reconstruction is outside v1. Rule IDs are wallet-scoped; do not treat an ID alone as a globally unique permission. + +## Wallet labels + +Donut uses the v5 factory `0x8137F96A50EBF41d904e3678c84c391a0D1BCcc5`. Wallet labels are stored on-chain; `info()` and `list()` read the current value. An empty deployment label defaults to `AGW `. + +```ts +async function renameWallet(owner: PushChain, wallet: Address) { + const handle = owner.agentic.wallet(wallet); + const renamed = await handle.setLabel('Trading'); + const info = await handle.info(); + const reset = await handle.setLabel(''); + return { renamed, info, reset }; +} +``` + +Create and rename labels accept at most **64 UTF-8 bytes**, including spaces; 16 four-byte emoji fit. Empty rename resets the default. The SDK renames through the owner door for EOA and UEA owners, emits progress through the supplied hook and records one owner-action checkpoint. The contract's direct `setLabel` call itself records no checkpoint. Agent and read-only clients cannot rename. + +New wallets use v5 only. Existing v4 wallets do not acquire label functions and are unsupported by this SDK adapter. Wallet predictions and EIP-712 intent domains use the new factory; do not reuse old predicted addresses or signatures. On relayed signed deployment, the contract does not include the cosmetic label in `OwnerIntent`; the owner can correct it afterward. + +## Grant an EVM destination rule + +The destination token identifies an asset on that destination chain. The SDK resolves the mapped PRC20 held by the AGW. Limits use the token's smallest units; `maxGasPerCall` uses PC wei. A rule may list several assets, but each outbound request moves one listed token. + +The example below bridges at most 0.000001 ETH per call and permits a counter's `increment()` call. The explicit zero-value call leaves the bridged ETH in the wallet's CEA. Use a target that actually implements that selector. + +```ts +async function createSepoliaWallet( + owner: PushChain, + agent: Address, + counter: Address, +) { + const rule: UniversalRule = { + agent, + chainNamespace: SEPOLIA, + assets: [{ + token: ETH, + maxPerCall: BigInt(10) ** BigInt(12), + maxTotal: BigInt(10) ** BigInt(13), + }], + maxGasPerCall: parseEther('20'), + validUntil: Math.floor(Date.now() / 1000) + 3600, + allowedCalls: [{ target: counter, selector: 'increment()', maxValue: BigInt(0) }], + }; + return owner.agentic.create('sepolia-counter', { rules: [rule] }); +} +``` + +An omitted universal asset total uses uint256 maximum; explicit zero forbids movement. `assets: []` is a call-only rule, represented internally by a zero-cap destination gas token. The SDK derives `expectedCEA` from the wallet, not the agent. + +## Fund the PRC20 and set a bounded allowance + +The gateway pulls the selected PRC20 from the AGW. Set the allowance through the owner door as a separate, explicit action. Neither owner nor agent outbound sends change allowances automatically. + +```ts +async function prepareSepoliaWallet( + owner: PushChain, + ownerKey: Hex, + wallet: Address, + amount: bigint, +) { + const prc20 = PushChain.utils.tokens.getPRC20Address(ETH, { network: NETWORK }); + // The ordinary owner account must already hold the mapped PRC20. + await (await owner.universal.sendTransaction({ + to: wallet, value: parseEther('21'), + })).wait(); + await (await owner.universal.sendTransaction({ + to: prc20.address, + data: encodeFunctionData({ + abi: erc20Abi, functionName: 'transfer', args: [wallet, amount], + }), + })).wait(); + const ownerDoor = await connectPush(ownerKey, wallet); + const gateway = '0x00000000000000000000000000000000000000C1' as Address; + await (await ownerDoor.universal.sendTransaction({ + to: prc20.address, + data: encodeFunctionData({ + abi: erc20Abi, functionName: 'approve', args: [gateway, amount], + }), + })).wait(); +} + +async function sendToSepolia(agent: PushChain, counter: Address, amount: bigint) { + return agent.universal.sendTransaction({ + to: { address: counter, chain: SEPOLIA }, + data: [{ to: counter, value: BigInt(0), data: '0xd09de08a' }], + funds: { amount, token: ETH }, + gasLimit: BigInt(2_000_000), // includes fresh CEA finalization in this example + }); +} +``` + +The current core default of 500,000 destination gas was insufficient for fresh Sepolia CEA finalization in our live test (~1.3 million estimated). The example supplies a bounded two-million-gas limit; choose a budget appropriate for your destination calls. The 20/21-PC values are example budgets, not fee estimates. Fees and gas are quoted for the current request. Approval safety is the application's responsibility; the SDK validates structure and contract limits rather than deciding whether a grant is appropriate. + +## Wait, replay and recover + +`from` is the AGW and `origin` is the signer. For EVM outbounds, `to/data/value` describe the first destination call; `agentic.destinationCalls` preserves all destination calls. `agentic.rawTo/rawData` retain the wrapped Push call. + +```ts +async function waitForDelivery(response: WalletTransaction) { + const receipt = await response.wait({ outboundTimeoutMs: 600_000 }); + return { + pushSucceeded: receipt.status === 1, + destinationStatus: receipt.externalStatus, + externalTxHash: receipt.externalTxHash, + }; +} + +async function replay(owner: PushChain, hash: string) { + const response = await owner.universal.trackTransaction(hash); + return response.wait({ outboundTimeoutMs: 600_000 }); +} + +function recoveryDetails(error: unknown) { + if (!(error instanceof AgenticError)) throw error; + return { code: error.code, hint: error.hint, details: error.details }; +} +``` + +A successful Push receipt does not mean destination success. Check `externalStatus`. A timeout can mean the relay is still processing; track the same hash rather than assuming the transaction failed. Reverted Push roots do not start destination polling. Wrapped outbounds are recognized from their calldata even if the indexer initially reports only the Push leg. + +| Result | Application action | +| --- | --- | +| `INDEX_RACE` | This create committed nothing; derive/retry the next slot. | +| `CREATE_PARTIAL` | Inspect `details.walletDeployed`, `grantedRulesIds`, `confirmedHashes` and `pendingHash`. Reconcile unknown/pending outcomes. If deployed, finish missing grants with `rules.add`; do not create a replacement wallet blindly. | +| `NO_RULES_FOR_CHAIN` | Grant a suitable enabled rule before the agent sends. | +| `AMBIGUOUS_RULE` | Multiple enabled rules match the agent and chain. No transaction is submitted. Inspect `details.rulesIds` and have the owner resolve the overlap; the SDK does not pick or revoke a rule arbitrarily. | +| `GATEWAY_ALLOWANCE_INSUFFICIENT` | The owner must explicitly establish a sufficient allowance. | +| `CAPABILITY_UNAVAILABLE` / `GENERATION_UNSUPPORTED` | Inspect the capability/deployment details; no compatible operation was submitted. | + +Ordinary returned funds do not lower policy spend. `creditRevert` still needs platform executor support. Revoking rules stops agent authorization but does not remove ERC20 allowances; the owner manages those separately. + +## Solana rules using named IDL inputs + +Use `SolanaRule` with an Anchor IDL, instruction name, named account restrictions and field constraints. Addresses accept base58 or 32-byte hex. `walletCEA` and `walletATA` references are derived from the AGW and configured cluster; declare output mints in `outputTokens` when their wallet ATAs also need protection. + +```ts +import type { Idl } from '@coral-xyz/anchor'; + +async function createSolanaWallet( + owner: PushChain, agent: Address, program: string, + idl: Idl, recipient: string, counter: string, +) { + const rule: SolanaRule = { + agent, + chainNamespace: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET, + validUntil: Math.floor(Date.now() / 1000) + 3600, + assets: [{ token: '0x0000000000000000000000000000000000000000', maxPerCall: BigInt(10_000), maxTotal: BigInt(20_000) }], + maxGasPerCall: parseEther('20'), + allowedInstructions: [{ + program, + instruction: { idl, name: 'receive_sol' }, + accounts: [ + { name: 'counter', expected: { kind: 'address', address: counter } }, + { name: 'recipient', expected: { kind: 'address', address: recipient } }, + { name: 'cea_authority', expected: { kind: 'walletCEA' } }, + { name: 'system_program', expected: { kind: 'address', address: '11111111111111111111111111111111' } }, + ], + fields: [{ name: 'amount', max: BigInt(10_000) }], + }], + }; + return owner.agentic.create('solana-wallet', { rules: [rule] }); +} + +async function sendSolanaInstruction(agent: PushChain, program: string, idl: Idl) { + const data = PushChain.utils.helpers.encodeTxData({ + abi: idl, functionName: 'receive_sol', args: [BigInt(10_000)], + }); + return agent.universal.sendTransaction({ + to: { address: program, chain: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET }, + data, value: BigInt(10_000), + }); +} +``` + +These examples assume the supplied IDL describes `receive_sol` with those accounts and a `u64` amount. The send resolver needs matching account addresses/PDA definitions in its IDL. Fund the wallet and set a separate bounded gateway allowance before sending. Native SOL amounts are lamports; the wallet burns its corresponding PRC20. `assets: []` creates a zero-amount gas-token routing cap. + +Supported rule layouts have an eight-byte discriminator, top-level non-optional accounts, and fixed-width primitive/fixed-array arguments. Field comparisons support exact bytes (at most 32 bytes), unsigned integer min/max (up to `u64`), and same-width unsigned ratio floors. Variable-length or nested/defined argument layouts and optional/nested accounts are rejected before signing. An IDL describes encoding; it does not prove what the program does with those accounts. + +Agent SVM sends require one instruction encoded with `encodeTxData` (which registers the IDL), or data matching an already registered IDL. Instruction arrays remain unsupported. Owners can also send funds without any IDL or instruction: + +```ts +async function transferSolanaFunds(ownerWallet: PushChain, recipient: string) { + return ownerWallet.universal.sendTransaction({ + to: { address: recipient, chain: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET }, + value: BigInt(10_000), // native SOL, in lamports + }); +} + +async function transferSolanaUsdt(ownerWallet: PushChain, recipient: string) { + return ownerWallet.universal.sendTransaction({ + to: { address: recipient, chain: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET }, + funds: { + token: PushChain.CONSTANTS.MOVEABLE.TOKEN.SOLANA_DEVNET.USDT, + amount: BigInt(1), // 0.000001 USDT at six decimals + }, + }); +} +``` + +`ownerWallet` is initialized with the owner signer and `agenticWallet`. As with instruction sends, fund the wallet with PC/PRC20 and establish a separate bounded gateway allowance. Transfers use an empty payload and the requested recipient; the SDK applies core’s Solana finalization/rent budget using the wallet identity. Send either `value` for SOL or `funds` for a token, not both. A zero-amount funds-only send is rejected. + +Funds-only responses expose `agentic.destinationTransfer` with the recipient, burned Push PRC20 token, and amount. They have no `destinationInstruction`; replay uses the same representation. Native SOL transfers show their amount in `value`, while SPL transfers carry the amount in transfer metadata and show zero native value. + +Structural/context/funding failures are SDK `AgenticError`s. Granted permissions, expiry and spend limits are enforced by the contract. Refusals obtained during contract simulation or execution are mapped to `AgenticRevertError` with `decodedError`; simulation failure does not broadcast a transaction. Reads return `DecodedSolanaRule` with `format: 'decoded'` and exact stored program/account/data constraints. They cannot reconstruct an IDL; use a named `SolanaRule` input to add or replace it. Native/EVM decoded rules remain reusable in raw-offset form. + +Responses expose `agentic.destinationInstruction` and the wallet’s destination CEA. Check `externalStatus` and replay the same transaction hash after a timeout. + +## Current API boundaries + +Rule `ref` is removed from public inputs and records; supplying it is rejected. Checkpoint `ref` is a separate existing event field and is preserved. Editable labels are implemented against the checked v5 deployment. Contract permission enforcement is already present: unauthorized actions under the supplied rule are rejected, and the SDK maps the errors. Revoked history, public spend records, public generation context and `compileCard` are outside standalone v1. Omitted native PC value limits are zero. Native argument-index and raw-offset inputs are both supported; decoded native/EVM rules preserve exact offsets. + +Use `PushChain.CONSTANTS.READ.CHAIN.WEB2` for Web2 reads. Its value is `web2`; the old literal `web2:https` is still accepted and normalizes to the same wire identity. Literal comparisons against the older spelling need updating. Web2 is a read source, not a transaction destination. diff --git a/packages/core/README.md b/packages/core/README.md index e19eeebee..d65a3a008 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,6 +8,8 @@ Push Chain is a true universal L1 that is 100% EVM compatible. It allows develop ## Documentation +For AGW v5 in this branch, see the [agentic wallet integration guide](AGW.md) for creation, rules, funding, execution and recovery. + For full documentation, API references, and guides, visit: 👉 [Push Chain Developer Docs](https://push.org/docs) diff --git a/packages/core/__agw-local__/agw-local-advanced.spec.ts b/packages/core/__agw-local__/agw-local-advanced.spec.ts new file mode 100644 index 000000000..83817f1bb --- /dev/null +++ b/packages/core/__agw-local__/agw-local-advanced.spec.ts @@ -0,0 +1,610 @@ +import { readNativeCounters } from '../__e2e__/shared/agw-state'; +/** + * Real-contract checks for the harder AGW paths (pinned v5 on anvil). + * The universal-outbound block drives the SDK's composer with a runtime whose + * CEA and fee quote are local stand-ins (no destination chain or price feeds + * exist here); URP still decides every gate on the composed request. + */ +import { + encodeAbiParameters, + encodeFunctionData, + getAddress, + keccak256, + parseAbi, + toBytes, + type Address, + type Hex, +} from 'viem'; +import { AgenticRevertError, CHAIN, type PushChain } from '../src'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../src/lib/agentic/errors'; +import { agenticSend } from '../src/lib/agentic/execution/send'; +import { updateRules } from '../src/lib/agentic/management/rules-write'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; +import type { AgenticExecutionContext } from '../src/lib/agentic/context'; +import { buildSession, encodeEnvelope } from '../src/lib/agentic/codec/session'; +import { encodeUniversalTerms } from '../src/lib/agentic/codec/universal-terms'; +import { startHarness, type Harness } from './harness'; + +const TARGET_ABI = parseAbi([ + 'function increment()', + 'function deposit(address beneficiary, uint256 amount) payable', + 'function lastBeneficiary() view returns (address)', + 'function counter() view returns (uint256)', +]); +const TOKEN_ABI = parseAbi([ + 'function mint(address to, uint256 amount)', + 'function balanceOf(address) view returns (uint256)', + 'function allowance(address,address) view returns (uint256)', + 'function approve(address,uint256) returns (bool)', + 'function totalSupply() view returns (uint256)', +]); +const GATEWAY_ABI = parseAbi([ + 'function outboundCount() view returns (uint256)', + 'function lastSender() view returns (address)', + 'function lastRequestHash() view returns (bytes32)', +]); +const CORE_ABI = parseAbi([ + 'function getOutboundTxGasAndFees(address,uint256) view returns (address,uint256,uint256,uint256,string,uint256)', +]); + +type Internals = { + agenticRuntime: AgenticRuntime; + agenticContext?: AgenticExecutionContext; +}; +const internals = (c: PushChain) => c as unknown as Internals; + +async function chainNow(h: Harness): Promise { + return Number((await h.publicClient.getBlock()).timestamp); +} +async function warp(h: Harness, seconds: number): Promise { + await h.publicClient.request({ + method: 'evm_increaseTime' as never, + params: [seconds] as never, + }); + await h.publicClient.request({ + method: 'evm_mine' as never, + params: [] as never, + }); +} +const fund = (h: Harness, to: Address, value: bigint) => + h.wallets[4].sendTransaction({ + to, + value, + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }); + +describe('AGW advanced paths against real v5 contracts', () => { + let h: Harness; + let owner: PushChain; + let agentAddr: Address; + + beforeAll(async () => { + h = await startHarness(18547); + owner = await h.client(0); + agentAddr = h.wallets[1].account!.address; + }); + afterAll(async () => { + await h?.stop(); + }); + + describe('native pins, amount and value caps', () => { + let wallet: Address; + let agent: PushChain; + const beneficiary = getAddress( + '0x00000000000000000000000000000000000b0b0b' + ); + + beforeAll(async () => { + const created = await owner.agentic.create('pins', { + rules: [ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'deposit(address,uint256)', + validUntil: (await chainNow(h)) + 3600, + pins: [{ arg: 0, expected: beneficiary }], + amount: { arg: 1, maxPerCall: BigInt(10), maxTotal: BigInt(15) }, + maxValuePerCall: BigInt(1000), + maxValueTotal: BigInt(1000), + }, + ], + }); + wallet = created.wallet; + await h.publicClient.waitForTransactionReceipt({ + hash: await fund(h, wallet, BigInt(10) ** BigInt(18)), + }); + agent = await h.client(1, { agenticWallet: wallet }); + }); + + const deposit = (to: Address, amount: bigint, value = BigInt(0)) => + agent.universal.sendTransaction({ + to: h.addresses.target, + value, + data: encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'deposit', + args: [to, amount], + }), + }); + + it('a pinned beneficiary and an in-cap amount pass', async () => { + await (await deposit(beneficiary, BigInt(5), BigInt(10))).wait(); + expect( + await h.publicClient.readContract({ + address: h.addresses.target, + abi: TARGET_ABI, + functionName: 'lastBeneficiary', + }) + ).toBe(beneficiary); + }); + + it('a wrong beneficiary is rejected by URP with a decoded error and no state change', async () => { + const err = await deposit(agentAddr, BigInt(5)).catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(err.decodedError?.name).toBe( + 'PolicyCheckReverted(ArgPinMismatch)' + ); + const rec = await owner.agentic.wallet(wallet).rules.list(); + expect(rec.rules[0]).not.toHaveProperty('spent'); + expect( + await readNativeCounters( + h.publicClient, + h.generation.addresses, + wallet, + rec.rules[0].rulesId + ) + ).toMatchObject({ amountSpent: BigInt(5), callsUsed: 1 }); + }); + + it('per-call and lifetime amount caps are enforced on-chain', async () => { + await expect(deposit(beneficiary, BigInt(11))).rejects.toBeInstanceOf( + AgenticRevertError + ); + await (await deposit(beneficiary, BigInt(10))).wait(); + await expect(deposit(beneficiary, BigInt(1))).rejects.toBeInstanceOf( + AgenticRevertError + ); + }); + + it('the decoded native terms carry the pins and caps as stored', async () => { + const { rules } = await owner.agentic.wallet(wallet).rules.list(); + expect(rules[0].rule).toMatchObject({ + pins: [ + { + offset: 4, + expected: `0x${'0'.repeat(24)}${beneficiary + .slice(2) + .toLowerCase()}`, + }, + ], + amount: { offset: 36, maxPerCall: BigInt(10), maxTotal: BigInt(15) }, + maxValuePerCall: BigInt(1000), + }); + }); + }); + + describe('stale-spend replacement race', () => { + it('an agent spend between the read and inclusion reverts the whole replacement atomically', async () => { + const created = await owner.agentic.create('race', { + rules: [ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: (await chainNow(h)) + 3600, + }, + ], + }); + const wallet = created.wallet; + const oldId = created.rulesIds[0]; + const agent = await h.client(1, { agenticWallet: wallet }); + const rt = internals(owner).agenticRuntime; + const cpBefore = await h.publicClient.readContract({ + address: wallet, + abi: parseAbi(['function checkpointCount() view returns (uint64)']), + functionName: 'checkpointCount', + }); + const racing: AgenticRuntime = { + ...rt, + reader: rt.reader, + execute: async (params, options) => { + // The agent spends after the SDK read the counters, before the owner's tx lands. + await ( + await agent.universal.sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'increment', + }), + }) + ).wait(); + return rt.execute(params, options); + }, + }; + const err = await updateRules(racing, h.generation, wallet, { + rules: [ + { + rulesId: oldId, + rule: { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: (await chainNow(h)) + 3600, + }, + }, + ], + }).catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + // Nothing changed: the old rule is still the only enabled rule and no checkpoint landed. + const { rules } = await owner.agentic.wallet(wallet).rules.list(); + expect(rules.map((r) => r.rulesId)).toEqual([oldId]); + expect( + await h.publicClient.readContract({ + address: wallet, + abi: parseAbi(['function checkpointCount() view returns (uint64)']), + functionName: 'checkpointCount', + }) + ).toBe(cpBefore); + }); + }); + + describe('expiry', () => { + it('an expired-but-enabled rule still identifies the agent; the send fails on-chain', async () => { + const created = await owner.agentic.create('expiring', { + rules: [ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: (await chainNow(h)) + 600, + }, + ], + }); + await warp(h, 1200); + const agent = await h.client(1, { agenticWallet: created.wallet }); + const err = await agent.universal + .sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'increment', + }), + }) + .catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(err.decodedError?.name).toBe('PolicyCheckReverted(RulesExpired)'); + }); + }); + + describe('sequential create partial failure and recovery', () => { + it('reports CREATE_PARTIAL with the deployed wallet and granted IDs; rules.add finishes it', async () => { + // Chain time runs ahead of the local clock: the SDK accepts validUntil, the policy does not. + const skewedValidUntil = Math.floor(Date.now() / 1000) + 600; + await warp(h, 7200); + const err = await owner.agentic + .create('partial', { + rules: [ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: (await chainNow(h)) + 3600, + }, + { + agent: h.wallets[3].account!.address, + target: h.addresses.target, + selector: 'increment()', + validUntil: skewedValidUntil, + }, + ], + }) + .catch((e) => e); + expect(err).toBeInstanceOf(AgenticError); + expect(err.code).toBe(AGENTIC_ERROR_CODE.CREATE_PARTIAL); + expect(err.details.walletDeployed).toBe(true); + expect(err.details.grantedRulesIds).toHaveLength(1); + expect(err.details.confirmedHashes.length).toBeGreaterThanOrEqual(2); + const wallet = err.details.wallet as Address; + const added = await owner.agentic.wallet(wallet).rules.add([ + { + agent: h.wallets[3].account!.address, + target: h.addresses.target, + selector: 'increment()', + validUntil: (await chainNow(h)) + 3600, + }, + ]); + expect(added.rulesIds).toHaveLength(1); + const { rules } = await owner.agentic.wallet(wallet).rules.list(); + expect(rules).toHaveLength(2); + }); + + it('rules.add with several rules is one atomic owner batch: OWNER_ACTION + RULES_GRANTED per rule', async () => { + const created = await owner.agentic.create('multi-add', { rules: [] }); + const before = await h.publicClient.readContract({ + address: created.wallet, + abi: parseAbi(['function checkpointCount() view returns (uint64)']), + functionName: 'checkpointCount', + }); + const added = await owner.agentic.wallet(created.wallet).rules.add([ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: (await chainNow(h)) + 3600, + }, + { + agent: h.wallets[3].account!.address, + target: h.addresses.target, + selector: 'increment()', + validUntil: (await chainNow(h)) + 3600, + }, + ]); + expect(added.rulesIds).toHaveLength(2); + expect(added.tx.atomic).toBe(true); + const { checkpoints } = await owner.agentic + .wallet(created.wallet) + .checkpoints({ sinceBlock: added.tx.blockNumber }); + expect(BigInt(checkpoints.length)).toBe(BigInt(4)); + expect(checkpoints.map((c) => c.kind)).toEqual([ + 'OWNER_ACTION', + 'RULES_GRANTED', + 'OWNER_ACTION', + 'RULES_GRANTED', + ]); + expect(before).toBe(BigInt(0)); + }); + }); + + describe('agent EVM outbound (v5 multi-asset terms, stub gateway)', () => { + const SEPOLIA = CHAIN.ETHEREUM_SEPOLIA; + const CEA = getAddress('0x000000000000000000000000000000000000cea1'); + const destTarget = getAddress('0x00000000000000000000000000000000000d0d0d'); + let token: Address; + let wallet: Address; + let rulesId: Hex; + let runtime: AgenticRuntime; + let ctx: AgenticExecutionContext; + let ownerAgw: PushChain; + + beforeAll(async () => { + token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + SEPOLIA, + 6, + ]); + const created = await owner.agentic.create('outbound', { rules: [] }); + wallet = created.wallet; + await h.write(0, token, TOKEN_ABI, 'mint', [wallet, BigInt(1_000_000)]); + await h.publicClient.waitForTransactionReceipt({ + hash: await fund(h, wallet, BigInt(10) ** BigInt(17)), + }); + // Fixture setup (raw owner call): grant a v5 multi-asset universal rule. + const terms = encodeUniversalTerms({ + validUntil: (await chainNow(h)) + 3600, + expectedCEA: CEA, + assets: [ + { token: token, maxPerCall: BigInt(1000), maxTotal: BigInt(1500) }, + ], + maxGasPerCall: BigInt(10) ** BigInt(16), + allowedCalls: [ + { + target: destTarget, + selector: '0xd09de08a', + beneficiaryOffset: 0, + hasBeneficiary: false, + maxValue: BigInt(0), + }, + ], + }); + const session = buildSession({ + validator: h.addresses.validator, + agent: agentAddr, + rulesPolicy: h.addresses.rulesPolicy, + actions: [ + { + target: h.addresses.gateway, + selector: '0x77b86bec', + initData: encodeEnvelope(SEPOLIA, terms), + }, + ], + }); + await h.write( + 0, + wallet, + h.generation.contracts.abis.wallet as never, + 'grantRules', + [session] + ); + [rulesId] = (await h.publicClient.readContract({ + address: h.addresses.engine, + abi: h.generation.contracts.abis.engine, + functionName: 'getPermissionIDs', + args: [wallet], + })) as Hex[]; + + const agent = await h.client(1, { agenticWallet: wallet }); + ctx = internals(agent).agenticContext!; + const base = internals(agent).agenticRuntime; + runtime = { + ...base, + reader: base.reader, + resolveCEA: async () => ({ cea: CEA, isDeployed: false }), + resolvePrc20: () => token, + quoteOutbound: async (prc20, gasLimit) => { + const [, gasFee, protocolFee, , , gasLimitUsed] = + (await h.publicClient.readContract({ + address: h.addresses.universalCore, + abi: CORE_ABI, + functionName: 'getOutboundTxGasAndFees', + args: [prc20, gasLimit], + })) as readonly [Address, bigint, bigint, bigint, string, bigint]; + return { protocolFee, nativeValueForGas: gasFee, gasLimitUsed }; + }, + }; + ownerAgw = await h.client(0, { agenticWallet: wallet }); + }); + + // Explicit call array: the funds land in the wallet's CEA and the listed + // call runs as given (a single `data` would also prepend transfer(target)). + const send = (amount: bigint) => + agenticSend(runtime, ctx, { + to: { address: destTarget, chain: SEPOLIA }, + data: [{ to: destTarget, value: BigInt(0), data: '0xd09de08a' }], + funds: { + amount, + token: { + symbol: 'USDC', + decimals: 6, + address: token, + mechanism: 'approve', + }, + }, + }); + + it('fails before signing when the owner has not set a gateway allowance', async () => { + await expect(send(BigInt(100))).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, + }); + }); + + it('owner sets a bounded allowance with an ordinary owner-door send; the agent outbound passes URP, pulls and burns', async () => { + await ( + await ownerAgw.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: TOKEN_ABI, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(300)], + }), + }) + ).wait(); + const supplyBefore = (await h.publicClient.readContract({ + address: token, + abi: TOKEN_ABI, + functionName: 'totalSupply', + })) as bigint; + const tx = await send(BigInt(100)); + const receipt = await h.publicClient.waitForTransactionReceipt({ + hash: tx.hash as Hex, + }); + expect(receipt.status).toBe('success'); + expect( + await h.publicClient.readContract({ + address: h.addresses.gateway, + abi: GATEWAY_ABI, + functionName: 'lastSender', + }) + ).toBe(wallet); + expect( + await h.publicClient.readContract({ + address: token, + abi: TOKEN_ABI, + functionName: 'totalSupply', + }) + ).toBe(supplyBefore - BigInt(100)); + expect( + await h.publicClient.readContract({ + address: token, + abi: TOKEN_ABI, + functionName: 'allowance', + args: [wallet, h.addresses.gateway], + }) + ).toBe(BigInt(200)); + // The request the wallet sent: empty recipient, wallet refund, nonzero gas cap, multicall payload. + const payload = `0x2cc2842d${encodeAbiParameters( + [ + { + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + [[{ to: destTarget, value: BigInt(0), data: '0xd09de08a' }]] + ).slice(2)}` as Hex; + const expected = keccak256( + encodeAbiParameters( + [ + { + type: 'tuple', + components: [ + { name: 'recipient', type: 'bytes' }, + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'gasLimit', type: 'uint256' }, + { name: 'gasPrice', type: 'uint256' }, + { name: 'maxPCForGas', type: 'uint256' }, + { name: 'payload', type: 'bytes' }, + { name: 'revertRecipient', type: 'address' }, + ], + }, + ], + [ + { + recipient: '0x', + token, + amount: BigInt(100), + gasLimit: BigInt(200_000), + gasPrice: BigInt(0), + maxPCForGas: BigInt(10) ** BigInt(15), + payload, + revertRecipient: wallet, + }, + ] + ) + ); + expect( + await h.publicClient.readContract({ + address: h.addresses.gateway, + abi: GATEWAY_ABI, + functionName: 'lastRequestHash', + }) + ).toBe(expected); + expect(tx.from).toBe(wallet); + expect(tx.route).toBe('UOA_TO_CEA'); + expect(tx.agentic).toMatchObject({ + door: 'agent', + rulesId, + destinationAccount: CEA, + chainNamespace: SEPOLIA, + }); + expect(keccak256(toBytes(SEPOLIA))).toBeTruthy(); + }); + + it('owner can remove the allowance; the agent is stopped before signing again', async () => { + await ( + await ownerAgw.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: TOKEN_ABI, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(0)], + }), + }) + ).wait(); + await expect(send(BigInt(10))).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, + }); + }); + + it('a call outside the allow-list is rejected by URP on-chain', async () => { + await ( + await ownerAgw.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: TOKEN_ABI, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(50)], + }), + }) + ).wait(); + const err = await agenticSend(runtime, ctx, { + to: { address: destTarget, chain: SEPOLIA }, + data: '0x12345678', + }).catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + }); + }); +}); diff --git a/packages/core/__agw-local__/agw-local-batch.spec.ts b/packages/core/__agw-local__/agw-local-batch.spec.ts new file mode 100644 index 000000000..ae5071df2 --- /dev/null +++ b/packages/core/__agw-local__/agw-local-batch.spec.ts @@ -0,0 +1,154 @@ +/** Real EIP-7702 self-delegation and real AGW/engine/policy; executor is a small + * test-only ERC-7821-shaped CALL dispatcher. No UEA relay or live network. */ +import { + encodeFunctionData, + getAddress, + parseAbi, + type Address, + type Hex, +} from 'viem'; +import { AgenticRevertError, CHAIN, type PushChain } from '../src'; +import { PUSH_BATCH_EXECUTOR_ADDRESS } from '../src/lib/constants/chain'; +import { startHarness, type Harness } from './harness'; +import { EvmClient } from '../src/lib/vm-client/evm-client'; +import { readNativeCounters } from '../__e2e__/shared/agw-state'; + +const ABI = parseAbi([ + 'function increment()', + 'function counter() view returns(uint256)', +]); +let h: Harness; +let owner: PushChain; +let previous: Address | undefined; + +beforeAll(async () => { + h = await startHarness(18552); + previous = PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET]; + const executor = await h.deploy( + 0, + 'HarnessAtomicExecutor', + 'HarnessFixtures.sol' + ); + PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET] = executor; + owner = await h.client(0); +}); +afterAll(async () => { + if (previous) PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET] = previous; + else delete PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET]; + await h?.stop(); +}); + +async function walletFor(maxCalls: number) { + const result = await owner.agentic.create('native-batch', { + rules: [ + { + agent: h.wallets[1].account!.address, + target: h.addresses.target, + selector: 'increment()', + validUntil: Number((await h.publicClient.getBlock()).timestamp) + 3600, + maxCalls, + }, + ], + }); + const agent = await h.client(1, { agenticWallet: result.wallet }); + return { ...result, agent }; +} +const calls = () => + Array.from({ length: 2 }, () => ({ + to: h.addresses.target, + value: BigInt(0), + data: encodeFunctionData({ abi: ABI, functionName: 'increment' }), + })); +const counter = () => + h.publicClient.readContract({ + address: h.addresses.target, + abi: ABI, + functionName: 'counter', + }); + +it('atomic native agent array preserves the agent sender and meters both actions', async () => { + const { wallet, rulesIds, agent } = await walletFor(3); + const before = await counter(); + const tx = await agent.universal.sendTransaction({ + to: h.addresses.target, + data: calls(), + }); + expect((await tx.wait()).status).toBe(1); + expect(tx.from).toBe(wallet); + expect(tx.agentic?.nativeCalls).toHaveLength(2); + expect(await counter()).toBe(before + BigInt(2)); + expect( + await readNativeCounters( + h.publicClient, + h.generation.addresses, + wallet, + rulesIds[0] + ) + ).toMatchObject({ callsUsed: 2 }); + // Verify actual type-4 outer transaction and its signer identity independently. + const raw = await h.publicClient.getTransaction({ hash: tx.hash as Hex }); + expect(raw.type).toBe('eip7702'); + expect(getAddress(raw.from)).toBe(h.wallets[1].account!.address); + expect(getAddress(raw.to as Address)).toBe(h.wallets[1].account!.address); +}); + +it('failure of a later native action rolls back the earlier call and its counters', async () => { + const { wallet, rulesIds, agent } = await walletFor(1); + const before = await counter(); + const nonceBefore = await h.publicClient.getTransactionCount({ + address: h.wallets[1].account!.address, + }); + // Normal estimation now refuses this invalid batch before broadcast. For + // this separate rollback proof, override only the test estimate so the real + // signed type-4 transaction reaches the real contracts and reverts there. + const original = EvmClient.prototype.sendBatch7702; + const send = jest + .spyOn(EvmClient.prototype, 'sendBatch7702') + .mockImplementation(async function (this: EvmClient, params) { + const estimate = jest + .spyOn(this.publicClient, 'estimateGas') + .mockResolvedValue(BigInt(2_000_000)); + try { + return await original.call(this, params); + } finally { + estimate.mockRestore(); + } + }); + try { + await expect( + agent.universal.sendTransaction({ to: h.addresses.target, data: calls() }) + ).rejects.toBeInstanceOf(AgenticRevertError); + } finally { + send.mockRestore(); + } + // Prove this was a mined transaction failure, not an early SDK rejection. + expect( + await h.publicClient.getTransactionCount({ + address: h.wallets[1].account!.address, + }) + ).toBeGreaterThan(nonceBefore); + expect(await counter()).toBe(before); + expect( + await readNativeCounters( + h.publicClient, + h.generation.addresses, + wallet, + rulesIds[0] + ) + ).toMatchObject({ callsUsed: 0 }); +}); + +it('normal estimation refuses an invalid native batch before broadcasting', async () => { + const { agent } = await walletFor(1); + const nonce = await h.publicClient.getTransactionCount({ + address: h.wallets[1].account!.address, + }); + await expect( + agent.universal.sendTransaction({ to: h.addresses.target, data: calls() }) + ).rejects.toBeInstanceOf(AgenticRevertError); + expect( + await h.publicClient.getTransactionCount({ + address: h.wallets[1].account!.address, + }) + ).toBe(nonce); +}); diff --git a/packages/core/__agw-local__/agw-local-races.spec.ts b/packages/core/__agw-local__/agw-local-races.spec.ts new file mode 100644 index 000000000..dd106d641 --- /dev/null +++ b/packages/core/__agw-local__/agw-local-races.spec.ts @@ -0,0 +1,166 @@ +/** + * Race regressions found during independent SDK review, + * against the real pinned v5 contracts on local anvil. + * R1 — a concurrent creation must never let this create() grant on a wallet it + * did not deploy (the deploy is index-bound through deployWalletWithSig). + * R4 — an owner outbound must never restore an allowance revoked after the + * SDK read it (the SDK never writes allowances). + */ +import { encodeFunctionData, parseAbi, type Address } from 'viem'; +import { AgenticRevertError, CHAIN, type PushChain } from '../src'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; +import { v5 } from '../src/lib/agentic/contracts/v5'; +import { AGENTIC_ERROR_CODE } from '../src/lib/agentic/errors'; +import { startHarness, type Harness } from './harness'; + +const TOKEN = parseAbi([ + 'function mint(address,uint256)', + 'function approve(address,uint256) returns (bool)', + 'function allowance(address,address) view returns (uint256)', +]); +const internalsOf = (c: PushChain) => + (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; + +describe('AGW race regressions (real v5 contracts)', () => { + let h: Harness; + let owner: PushChain; + beforeAll(async () => { + h = await startHarness(18549); + owner = await h.client(0); + }); + afterAll(async () => { + await h?.stop(); + }); + + it('R1 a concurrent creation makes create() fail with nothing granted on either wallet', async () => { + const expected = await owner.agentic.derive(); + const rt = internalsOf(owner); + const execute = rt.execute; + rt.execute = async (params, options) => { + rt.execute = execute; + await h.write( + 0, + h.addresses.factory, + h.generation.contracts.abis.factory, + 'deployWallet', + ['other-purpose-wallet'] + ); + return execute(params, options); + }; + const err = await owner.agentic + .create('intended', { + rules: [ + { + agent: h.wallets[1].account!.address, + target: h.addresses.target, + selector: 'increment()', + validUntil: + Number((await h.publicClient.getBlock()).timestamp) + 3600, + }, + ], + }) + .catch((e) => e) + .finally(() => { + rt.execute = execute; + }); + expect(err).toMatchObject({ + code: AGENTIC_ERROR_CODE.INDEX_RACE, + details: { committed: false }, + }); + const permissionIds = (address: Address) => + h.publicClient.readContract({ + address: h.addresses.engine, + abi: h.generation.contracts.abis.engine, + functionName: 'getPermissionIDs', + args: [address], + }); + expect(await permissionIds(expected.address)).toHaveLength(0); + const next = await owner.agentic.derive(); + expect(next.index).toBe(expected.index + 1); + expect(await permissionIds(next.address)).toHaveLength(0); + // A retry re-reads the slot and succeeds. + const retried = await owner.agentic.create('intended', { rules: [] }); + expect(retried.index).toBe(next.index); + }); + + it('R4 an allowance revoked after the SDK read it stays revoked; the outbound fails on-chain', async () => { + const { wallet } = await owner.agentic.create('allowance-race', { + rules: [], + }); + const token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.ETHEREUM_SEPOLIA, + 6, + ]); + await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(1000)]); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[0].sendTransaction({ + to: wallet, + value: BigInt(10) ** BigInt(17), + account: h.wallets[0].account!, + chain: h.wallets[0].chain, + }), + }); + const c = await h.client(0, { agenticWallet: wallet }); + await ( + await c.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(50)], + }), + }) + ).wait(); + const rt = internalsOf(c); + rt.resolvePrc20 = () => token; + rt.resolveCEA = async () => ({ + cea: h.wallets[2].account!.address, + isDeployed: true, + }); + rt.quoteOutbound = async () => ({ + protocolFee: BigInt(10) ** BigInt(14), + nativeValueForGas: BigInt(10) ** BigInt(15), + gasLimitUsed: BigInt(200000), + }); + const execute = rt.execute; + rt.execute = async (params, options) => { + rt.execute = execute; + const revoke = v5.encodeExecute([ + { + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(0)], + }), + }, + ]); + await (await execute({ to: wallet, data: revoke })).wait(); + return execute(params, options); + }; + const err = await c.universal + .sendTransaction({ + to: { + address: h.wallets[3].account!.address as Address, + chain: CHAIN.ETHEREUM_SEPOLIA, + }, + data: '0xd09de08a', + funds: { amount: BigInt(10) }, + }) + .catch((e) => e) + .finally(() => { + rt.execute = execute; + }); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(err.message).toContain('0x10bad147'); // LowAllowance() — the token refused the pull + expect( + await h.publicClient.readContract({ + address: token, + abi: TOKEN, + functionName: 'allowance', + args: [wallet, h.addresses.gateway], + }) + ).toBe(BigInt(0)); + }); +}); diff --git a/packages/core/__agw-local__/agw-local.spec.ts b/packages/core/__agw-local__/agw-local.spec.ts new file mode 100644 index 000000000..93f0a3db7 --- /dev/null +++ b/packages/core/__agw-local__/agw-local.spec.ts @@ -0,0 +1,447 @@ +import { readNativeCounters } from '../__e2e__/shared/agw-state'; +/** + * Real-contract checks of the AGW SDK against the pinned v5 contracts on a + * local anvil chain (see harness.ts for scope and limits). Every assertion + * about authorization, atomicity, counters and checkpoints is decided by the + * real contracts; the only stubbed component is the gateway. + */ +import { + encodeFunctionData, + getAddress, + parseAbi, + type Address, + type Hex, +} from 'viem'; +import { + AgenticError, + AgenticRevertError, + CHAIN, + type PushChain, +} from '../src'; +import { AGENTIC_ERROR_CODE } from '../src/lib/agentic/errors'; +import { startHarness, type Harness } from './harness'; + +const TARGET_ABI = parseAbi([ + 'function increment()', + 'function counter() view returns (uint256)', + 'function deposit(address beneficiary, uint256 amount) payable', + 'function lastBeneficiary() view returns (address)', +]); +const WALLET_ABI = parseAbi([ + 'function checkpointCount() view returns (uint64)', + 'function agentOf(bytes32) view returns (address)', + 'function owner() view returns (address)', +]); + +const inAnHour = () => Math.floor(Date.now() / 1000) + 3600; + +describe('AGW SDK against real v5 contracts (local anvil)', () => { + let h: Harness; + let owner: PushChain; + let ownerAddr: Address; + let agentAddr: Address; + + beforeAll(async () => { + h = await startHarness(); + owner = await h.client(0); + ownerAddr = h.wallets[0].account!.address; + agentAddr = h.wallets[1].account!.address; + }); + afterAll(async () => { + await h?.stop(); + }); + + const counter = () => + h.publicClient.readContract({ + address: h.addresses.target, + abi: TARGET_ABI, + functionName: 'counter', + }); + const checkpointCount = (wallet: Address) => + h.publicClient.readContract({ + address: wallet, + abi: WALLET_ABI, + functionName: 'checkpointCount', + }); + + it('derive() predicts slot 0 from the signer identity and matches the factory', async () => { + const d = await owner.agentic.derive(); + expect(d.index).toBe(0); + expect(d.deployed).toBe(false); + const [predicted] = (await h.publicClient.readContract({ + address: h.addresses.factory, + abi: h.generation.contracts.abis.factory, + functionName: 'predictWallet', + args: [ownerAddr, BigInt(0)], + })) as readonly [Address, boolean]; + expect(d.address).toBe(getAddress(predicted)); + }); + + let bare: Address; + it('create(label, { rules: [] }) deploys a bare wallet without funding it', async () => { + const created = await owner.agentic.create('scratch', { rules: [] }); + bare = created.wallet; + expect(created.index).toBe(0); + expect(created.rulesIds).toEqual([]); + expect(await h.publicClient.getBalance({ address: bare })).toBe(BigInt(0)); + const info = await owner.agentic.wallet(bare).info(); + expect(info).toMatchObject({ + label: 'scratch', + index: 0, + owner: ownerAddr, + deployed: true, + rulesCount: 0, + }); + }); + + let wallet: Address; + let rule1: Hex; + it('create with a native rule returns the receipt-confirmed ID; grant order and IDs match the contract', async () => { + const created = await owner.agentic.create('ops', { + rules: [ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: inAnHour(), + maxCalls: 2, + }, + ], + }); + wallet = created.wallet; + rule1 = created.rulesIds[0]; + expect(created.index).toBe(1); + expect(created.rulesIds).toHaveLength(1); + const agentOf = await h.publicClient.readContract({ + address: wallet, + abi: WALLET_ABI, + functionName: 'agentOf', + args: [rule1], + }); + expect(agentOf).toBe(agentAddr); + // Sequential fallback on a 7702-less localnet is reported honestly. + expect(typeof created.tx.atomic).toBe('boolean'); + }); + + it('list() returns both deployed wallets plus the next undeployed slot', async () => { + const { wallets } = await owner.agentic.list(); + expect( + wallets.map((w) => [w.index, w.label, w.deployed, w.rulesCount]) + ).toEqual([ + [0, 'scratch', true, 0], + [1, 'ops', true, 1], + [2, '', false, 0], + ]); + }); + + it('rules.get / rules.list decode active terms without exposing internal spend', async () => { + const rec = await owner.agentic.wallet(wallet).rules.get(rule1); + expect(rec.agent).toBe(agentAddr); + expect(rec.rule).toMatchObject({ + target: h.addresses.target, + maxCalls: 2, + maxValuePerCall: BigInt(0), + }); + expect(rec).not.toHaveProperty('spent'); + expect( + await readNativeCounters( + h.publicClient, + h.generation.addresses, + wallet, + rule1 + ) + ).toEqual({ valueSpent: BigInt(0), amountSpent: BigInt(0), callsUsed: 0 }); + const { rules } = await owner.agentic.wallet(wallet).rules.list(); + expect(rules.map((r) => r.rulesId)).toEqual([rule1]); + }); + + let agent: PushChain; + it('agent client: universal.account is the wallet, origin stays the signer, sends go through executeAsAgent', async () => { + agent = await h.client(1, { agenticWallet: wallet }); + expect(agent.universal.account).toBe(wallet); + expect(agent.universal.origin.address.toLowerCase()).toBe( + agentAddr.toLowerCase() + ); + const before = await counter(); + const tx = await agent.universal.sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ abi: TARGET_ABI, functionName: 'increment' }), + }); + const receipt = await tx.wait(); + expect(receipt.status).toBe(1); + expect(await counter()).toBe(before + BigInt(1)); + expect(tx.from).toBe(wallet); + expect(receipt.from).toBe(wallet); + expect(tx.to).toBe(h.addresses.target); + expect(tx.agentic).toMatchObject({ wallet, door: 'agent', rulesId: rule1 }); + expect(tx.agentic?.rawTo?.toLowerCase()).toBe(wallet.toLowerCase()); + }); + + it('agent sends do not tick checkpoints; the policy enforces maxCalls on-chain', async () => { + const cpBefore = await checkpointCount(wallet); + await ( + await agent.universal.sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'increment', + }), + }) + ).wait(); + expect(await checkpointCount(wallet)).toBe(cpBefore); + const rec = await owner.agentic.wallet(wallet).rules.get(rule1); + expect(rec).not.toHaveProperty('spent'); + expect( + await readNativeCounters( + h.publicClient, + h.generation.addresses, + wallet, + rule1 + ) + ).toMatchObject({ callsUsed: 2 }); + const err = await agent.universal + .sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'increment', + }), + }) + .catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(err.decodedError?.name).toBe( + 'PolicyCheckReverted(CallLimitReached)' + ); + }); + + it('a stranger cannot initialize against the wallet', async () => { + await expect(h.client(2, { agenticWallet: wallet })).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.NOT_OWNER_OR_AGENT, + }); + }); + + it('owner client goes through execute and ticks one OWNER_ACTION checkpoint per call', async () => { + const ownerAgw = await h.client(0, { agenticWallet: wallet }); + const before = await checkpointCount(wallet); + const data = encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'increment', + }); + const tx = await ownerAgw.universal.sendTransaction({ + to: h.addresses.target, + data: [ + { to: h.addresses.target, value: BigInt(0), data }, + { to: h.addresses.target, value: BigInt(0), data }, + ], + }); + await tx.wait(); + expect(await checkpointCount(wallet)).toBe(before + BigInt(2)); + expect(tx.agentic?.door).toBe('owner'); + const { checkpoints } = await owner.agentic + .wallet(wallet) + .checkpoints({ sinceBlock: tx.blockNumber }); + expect(checkpoints.map((c) => c.kind)).toEqual([ + 'OWNER_ACTION', + 'OWNER_ACTION', + ]); + }); + + it('native agent arrays require an available atomic executor instead of sequential fallback', async () => { + const data = encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'increment', + }); + await expect( + agent.universal.sendTransaction({ + to: h.addresses.target, + data: [ + { to: h.addresses.target, value: BigInt(0), data }, + { to: h.addresses.target, value: BigInt(0), data }, + ], + }) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, + }); + }); + + let rule2: Hex; + it('rules.update replaces atomically: five checkpoints, old disabled, new usable with reset counters', async () => { + const before = await checkpointCount(wallet); + const res = await owner.agentic.wallet(wallet).rules.update({ + rules: [ + { + rulesId: rule1, + rule: { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: inAnHour(), + maxCalls: 1, + }, + }, + ], + }); + rule2 = res.rules[0].rulesId; + expect(res.rules[0].replaced).toBe(rule1); + expect(await checkpointCount(wallet)).toBe(before + BigInt(5)); + const { checkpoints } = await owner.agentic + .wallet(wallet) + .checkpoints({ sinceBlock: res.tx.blockNumber }); + expect(checkpoints.map((c) => c.kind)).toEqual([ + 'OWNER_ACTION', + 'OWNER_ACTION', + 'RULES_REVOKED', + 'OWNER_ACTION', + 'RULES_GRANTED', + ]); + const rec = await owner.agentic.wallet(wallet).rules.get(rule2); + expect(rec).not.toHaveProperty('spent'); + expect( + await readNativeCounters( + h.publicClient, + h.generation.addresses, + wallet, + rule2 + ) + ).toMatchObject({ callsUsed: 0 }); + await expect( + owner.agentic.wallet(wallet).rules.get(rule1) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.RULE_NOT_FOUND, + }); + const tx = await agent.universal.sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ abi: TARGET_ABI, functionName: 'increment' }), + }); + expect(tx.agentic?.rulesId).toBe(rule2); + }); + + it('full checkpoint history is contiguous and matches checkpointCount()', async () => { + const { checkpoints } = await owner.agentic.wallet(wallet).checkpoints(); + expect(checkpoints.map((c) => c.seq)).toEqual( + checkpoints.map((_, i) => i + 1) + ); + expect(BigInt(checkpoints.length)).toBe(await checkpointCount(wallet)); + expect(checkpoints[0].kind).toBe('RULES_GRANTED'); + }); + + it('bare revoke is refused; revoke({ all: true }) disables every rule', async () => { + await expect( + owner.agentic.wallet(wallet).rules.revoke(undefined as unknown as Hex[]) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET }); + await expect( + owner.agentic.wallet(wallet).rules.revoke([]) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET, + }); + await ( + await owner.agentic.wallet(wallet).rules.revoke({ all: true }) + ).wait(); + const { rules } = await owner.agentic.wallet(wallet).rules.list(); + expect(rules).toEqual([]); + await expect( + agent.universal.sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ + abi: TARGET_ABI, + functionName: 'increment', + }), + }) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN }); + }); + + it('a non-owner cannot write rules (fails before signing)', async () => { + const stranger = await h.client(2); + await expect( + stranger.agentic.wallet(wallet).rules.add([ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: inAnHour(), + }, + ]) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NOT_WALLET_OWNER }); + }); + + it('a read-only client can read but never sign, even with the owner address', async () => { + const ro = await h.readOnlyClient(0, { agenticWallet: wallet }); + expect(ro.universal.account).toBe(wallet); + expect((await ro.agentic.wallet(wallet).owner()).owner).toBe(ownerAddr); + await expect( + ro.agentic.wallet(wallet).rules.add([ + { + agent: agentAddr, + target: h.addresses.target, + selector: 'increment()', + validUntil: inAnHour(), + }, + ]) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.READ_ONLY }); + expect(() => + ro.universal.sendTransaction({ to: h.addresses.target, data: '0x' }) + ).toThrow(/Read only/); + }); + + it('reinitialize without agenticWallet returns an ordinary client', async () => { + const ownerAgw = await h.client(0, { agenticWallet: wallet }); + const plain = await ownerAgw.reinitialize( + await import('../src').then((m) => + m.PushChain.utils.signer.toUniversalFromKeypair(h.wallets[0], { + chain: m.CHAIN.PUSH_LOCALNET, + library: m.PushChain.CONSTANTS.LIBRARY.ETHEREUM_VIEM, + }) + ) + ); + expect(plain.universal.account.toLowerCase()).toBe(ownerAddr.toLowerCase()); + }); + + it('signer-level and cascade entry points are closed in agentic mode', async () => { + const ownerAgw = await h.client(0, { agenticWallet: wallet }); + const code = AGENTIC_ERROR_CODE.NOT_ALLOWED_IN_AGENTIC_MODE; + expect(() => + ownerAgw.universal.prepareTransaction({ + to: h.addresses.target, + data: '0x', + }) + ).toThrow(expect.objectContaining({ code })); + await expect( + ownerAgw.universal.executeTransactions([]) + ).rejects.toMatchObject({ code }); + expect(() => ownerAgw.universal.migrateCEA(CHAIN.ETHEREUM_SEPOLIA)).toThrow( + expect.objectContaining({ code }) + ); + expect(() => + ownerAgw.universal.rescueFunds({ + universalTxId: `0x${'00'.repeat(32)}`, + prc20: h.addresses.target, + }) + ).toThrow(expect.objectContaining({ code })); + await expect( + ownerAgw.universal.sendTransaction({ + to: h.addresses.target, + data: '0x', + payGasWith: {}, + }) + ).rejects.toMatchObject({ code }); + await expect( + ownerAgw.universal.sendTransaction({ + to: { address: h.addresses.target, chain: CHAIN.PUSH_LOCALNET }, + from: { chain: CHAIN.ETHEREUM_SEPOLIA }, + }) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.FROM_NOT_ALLOWED }); + }); + + it('getAccountStatus stays signer-scoped in agentic mode', async () => { + const ownerAgw = await h.client(0, { agenticWallet: wallet }); + const status = await ownerAgw.getAccountStatus(); + expect(status.mode).toBe('signer'); + expect(ownerAgw.universal.account).toBe(wallet); + }); + + it('errors from the agentic surface are typed', () => { + expect( + new AgenticError(AGENTIC_ERROR_CODE.AMBIGUOUS_RULE, 'x') + ).toBeInstanceOf(Error); + }); +}); diff --git a/packages/core/__agw-local__/delayed-index.spec.ts b/packages/core/__agw-local__/delayed-index.spec.ts new file mode 100644 index 000000000..958bf8c3f --- /dev/null +++ b/packages/core/__agw-local__/delayed-index.spec.ts @@ -0,0 +1,257 @@ +/** Real v5 execution/receipts; only origin mapping and Cosmos indexing are fixtures. */ +import { getAddress, parseAbi, type Address, type Hex } from 'viem'; +import { CHAIN, type PushChain } from '../src'; +import * as account from '../src/lib/universal/account/account'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; +import type { OrchestratorContext } from '../src/lib/orchestrator/internals/context'; +import { detectRouteFromUniversalTxData } from '../src/lib/orchestrator/internals/tx-transformer'; +import { UniversalTxStatus } from '../src/lib/generated/uexecutor/v1/types'; +import { + OutboundStatus, + UniversalTxV2Codec, + OutboundTxV2Codec, +} from '../src/lib/generated/uexecutor/v2/types'; +import type { ProgressEvent } from '../src/lib/progress-hook/progress-hook.types'; +import { startHarness, type Harness } from './harness'; + +const DEST = getAddress('0x0000000000000000000000000000000000001234'); +const CEA = getAddress('0x000000000000000000000000000000000000cea1'); +const EXTERNAL_HASH = `0x${'ef'.repeat(32)}` as Hex; +const CORE = parseAbi([ + 'function setGasToken(string,address)', + 'function getOutboundTxGasAndFees(address,uint256) view returns (address,uint256,uint256,uint256,string,uint256)', +]); +const runtime = (c: PushChain) => + (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; +const context = (c: PushChain) => + (c as unknown as { orchestrator: { ctx: OrchestratorContext } }).orchestrator + .ctx; + +describe('AGW replay with delayed Cosmos indexing', () => { + let h: Harness, token: Address; + beforeAll(async () => { + h = await startHarness(18553); + token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.ETHEREUM_SEPOLIA, + 18, + ]); + await h.write(0, h.addresses.universalCore, CORE, 'setGasToken', [ + CHAIN.ETHEREUM_SEPOLIA, + token, + ]); + }); + afterEach(() => jest.restoreAllMocks()); + afterAll(async () => h?.stop()); + + function configure(c: PushChain) { + runtime(c).resolvePrc20 = () => token; + runtime(c).resolveCEA = async () => ({ cea: CEA, isDeployed: false }); + runtime(c).quoteOutbound = async (asset, limit) => { + const [, nativeValueForGas, protocolFee, , , gasLimitUsed] = + await h.publicClient.readContract({ + address: h.addresses.universalCore, + abi: CORE, + functionName: 'getOutboundTxGasAndFees', + args: [asset, limit], + }); + return { nativeValueForGas, protocolFee, gasLimitUsed }; + }; + } + + it.each([ + ['owner', 'success'], + ['agent', 'success'], + ['owner', 'failed'], + ['agent', 'failed'], + ['owner', 'timeout'], + ['agent', 'timeout'], + ] as const)( + '%s door: real Push receipt then delayed external %s', + async (door, outcome) => { + // No UEA factory is present locally. Preserve real EVM RPC and contract + // verification, while isolating the unrelated UEA origin lookup. + jest + .spyOn(account, 'convertExecutorToOrigin') + .mockResolvedValue({ account: null, exists: false }); + const owner = await h.client(0); + configure(owner); + const created = await owner.agentic.create('delayed-index', { + rules: [ + { + agent: h.wallets[1].account!.address, + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + assets: [], + maxGasPerCall: BigInt(10) ** BigInt(16), + validUntil: Math.floor(Date.now() / 1000) + 3600, + allowedCalls: [{ target: DEST, selector: 'increment()' }], + }, + ], + }); + const wallet = created.wallet; + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + const sender = await h.client(door === 'owner' ? 0 : 1, { + agenticWallet: wallet, + }); + configure(sender); + const sent = await sender.universal.sendTransaction({ + to: { address: DEST, chain: CHAIN.ETHEREUM_SEPOLIA }, + data: [{ to: DEST, value: BigInt(0), data: '0xd09de08a' }], + }); + const root = await h.publicClient.waitForTransactionReceipt({ + hash: sent.hash as Hex, + }); + expect(root.status).toBe('success'); + expect(getAddress(root.from)).toBe( + h.wallets[door === 'owner' ? 0 : 1].account!.address + ); + + const init: ProgressEvent[] = [], + perCall: ProgressEvent[] = []; + const tracker = await h.client(0, { + progressHook: (e) => init.push(e as ProgressEvent), + }); + const ctx = context(tracker); + const partial = UniversalTxV2Codec.fromPartial({ + id: 'delayed-record', + universalStatus: UniversalTxStatus.PC_EXECUTED_SUCCESS, + pcTx: [ + { + txHash: sent.hash, + status: 'SUCCESS', + sender: wallet, + gasUsed: 0, + blockHeight: Number(root.blockNumber), + errorMsg: '', + }, + ], + }); + expect(detectRouteFromUniversalTxData(partial)).toBe('UOA_TO_PUSH'); + let waiting = false, + polls = 0; + const lookup = jest + .spyOn(ctx.pushClient, 'getUniversalTxByIdV2') + .mockImplementation(async () => { + if (!waiting) return { universalTx: partial }; + polls++; + // Missing record, then root-only record, then the destination observation. + if (polls === 1) return { universalTx: undefined }; + if (polls === 2 || outcome === 'timeout') + return { universalTx: partial }; + return { + universalTx: UniversalTxV2Codec.fromPartial({ + ...partial, + universalStatus: UniversalTxStatus.OUTBOUND_PENDING, + outboundTx: [ + OutboundTxV2Codec.fromPartial({ + destinationChain: CHAIN.ETHEREUM_SEPOLIA, + sender: wallet, + recipient: CEA, + amount: '0', + prc20AssetAddr: token, + externalAssetAddr: token, + outboundStatus: + outcome === 'success' + ? OutboundStatus.OBSERVED + : OutboundStatus.REVERTED, + observedTx: { + txHash: EXTERNAL_HASH, + success: outcome === 'success', + errorMsg: + outcome === 'failed' ? 'fixture destination revert' : '', + blockHeight: 1, + gasFeeUsed: '', + pc20WrapperAddress: '', + }, + }), + ], + }), + }; + }); + const cosmos = jest + .spyOn(ctx.pushClient, 'getCosmosTx') + .mockResolvedValue({ + events: [], + height: Number(root.blockNumber), + txIndex: 0, + code: 0, + transactionHash: sent.hash, + msgResponses: [], + gasWanted: BigInt(0), + gasUsed: BigInt(0), + }); + const signerNonces = await Promise.all( + h.wallets + .slice(0, 2) + .map((signer) => + h.publicClient.getTransactionCount({ + address: signer.account!.address, + blockTag: 'pending', + }) + ) + ); + const replay = await tracker.universal.trackTransaction(sent.hash, { + progressHook: (e) => perCall.push(e), + }); + expect(lookup).toHaveBeenCalledTimes(1); + expect(polls).toBe(0); + expect(replay).toMatchObject({ + from: wallet, + origin: sent.origin, + to: DEST, + data: '0xd09de08a', + route: 'UOA_TO_CEA', + chain: CHAIN.ETHEREUM_SEPOLIA, + }); + expect(replay.agentic).toMatchObject({ wallet, door }); + waiting = true; + const receipt = await replay.wait({ + outboundInitialWaitMs: 0, + outboundPollingIntervalMs: 1, + outboundTimeoutMs: outcome === 'timeout' ? 100 : 1000, + }); + expect(receipt).toMatchObject({ + status: 1, + from: wallet, + externalStatus: outcome, + }); + expect(cosmos).toHaveBeenCalledTimes(1); + expect(polls).toBeGreaterThanOrEqual(outcome === 'timeout' ? 1 : 3); + if (outcome !== 'timeout') + expect(receipt.externalTxHash).toBe(EXTERNAL_HASH); + else expect(receipt.externalTxHash).toBeUndefined(); + const terminal = + outcome === 'success' + ? 'SEND-TX-299-01' + : outcome === 'failed' + ? 'SEND-TX-299-02' + : 'SEND-TX-299-03'; + for (const events of [init, perCall]) { + expect(events.filter((event) => event.id === terminal)).toHaveLength(1); + } + expect(perCall.map((e) => e.id)).toEqual(init.map((e) => e.id)); + // wait reuses the mined root; it does not sign or send a replacement. + expect( + await Promise.all( + h.wallets + .slice(0, 2) + .map((signer) => + h.publicClient.getTransactionCount({ + address: signer.account!.address, + blockTag: 'pending', + }) + ) + ) + ).toEqual(signerNonces); + expect( + (await h.publicClient.getTransaction({ hash: sent.hash as Hex })).input + ).toBe(sent.agentic!.rawData); + } + ); +}); diff --git a/packages/core/__agw-local__/harness.ts b/packages/core/__agw-local__/harness.ts new file mode 100644 index 000000000..22ad93283 --- /dev/null +++ b/packages/core/__agw-local__/harness.ts @@ -0,0 +1,444 @@ +/** + * Local AGW contract harness — REVIEW ONLY. + * + * Starts anvil as a stand-in Push LOCALNET (chainId 9000, the SDK's + * CHAIN_INFO[PUSH_LOCALNET].chainId), deploys the REAL pinned v5 + * contracts (SmartSession, AgentValidator, UniversalRulesPolicy behind a + * TransparentUpgradeableProxy, AGW implementation, AGWFactory behind an + * ERC1967Proxy) from an isolated build prepared by + * scripts/agw-local/prepare.sh, and installs a stub gateway at the + * UniversalGatewayPC precompile address. It registers that deployment as a + * NON-advertised generation so the SDK can be driven end to end. + * + * Limits: no UEA factory / precompiles / Cosmos module, so only Push-EOA + * signers are exercised; the gateway is a stub (pull + burn + fee arithmetic); + * no TSS, relay or destination execution happens. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { + createPublicClient, + createWalletClient, + defineChain, + encodeFunctionData, + getAddress, + http, + type Abi, + type Address, + type Hex, + type PublicClient, + type WalletClient, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { PushChain } from '../src'; +import { CHAIN, PUSH_NETWORK } from '../src/lib/constants/enums'; +import { LIBRARY } from '../src/lib/constants/enums'; +import { + registerAgenticGeneration, + resetAgenticGenerations, + type AgenticGeneration, +} from '../src/lib/agentic/deployments'; + +export const CHAIN_ID = 9000; +export const GATEWAY_PRECOMPILE = getAddress( + '0x00000000000000000000000000000000000000c1' +); +export const EXECUTOR_MODULE = getAddress( + '0x00000000000000000000000000000000000000e1' +); +export const ACCOUNT_ID = 'push.agw.1.0.0'; + +/** Anvil's well-known development keys (public test material, not secrets). */ +export const DEV_KEYS: Hex[] = [ + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a', + '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6', + '0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a', +]; + +export function buildDir(): string { + const dir = process.env['AGW_LOCAL_DIR']; + if (!dir || !existsSync(join(dir, 'out', 'AGW.sol', 'AGW.json'))) { + throw new Error( + 'AGW_LOCAL_DIR must point at a build produced by packages/core/scripts/agw-local/prepare.sh' + ); + } + return dir; +} + +function artifact( + dir: string, + file: string, + name: string +): { abi: Abi; bytecode: Hex } { + const json = JSON.parse( + readFileSync(join(dir, 'out', file, `${name}.json`), 'utf8') + ); + return { abi: json.abi as Abi, bytecode: json.bytecode.object as Hex }; +} + +export interface Harness { + rpcUrl: string; + anvil: ChildProcess; + publicClient: PublicClient; + wallets: WalletClient[]; + generation: AgenticGeneration; + addresses: { + engine: Address; + validator: Address; + rulesPolicy: Address; + walletImplementation: Address; + factory: Address; + universalCore: Address; + gateway: Address; + target: Address; + }; + deploy( + walletIndex: number, + name: string, + file: string, + args?: readonly unknown[] + ): Promise
; + write( + walletIndex: number, + to: Address, + abi: Abi, + functionName: string, + args: readonly unknown[], + value?: bigint + ): Promise; + client( + walletIndex: number, + opts?: { agenticWallet?: Address; progressHook?: (e: unknown) => void } + ): Promise; + readOnlyClient( + walletIndex: number, + opts?: { agenticWallet?: Address } + ): Promise; + stop(): Promise; + dir: string; +} + +const localChain = (rpcUrl: string) => + defineChain({ + id: CHAIN_ID, + name: 'push-localnet-harness', + nativeCurrency: { name: 'PC', symbol: 'PC', decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }); + +async function waitForRpc(url: string, timeoutMs = 20_000): Promise { + const start = Date.now(); + for (;;) { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_chainId', + params: [], + }), + }); + if (res.ok) return; + } catch { + // not yet listening + } + if (Date.now() - start > timeoutMs) + throw new Error(`anvil did not start at ${url}`); + await new Promise((r) => setTimeout(r, 200)); + } +} + +export async function startHarness(port = 18545): Promise { + const dir = buildDir(); + const rpcUrl = `http://127.0.0.1:${port}`; + const anvil = spawn( + 'anvil', + [ + '--port', + String(port), + '--chain-id', + String(CHAIN_ID), + '--hardfork', + 'prague', + '--silent', + ], + { stdio: 'ignore' } + ); + try { + return await deployHarness(dir, rpcUrl, anvil); + } catch (err) { + await stopAnvil(anvil); + throw err; + } +} + +/** Wait for the child to release its port; never leave cleanup to Jest exit. */ +async function stopAnvil(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve, reject) => { + const killTimer = setTimeout(() => child.kill('SIGKILL'), 2000); + const deadline = setTimeout(() => { + cleanup(); + reject(new Error('Anvil did not exit after SIGTERM/SIGKILL')); + }, 5000); + const cleanup = () => { + clearTimeout(killTimer); + clearTimeout(deadline); + child.off('exit', exited); + }; + const exited = () => { + cleanup(); + resolve(); + }; + child.once('exit', exited); + child.kill('SIGTERM'); + }); +} + +async function deployHarness( + dir: string, + rpcUrl: string, + anvil: ChildProcess +): Promise { + await waitForRpc(rpcUrl); + const chain = localChain(rpcUrl); + const publicClient = createPublicClient({ + chain, + transport: http(rpcUrl), + }) as PublicClient; + const wallets = DEV_KEYS.map((k) => + createWalletClient({ + account: privateKeyToAccount(k), + chain, + transport: http(rpcUrl), + }) + ); + const clients = new Set(); + + const deploy = async ( + walletIndex: number, + name: string, + file: string, + args: readonly unknown[] = [] + ) => { + const art = artifact(dir, file, name); + const hash = await wallets[walletIndex].deployContract({ + abi: art.abi, + bytecode: art.bytecode, + args: args as unknown[], + account: wallets[walletIndex].account!, + chain, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (!receipt.contractAddress) throw new Error(`deploy ${name} failed`); + return getAddress(receipt.contractAddress); + }; + const write = async ( + walletIndex: number, + to: Address, + abi: Abi, + functionName: string, + args: readonly unknown[], + value?: bigint + ) => { + const hash = await wallets[walletIndex].sendTransaction({ + to, + data: encodeFunctionData({ abi, functionName, args: args as unknown[] }), + value, + account: wallets[walletIndex].account!, + chain, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') + throw new Error(`${functionName} reverted`); + return hash; + }; + + const admin = wallets[0].account!.address; + const engine = await deploy(0, 'SmartSession', 'SmartSession.sol'); + const validator = await deploy(0, 'AgentValidator', 'AgentValidator.sol'); + const urpLogic = await deploy( + 0, + 'UniversalRulesPolicy', + 'UniversalRulesPolicy.sol' + ); + const urpAbi = artifact( + dir, + 'UniversalRulesPolicy.sol', + 'UniversalRulesPolicy' + ).abi; + const rulesPolicy = await deploy( + 0, + 'TransparentUpgradeableProxy', + 'TransparentUpgradeableProxy.sol', + [ + urpLogic, + admin, + encodeFunctionData({ + abi: urpAbi, + functionName: 'initialize', + args: [GATEWAY_PRECOMPILE, EXECUTOR_MODULE, engine], + }), + ] + ); + const coreLogic = await deploy( + 0, + 'HarnessUniversalCore', + 'HarnessFixtures.sol' + ); + const universalCore = getAddress( + '0x00000000000000000000000000000000000000c0' + ); + await publicClient.request({ + method: 'anvil_setCode' as never, + params: [ + universalCore, + await publicClient.getCode({ address: coreLogic }), + ] as never, + }); + // The stub's storage initializers are not installed by etching code. + await publicClient.request({ + method: 'anvil_setStorageAt' as never, + params: [ + universalCore, + `0x${'00'.repeat(32)}`, + `0x${BigInt(10 ** 15) + .toString(16) + .padStart(64, '0')}`, + ] as never, + }); + await publicClient.request({ + method: 'anvil_setStorageAt' as never, + params: [ + universalCore, + `0x${'00'.repeat(31)}01`, + `0x${BigInt(10 ** 14) + .toString(16) + .padStart(64, '0')}`, + ] as never, + }); + const gatewayImpl = await deploy( + 0, + 'HarnessGatewayPC', + 'HarnessFixtures.sol', + [universalCore] + ); + const gatewayCode = await publicClient.getCode({ address: gatewayImpl }); + await publicClient.request({ + method: 'anvil_setCode' as never, + params: [GATEWAY_PRECOMPILE, gatewayCode] as never, + }); + const walletImplementation = await deploy(0, 'AGW', 'AGW.sol', [ + engine, + rulesPolicy, + validator, + GATEWAY_PRECOMPILE, + ]); + const factoryLogic = await deploy(0, 'AGWFactory', 'AGWFactory.sol'); + const factoryAbi = artifact(dir, 'AGWFactory.sol', 'AGWFactory').abi; + const factory = await deploy(0, 'ERC1967Proxy', 'ERC1967Proxy.sol', [ + factoryLogic, + encodeFunctionData({ + abi: factoryAbi, + functionName: 'initialize', + args: [admin, walletImplementation], + }), + ]); + const target = await deploy(0, 'HarnessTarget', 'HarnessFixtures.sol'); + + resetAgenticGenerations(); + const generation = registerAgenticGeneration({ + id: 'v5', + sourceCommit: '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb', + network: PUSH_NETWORK.LOCALNET, + advertised: false, + addresses: { + factory, + walletImplementation, + sessionEngine: engine, + rulesPolicy, + sessionValidator: validator, + gateway: GATEWAY_PRECOMPILE, + }, + startBlock: BigInt(0), + accountId: ACCOUNT_ID, + }); + + const signerFor = (walletIndex: number) => + PushChain.utils.signer.toUniversalFromKeypair(wallets[walletIndex], { + chain: CHAIN.PUSH_LOCALNET, + library: LIBRARY.ETHEREUM_VIEM, + }); + + const client = async ( + walletIndex: number, + opts: { agenticWallet?: Address; progressHook?: (e: unknown) => void } = {} + ) => { + const c = await PushChain.initialize(await signerFor(walletIndex), { + network: PUSH_NETWORK.LOCALNET, + rpcUrls: { [CHAIN.PUSH_LOCALNET]: [rpcUrl] }, + ...(opts.agenticWallet ? { agenticWallet: opts.agenticWallet } : {}), + ...(opts.progressHook + ? { progressHook: opts.progressHook as never } + : {}), + }); + clients.add(c); + return c; + }; + + const readOnlyClient = async ( + walletIndex: number, + opts: { agenticWallet?: Address } = {} + ) => { + const c = await PushChain.initialize( + { + address: wallets[walletIndex].account!.address, + chain: CHAIN.PUSH_LOCALNET, + }, + { + network: PUSH_NETWORK.LOCALNET, + rpcUrls: { [CHAIN.PUSH_LOCALNET]: [rpcUrl] }, + ...(opts.agenticWallet ? { agenticWallet: opts.agenticWallet } : {}), + } + ); + clients.add(c); + return c; + }; + + return { + rpcUrl, + anvil, + publicClient, + wallets, + generation, + addresses: { + engine, + validator, + rulesPolicy, + walletImplementation, + factory, + universalCore, + gateway: GATEWAY_PRECOMPILE, + target, + }, + deploy, + write, + client, + readOnlyClient, + dir, + stop: async () => { + await Promise.allSettled([...clients].map((c) => c.accountStatusReady)); + resetAgenticGenerations(); + await stopAnvil(anvil); + }, + }; +} + +export function artifactAbi(dir: string, file: string, name: string): Abi { + return artifact(dir, file, name).abi; +} diff --git a/packages/core/__agw-local__/labels.spec.ts b/packages/core/__agw-local__/labels.spec.ts new file mode 100644 index 000000000..86786d7ef --- /dev/null +++ b/packages/core/__agw-local__/labels.spec.ts @@ -0,0 +1,141 @@ +/** Real v5 wallet/factory label state; no bridge or destination simulation. */ +import { decodeEventLog, type Address } from 'viem'; +import { PushChain } from '../src'; +import { v5 } from '../src/lib/agentic/contracts/v5'; +import { AGENTIC_ERROR_CODE } from '../src/lib/agentic/errors'; +import { startHarness, type Harness } from './harness'; + +describe('v5 label SDK against real contracts', () => { + let h: Harness; + let owner: PushChain; + beforeAll(async () => { + h = await startHarness(18565); + owner = await h.client(0); + }); + afterAll(async () => { + await h?.stop(); + }); + const count = (address: Address) => + h.publicClient.readContract({ + address, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + + it('stores custom deploy labels and defaults empty labels per owner index', async () => { + const first = await owner.agentic.create('', { rules: [] }); + const second = await owner.agentic.create('custom', { rules: [] }); + const other = await h.client(2); + const theirs = await other.agentic.create('', { rules: [] }); + expect((await owner.agentic.wallet(first.wallet).info()).label).toBe( + `AGW ${first.index + 1}` + ); + expect((await owner.agentic.wallet(second.wallet).info()).label).toBe( + 'custom' + ); + expect((await other.agentic.wallet(theirs.wallet).info()).label).toBe( + 'AGW 1' + ); + expect(await count(first.wallet)).toBe(BigInt(0)); + }); + + it('renames and resets through public SDK; fresh info/list and checkpoints agree', async () => { + const made = await owner.agentic.create('before', { rules: [] }); + const w = owner.agentic.wallet(made.wallet); + const hook = jest.fn(); + const before = await count(made.wallet); + const tx = await w.setLabel('after', { progressHook: hook }); + const receipt = await tx.wait(); + const labelEvents = receipt.logs.flatMap((log) => { + if (log.address.toLowerCase() !== made.wallet.toLowerCase()) return []; + try { + const ev = decodeEventLog({ + abi: v5.abis.wallet, + data: log.data, + topics: log.topics, + }); + return ev.eventName === 'LabelSet' ? [ev.args.label] : []; + } catch { + return []; + } + }); + expect(labelEvents).toEqual(['after']); + expect((await w.info()).label).toBe('after'); + expect( + (await owner.agentic.list()).wallets.find( + (x) => x.address === made.wallet + )?.label + ).toBe('after'); + expect(await count(made.wallet)).toBe(before + BigInt(1)); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ id: 'AGENTIC-TX-199-01' }) + ); + await w.setLabel(''); + expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); + expect(await count(made.wallet)).toBe(before + BigInt(2)); + }); + + it('direct contract rename changes no checkpoint; subsequent SDK reads show it', async () => { + const made = await owner.agentic.create('before', { rules: [] }); + const before = await count(made.wallet); + await h.write(0, made.wallet, v5.abis.wallet, 'setLabel', ['direct']); + expect(await count(made.wallet)).toBe(before); + expect((await owner.agentic.wallet(made.wallet).info()).label).toBe( + 'direct' + ); + }); + + it('accepts exactly 64 UTF-8 bytes and rejects invalid create/rename without changing count or state', async () => { + const unicode = '🙂'.repeat(16); + const made = await owner.agentic.create(unicode, { rules: [] }); + const w = owner.agentic.wallet(made.wallet); + await w.setLabel('a'.repeat(64)); + const before = await count(made.wallet); + const next = await owner.agentic.derive(); + for (const invalid of ['a'.repeat(65), '🙂'.repeat(17)]) { + await expect(w.setLabel(invalid)).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.INVALID_RULE, + }); + await expect( + owner.agentic.create(invalid, { rules: [] }) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.INVALID_RULE }); + await expect( + h.publicClient.simulateContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'setLabel', + args: [invalid], + account: h.wallets[0].account!.address, + }) + ).rejects.toThrow('LabelTooLong'); + } + expect(await owner.agentic.derive()).toEqual(next); + expect(await count(made.wallet)).toBe(before); + expect((await w.info()).label).toBe('a'.repeat(64)); + }); + + it('non-owner and read-only SDK calls cannot rename; actual contract also rejects non-owner', async () => { + const made = await owner.agentic.create('owner-only', { rules: [] }); + const agent = await h.client(1); + await expect( + agent.agentic.wallet(made.wallet).setLabel('attack') + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NOT_WALLET_OWNER }); + const ro = await h.readOnlyClient(0); + await expect( + ro.agentic.wallet(made.wallet).setLabel('attack') + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.READ_ONLY }); + await expect( + h.publicClient.simulateContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'setLabel', + args: ['attack'], + account: h.wallets[1].account!.address, + }) + ).rejects.toThrow('CallerIsNotOwner'); + expect((await owner.agentic.wallet(made.wallet).info()).label).toBe( + 'owner-only' + ); + expect(await count(made.wallet)).toBe(BigInt(0)); + }); +}); diff --git a/packages/core/__agw-local__/setup.ts b/packages/core/__agw-local__/setup.ts new file mode 100644 index 000000000..c35a8fc56 --- /dev/null +++ b/packages/core/__agw-local__/setup.ts @@ -0,0 +1,18 @@ +/** Actual RPC/actions with a local poll interval, not mocked transactions. */ +jest.mock('viem', () => { + const actual = jest.requireActual('viem'); + return { + ...actual, + // viem 2.27 poll() leaves an already-scheduled wait alive after unwatch. + // Its production 4s default needlessly delays local Jest shutdown. Keep + // real receipt/replacement logic, and configure short local polling. + createPublicClient: (options: { + pollingInterval?: number; + [key: string]: unknown; + }) => + actual.createPublicClient({ + ...options, + pollingInterval: options?.pollingInterval ?? 25, + }), + }; +}); diff --git a/packages/core/__agw-local__/svm-backend.spec.ts b/packages/core/__agw-local__/svm-backend.spec.ts new file mode 100644 index 000000000..98e22be9d --- /dev/null +++ b/packages/core/__agw-local__/svm-backend.spec.ts @@ -0,0 +1,549 @@ +/** Internal SVM lifecycle/execution on actual v5 contracts; no Solana/TSS simulation. */ +import { PublicKey } from '@solana/web3.js'; +import { + encodeFunctionData, + encodeErrorResult, + parseAbi, + type Address, + type Hex, +} from 'viem'; +import { CHAIN, type PushChain } from '../src'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; +import { Snapshot } from '../src/lib/agentic/reads/snapshot'; +import { readSvmRule } from '../src/lib/agentic/reads/svm'; +import { + grantSvmWire, + prepareSvmReplacement, + replaceSvmWire, + type SvmWireGrant, +} from '../src/lib/agentic/management/svm'; +import { + resolveSvmContext, + type SvmMetadataProvider, +} from '../src/lib/agentic/management/svm-context'; +import { + prepareSvmAgentExecution, + sendSvmAgentWire, +} from '../src/lib/agentic/execution/svm-send'; +import { + registerIdl, + clearRegistry, +} from '../src/lib/orchestrator/svm-idl/registry'; +import { v5 } from '../src/lib/agentic/contracts/v5'; +import { wrapSendError } from '../src/lib/agentic/management/common'; +import { startHarness, type Harness } from './harness'; + +const key = (b: string) => `0x${b.repeat(32)}` as Hex; +const GATEWAY = key('22'), + PROGRAM = key('33'), + MINT = key('44'), + MINT2 = key('55'); +const SPL = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + TOKEN2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; +const TOKEN = parseAbi([ + 'function mint(address,uint256)', + 'function approve(address,uint256) returns (bool)', + 'function setSourceTokenAddress(string)', + 'function setSourceChainNamespace(string)', +]); +const CORE = parseAbi([ + 'function setGasToken(string,address)', + 'function getOutboundTxGasAndFees(address,uint256) view returns (address,uint256,uint256,uint256,string,uint256)', +]); +const rt = (c: PushChain) => + (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; +const metadata: SvmMetadataProvider = { + gateway: async (chain) => ({ chainNamespace: chain, program: GATEWAY }), + mint: async (_chain, address) => ({ + mint: address, + tokenProgram: address === MINT2 ? TOKEN2022 : SPL, + }), +}; + +describe('internal prepared SVM backend on v5', () => { + let h: Harness, owner: PushChain, tokens: Address[]; + beforeAll(async () => { + h = await startHarness(18554); + owner = await h.client(0); + tokens = []; + for (const source of ['', MINT, MINT2]) { + const token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.SOLANA_DEVNET, + 9, + ]); + await h.write(0, token, TOKEN, 'setSourceTokenAddress', [source]); + tokens.push(token); + } + await h.write(0, h.addresses.universalCore, CORE, 'setGasToken', [ + CHAIN.SOLANA_DEVNET, + tokens[0], + ]); + }); + afterEach(() => { + jest.restoreAllMocks(); + clearRegistry(); + }); + afterAll(async () => h?.stop()); + const cfg = async (wallet: Address, id: Hex) => + readSvmRule( + await Snapshot.at(rt(owner).reader), + h.generation, + wallet, + id, + rt(owner).pushChainNamespace + ); + function configure(runtime: AgenticRuntime) { + runtime.quoteOutbound = async (token, limit) => { + const [, nativeValueForGas, protocolFee, , , gasLimitUsed] = + await h.publicClient.readContract({ + address: h.addresses.universalCore, + abi: CORE, + functionName: 'getOutboundTxGasAndFees', + args: [token, limit], + }); + return { nativeValueForGas, protocolFee, gasLimitUsed }; + }; + } + async function setup() { + const { wallet } = await owner.agentic.create('svm-backend', { rules: [] }); + const caps = tokens.map((token) => ({ + token, + maxPerCall: BigInt(5), + maxTotal: BigInt(10), + })); + const resolved = await resolveSvmContext( + await Snapshot.at(rt(owner).reader), + wallet, + CHAIN.SOLANA_DEVNET, + caps, + metadata + ); + const terms = { + ...resolved, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(16), + programs: [ + { + program: PROGRAM, + discriminator: '0x0102030405060708' as Hex, + discriminatorLen: 8, + dataless: false, + maxAccounts: 3, + }, + ], + pins: resolved.ceaAccounts.map((expected, accountIndex) => ({ + ruleIndex: 0, + accountIndex, + expected, + })), + dataPins: [], + }; + const input: SvmWireGrant = { + agent: h.wallets[1].account!.address, + chainNamespace: CHAIN.SOLANA_DEVNET, + terms, + }; + const grant = await grantSvmWire(rt(owner), h.generation, wallet, input); + registerIdl({ + address: new PublicKey(Buffer.from(PROGRAM.slice(2), 'hex')).toBase58(), + metadata: { name: 'fixture', version: '1', spec: '0.1.0' }, + instructions: [ + { + name: 'go', + discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + accounts: [ + { name: 'cea_authority', writable: true }, + ...resolved.ceaAccounts.slice(1).map((k, i) => ({ + name: `ata${i}`, + writable: true, + address: new PublicKey(Buffer.from(k.slice(2), 'hex')).toBase58(), + })), + ], + args: [], + }, + ], + }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + for (const token of tokens) { + await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(100)]); + await h.write(0, wallet, v5.abis.wallet, 'execute', [ + key('00'), + v5.packSingle({ + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(100)], + }), + }), + ]); + } + const agent = await h.client(1); + configure(rt(agent)); + const request = (token = tokens[2], amount = BigInt(5)) => ({ + wallet, + rulesId: grant.rulesId, + token, + amount, + gasLimit: BigInt(200_000), + program: PROGRAM, + instructionData: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + }); + return { wallet, id: grant.rulesId, input, agent, request }; + } + it('reads all source assets/counters/pins losslessly through public SVM reads', async () => { + const s = await setup(), + stored = await cfg(s.wallet, s.id); + expect(stored.config.assets.map((a) => a.token)).toEqual(tokens); + expect(stored.config.assets.map((a) => a.spent)).toEqual([ + BigInt(0), + BigInt(0), + BigInt(0), + ]); + expect(stored.config.pins).toEqual(s.input.terms.pins); + expect(stored.chainNamespace).toBe(CHAIN.SOLANA_DEVNET); + await expect( + owner.agentic.wallet(s.wallet).rules.get(s.id) + ).resolves.toMatchObject({ rule: { format: 'decoded', pins: s.input.terms.pins } }); + }); + it('runs context, IDL, quote, allowance, wrapping and real token pull/burn as the agent', async () => { + const s = await setup(); + const sent = await sendSvmAgentWire( + rt(s.agent), + h.generation, + s.request(), + metadata + ); + expect((await sent.tx.wait()).status).toBe(1); + const root = await h.publicClient.getTransactionReceipt({ + hash: sent.tx.hash as Hex, + }); + expect(root.from).toBe(h.wallets[1].account!.address.toLowerCase()); + expect( + (await cfg(s.wallet, s.id)).config.assets.map((a) => a.spent) + ).toEqual([BigInt(0), BigInt(0), BigInt(5)]); + }); + it('refuses the wrong signer before execute', async () => { + const s = await setup(), + execute = jest.spyOn(rt(owner), 'execute'); + await expect( + prepareSvmAgentExecution(rt(owner), h.generation, s.request(), metadata) + ).rejects.toMatchObject({ code: 'NOT_OWNER_OR_AGENT' }); + expect(execute).not.toHaveBeenCalled(); + }); + it('requires separate allowance and never writes an approval on the agent path', async () => { + const s = await setup(); + await h.write(0, s.wallet, v5.abis.wallet, 'execute', [ + key('00'), + v5.packSingle({ + target: tokens[2], + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(0)], + }), + }), + ]); + const execute = jest.spyOn(rt(s.agent), 'execute'); + await expect( + sendSvmAgentWire(rt(s.agent), h.generation, s.request(), metadata) + ).rejects.toMatchObject({ code: 'GATEWAY_ALLOWANCE_INSUFFICIENT' }); + expect(execute).not.toHaveBeenCalled(); + }); + it('atomically replaces and resets every asset, recording five checkpoints', async () => { + const s = await setup(); + await ( + await sendSvmAgentWire(rt(s.agent), h.generation, s.request(), metadata) + ).tx.wait(); + const before = await h.publicClient.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + const next = await replaceSvmWire( + rt(owner), + h.generation, + s.wallet, + s.id, + s.input + ); + expect( + (await cfg(s.wallet, next.rulesId)).config.assets.map((a) => a.spent) + ).toEqual([BigInt(0), BigInt(0), BigInt(0)]); + await expect(cfg(s.wallet, s.id)).rejects.toMatchObject({ + code: 'RULE_NOT_FOUND', + }); + expect( + await h.publicClient.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(before + BigInt(5)); + }); + it('a second-asset spend after snapshot rolls back revoke/grant/checkpoints', async () => { + const s = await setup(), + prepared = await prepareSvmReplacement( + rt(owner), + h.generation, + s.wallet, + s.id, + s.input + ); + await ( + await sendSvmAgentWire( + rt(s.agent), + h.generation, + s.request(tokens[1]), + metadata + ) + ).tx.wait(); + const before = await h.publicClient.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + const error = await rt(owner) + .execute({ to: s.wallet, value: BigInt(0), data: prepared.data }) + .catch(wrapSendError); + expect(error).toMatchObject({ + decodedError: { name: 'AssetSpentMismatch' }, + }); + expect((await cfg(s.wallet, s.id)).config.assets[1].spent).toBe(BigInt(5)); + expect( + await h.publicClient.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(before); + }); + it('grant initialization failure after revoke rolls back the old rule', async () => { + const s = await setup(); + const alternate = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.SOLANA_DEVNET, + 9, + ]); + const input = { + ...s.input, + terms: { + ...s.input.terms, + assets: [ + { token: alternate, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, + ], + }, + }; + const prepared = await prepareSvmReplacement( + rt(owner), + h.generation, + s.wallet, + s.id, + input + ); + await h.write(0, alternate, TOKEN, 'setSourceChainNamespace', [ + CHAIN.ETHEREUM_SEPOLIA, + ]); + const error = await rt(owner) + .execute({ to: s.wallet, value: BigInt(0), data: prepared.data }) + .catch(wrapSendError); + expect(error).toMatchObject({ decodedError: { name: 'ChainMismatch' } }); + expect((await cfg(s.wallet, s.id)).config.assets).toHaveLength(3); + }); + it('generic owner revocation disables a SVM rule without public decoding', async () => { + const s = await setup(); + await (await owner.agentic.wallet(s.wallet).rules.revoke([s.id])).wait(); + await expect(cfg(s.wallet, s.id)).rejects.toMatchObject({ + code: 'RULE_NOT_FOUND', + }); + }); + it('returns the receipt ID when another owner grant advances the nonce during replacement', async () => { + const s = await setup(), + execute = rt(owner).execute; + let intercepted = false; + rt(owner).execute = async (params, opts) => { + if (!intercepted) { + intercepted = true; + await grantSvmWire(rt(owner), h.generation, s.wallet, s.input); + } + return execute(params, opts); + }; + try { + const next = await replaceSvmWire( + rt(owner), + h.generation, + s.wallet, + s.id, + s.input + ); + expect((await cfg(s.wallet, next.rulesId)).config.assets).toHaveLength(3); + await expect(cfg(s.wallet, s.id)).rejects.toMatchObject({ + code: 'RULE_NOT_FOUND', + }); + } finally { + rt(owner).execute = execute; + } + }); + it.each(['allowance', 'balance', 'revocation'])( + 'on-chain %s change after preflight refuses submission without metering', + async (kind) => { + const s = await setup(), + runtime = rt(s.agent), + original = runtime.execute; + const before = await cfg(s.wallet, s.id); + const nonce = await h.publicClient.getTransactionCount({ + address: h.wallets[1].account!.address, + blockTag: 'pending', + }); + let ticks: bigint | undefined; + runtime.execute = async (params, options) => { + if (kind === 'revocation') + await ( + await owner.agentic.wallet(s.wallet).rules.revoke([s.id]) + ).wait(); + else + await h.write(0, s.wallet, v5.abis.wallet, 'execute', [ + key('00'), + v5.packSingle({ + target: tokens[2], + value: BigInt(0), + data: encodeFunctionData({ + abi: parseAbi([ + 'function approve(address,uint256) returns(bool)', + 'function transfer(address,uint256) returns(bool)', + ]), + functionName: kind === 'allowance' ? 'approve' : 'transfer', + args: [ + kind === 'allowance' + ? h.addresses.gateway + : h.wallets[0].account!.address, + kind === 'allowance' ? BigInt(0) : BigInt(100), + ], + }), + }), + ]); + ticks = await h.publicClient.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + return original(params, options); + }; + try { + const error = await sendSvmAgentWire( + runtime, + h.generation, + s.request(), + metadata + ).catch((e) => e); + expect(error.name).toBe('AgenticRevertError'); + if (kind === 'revocation') + expect(error.decodedError?.name).toBe('CallerIsNotAgent'); + else { + const name = kind === 'allowance' ? 'LowAllowance' : 'LowBalance'; + const data = encodeErrorResult({ + abi: parseAbi([`error ${name}()`]), + errorName: name, + }); + expect(error.message).toContain(data); + } + const stored = await h.publicClient.readContract({ + address: h.addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getSvmConfig', + args: [before.configId, s.wallet], + }); + expect(stored.assets.map((a) => a.spent)).toEqual([ + BigInt(0), + BigInt(0), + BigInt(0), + ]); + expect( + await h.publicClient.getTransactionCount({ + address: h.wallets[1].account!.address, + blockTag: 'pending', + }) + ).toBe(nonce); + expect( + await h.publicClient.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(ticks); + } finally { + runtime.execute = original; + } + } + ); + it('an RPC outage during rule reads fails before execute or signing', async () => { + const s = await setup(), + runtime = rt(s.agent), + reader = runtime.reader, + read = reader.readContract.bind(reader); + const execute = jest.spyOn(runtime, 'execute'); + const failedReader = { + ...reader, + readContract: async (args: Parameters[0]) => { + if (args.functionName === 'getSvmConfig') + throw new Error('fixture RPC outage'); + return read(args); + }, + }; + const failed = { ...runtime, reader: failedReader }; + await expect( + sendSvmAgentWire(failed, h.generation, s.request(), metadata) + ).rejects.toMatchObject({ code: 'RULE_READ_FAILED' }); + expect(execute).not.toHaveBeenCalled(); + expect( + (await cfg(s.wallet, s.id)).config.assets.map((a) => a.spent) + ).toEqual([BigInt(0), BigInt(0), BigInt(0)]); + }); + it('receipt failure after a mined grant retains the hash and does not retry', async () => { + const s = await setup(), + runtime = rt(owner), + original = runtime.execute; + let hash: Hex | undefined, + submissions = 0; + runtime.execute = async (params, options) => { + submissions++; + const tx = await original(params, options); + hash = tx.hash as Hex; + await h.publicClient.waitForTransactionReceipt({ hash }); + tx.wait = async () => { + throw new Error('fixture receipt outage'); + }; + return tx; + }; + try { + await expect( + grantSvmWire(runtime, h.generation, s.wallet, s.input) + ).rejects.toMatchObject({ + code: 'RECEIPT_UNAVAILABLE', + details: { txHash: expect.any(String) }, + }); + expect(submissions).toBe(1); + expect(hash).toBeDefined(); + expect( + await h.publicClient.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'grantNonce', + }) + ).toBe(BigInt(2)); + expect( + (await h.publicClient.getTransactionReceipt({ hash: hash! })).status + ).toBe('success'); + } finally { + runtime.execute = original; + } + }); +}); diff --git a/packages/core/__agw-local__/svm-differential.spec.ts b/packages/core/__agw-local__/svm-differential.spec.ts new file mode 100644 index 000000000..520261555 --- /dev/null +++ b/packages/core/__agw-local__/svm-differential.spec.ts @@ -0,0 +1,418 @@ +/** Seeded, independent SDK-vs-contract comparisons. No live transport/funding. */ +import { + encodeAbiParameters, + encodeFunctionData, + parseAbi, + type Hex, +} from 'viem'; +import { CHAIN } from '../src/lib/constants/enums'; +import { buildSession, encodeEnvelope } from '../src/lib/agentic/codec/session'; +import { + SVM_TERMS_PARAM, + validateSvmTerms, + type SvmTermsWire, +} from '../src/lib/agentic/codec/svm-terms'; +import { + encodeAgwSvmPayload, + validateAgwSvmPayload, +} from '../src/lib/agentic/execution/svm-payload'; +import { v5 } from '../src/lib/agentic/contracts/v5'; +import { deriveAgwSvmValueAccounts } from '../src/lib/agentic/codec/svm-accounts'; +import { decodeAgenticRevert } from '../src/lib/agentic/revert'; +import { startHarness, type Harness } from './harness'; +const key = (b: string) => `0x${b.repeat(32)}` as Hex; +const GATEWAY = key('22'), + PROGRAM = key('33'); +const TOKEN = parseAbi([ + 'function mint(address,uint256)', + 'function approve(address,uint256) returns(bool)', +]); +const seed = 0x5a17; +let rng = seed; +function random(n: number) { + rng = (Math.imul(rng, 1664525) + 1013904223) >>> 0; + return rng % n; +} +const clone = (t: T): T => structuredClone(t); + +describe('seeded SVM differential validation', () => { + let h: Harness, wallet: `0x${string}`, token: `0x${string}`; + let terms: SvmTermsWire; + beforeAll(async () => { + h = await startHarness(18555); + token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.SOLANA_DEVNET, + 9, + ]); + wallet = ( + await (await h.client(0)).agentic.create('differential', { rules: [] }) + ).wallet; + const derived = deriveAgwSvmValueAccounts(wallet, GATEWAY, []); + terms = { + ...derived, + validUntil: Math.floor(Date.now() / 1000) + 3600, + assets: [{ token, maxPerCall: BigInt(10), maxTotal: BigInt(100) }], + maxGasPerCall: BigInt(10) ** BigInt(16), + programs: [ + { + program: PROGRAM, + discriminator: '0x0100000000000000', + discriminatorLen: 1, + dataless: false, + maxAccounts: 1, + }, + ], + pins: [{ ruleIndex: 0, accountIndex: 0, expected: derived.expectedCEA }], + dataPins: [], + }; + }); + afterAll(async () => h?.stop()); + const session = (t: SvmTermsWire) => + buildSession({ + validator: h.addresses.validator, + rulesPolicy: h.addresses.rulesPolicy, + agent: h.wallets[1].account!.address, + actions: [ + { + target: h.addresses.gateway, + selector: '0x77b86bec', + initData: encodeEnvelope( + CHAIN.SOLANA_DEVNET, + encodeAbiParameters([SVM_TERMS_PARAM], [t]) + ), + }, + ], + }); + + it('grant-shape corpus agrees with the actual URP initializer', async () => { + const cases: { name: string; terms: SvmTermsWire }[] = []; + const add = (name: string, mutate: (t: SvmTermsWire) => void) => { + const t = clone(terms); + mutate(t); + cases.push({ name, terms: t }); + }; + add('base', () => { + /* Keep the baseline unchanged for the first comparison. */ + }); + for (const per of [ + BigInt(0), + BigInt(1), + (BigInt(1) << BigInt(64)) - BigInt(1), + (BigInt(1) << BigInt(256)) - BigInt(1), + ]) + for (const total of [ + BigInt(0), + BigInt(1), + (BigInt(1) << BigInt(64)) - BigInt(1), + (BigInt(1) << BigInt(256)) - BigInt(1), + ]) + add(`caps-${per}-${total}`, (t) => { + t.assets = [{ token, maxPerCall: per, maxTotal: total }]; + }); + add('zero-pc-hard-cap', (t) => { + t.maxGasPerCall = BigInt(0); + }); + add('zero-assets', (t) => { + t.assets = []; + }); + add('duplicate-asset', (t) => { + t.assets = [...t.assets, t.assets[0]]; + }); + add('zero-token', (t) => { + t.assets = [ + { ...t.assets[0], token: '0x0000000000000000000000000000000000000000' }, + ]; + }); + add('empty-programs', (t) => { + t.programs = []; + }); + add('zero-cea', (t) => { + t.expectedCEA = key('00'); + }); + add('zero-gateway', (t) => { + t.gatewayProgram = key('00'); + }); + add('zero-expiry', (t) => { + t.validUntil = 0; + }); + add('no-pins', (t) => { + t.pins = []; + }); + add('duplicate-pin', (t) => { + t.pins = [...t.pins, t.pins[0]]; + }); + add('pin-rule-outside', (t) => { + t.pins[0].ruleIndex = 1; + }); + add('pin-index-outside', (t) => { + t.pins[0].accountIndex = 64; + }); + add('no-cea-coverage', (t) => { + t.ceaAccounts = []; + }); + add('duplicate-cea', (t) => { + t.ceaAccounts = [t.expectedCEA, t.expectedCEA]; + }); + add('forbidden-program', (t) => { + t.programs[0].program = t.gatewayProgram; + }); + add('overlapping-program-rules', (t) => { + t.programs = [...t.programs, { ...t.programs[0] }]; + t.pins = [...t.pins, { ...t.pins[0], ruleIndex: 1 }]; + }); + for (const count of [0, 1, 63, 64, 65]) + add(`account-count-${count}`, (t) => { + t.programs[0].maxAccounts = count; + }); + for (const len of [0, 1, 8, 9]) + add(`tag-len-${len}`, (t) => { + t.programs[0].discriminatorLen = len; + }); + add('dataless', (t) => { + t.programs[0].dataless = true; + t.programs[0].discriminatorLen = 0; + }); + for (let i = 0; i < 64; i++) { + const mode = random(4), + width = [0, 1, 2, 8, 9, 32, 33][random(7)], + fromEnd = Boolean(random(2)); + const offset = [0, 1, width, 1024 - width, 1024, 1025][random(6)]; + add(`pin-seed-${i}`, (t) => { + t.dataPins = [ + { + ruleIndex: 0, + fromEnd, + offset, + offsetB: width, + len: width, + mode, + expected: key('00'), + num: BigInt(mode === 3 ? random(2) : 0), + den: BigInt(mode === 3 ? random(2) : 0), + }, + ]; + }); + } + const mismatches: string[] = []; + let accepted = 0, + rejected = 0; + const now = Number((await h.publicClient.getBlock()).timestamp); + for (const c of cases) { + let sdk = true, + contract = true; + try { + validateSvmTerms(c.terms, now); + } catch { + sdk = false; + } + try { + await h.publicClient.call({ + to: wallet, + data: v5.encodeGrantRules(session(c.terms)), + account: h.wallets[0].account!.address, + }); + } catch (e) { + contract = false; + expect(decodeAgenticRevert(e)).toBeDefined(); + } + if (contract) accepted++; + else rejected++; + if (sdk !== contract) + mismatches.push(c.name + `: sdk=${sdk},contract=${contract}`); + } + expect(accepted).toBeGreaterThan(0); + expect(rejected).toBeGreaterThan(0); + expect(mismatches).toEqual([]); + console.log( + JSON.stringify({ + corpus: 'svm-grant', + seed, + cases: cases.length, + accepted, + rejected, + mismatches, + }) + ); + }); + + it('payload mutation corpus agrees with actual URP authorization and rejects without metering', async () => { + const t = clone(terms); + t.programs[0].maxAccounts = 0; + t.dataPins = [ + { + ruleIndex: 0, + fromEnd: false, + offset: 1, + offsetB: 0, + len: 1, + mode: 1, + expected: `0x${'00'.repeat(31)}05`, + num: BigInt(0), + den: BigInt(0), + }, + ]; + await h.write(0, wallet, v5.abis.wallet, 'grantRules', [session(t)]); + const [id] = await h.publicClient.readContract({ + address: h.addresses.engine, + abi: v5.abis.engine, + functionName: 'getPermissionIDs', + args: [wallet], + }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(100)]); + await h.write(0, wallet, v5.abis.wallet, 'execute', [ + key('00'), + v5.packSingle({ + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(100)], + }), + }), + ]); + const base = encodeAgwSvmPayload( + PROGRAM, + [{ pubkey: t.expectedCEA, isWritable: true }], + new Uint8Array([1, 5]) + ); + const cases: { name: string; payload: Hex }[] = [ + { name: 'base', payload: base }, + ]; + for (const amount of [0, 4, 5, 6, 255]) + cases.push({ + name: `floor-${amount}`, + payload: encodeAgwSvmPayload( + PROGRAM, + [{ pubkey: t.expectedCEA, isWritable: true }], + new Uint8Array([1, amount]) + ), + }); + cases.push({ + name: 'uncovered-cea-alias', + payload: encodeAgwSvmPayload( + PROGRAM, + [ + { pubkey: t.expectedCEA, isWritable: true }, + { pubkey: t.expectedCEA, isWritable: false }, + ], + new Uint8Array([1, 5]) + ), + }); + cases.push({ + name: 'pin-substitution', + payload: encodeAgwSvmPayload( + PROGRAM, + [{ pubkey: key('66'), isWritable: true }], + new Uint8Array([1, 5]) + ), + }); + for (let i = 0; i < 80; i++) { + const bytes = Buffer.from(base.slice(2), 'hex'); + if (i % 4 === 0) + cases.push({ + name: `truncate-${i}`, + payload: `0x${bytes + .subarray(0, random(bytes.length)) + .toString('hex')}`, + }); + else if (i % 4 === 1) + cases.push({ + name: `trailing-${i}`, + payload: `${base}${random(256).toString(16).padStart(2, '0')}` as Hex, + }); + else { + const at = random(bytes.length); + bytes[at] ^= 1 << random(8); + cases.push({ + name: `flip-${i}-${at}`, + payload: `0x${bytes.toString('hex')}`, + }); + } + } + const mismatches: string[] = []; + let accepted = 0, + rejected = 0; + for (const c of cases) { + let sdk = true, + contract = true; + try { + validateAgwSvmPayload(t, PROGRAM, c.payload); + } catch { + sdk = false; + } + const data = encodeFunctionData({ + abi: parseAbi([ + 'function sendUniversalTxOutbound((bytes recipient,address token,uint256 amount,uint256 gasLimit,uint256 gasPrice,uint256 maxPCForGas,bytes payload,address revertRecipient) req) payable', + ]), + functionName: 'sendUniversalTxOutbound', + args: [ + { + recipient: PROGRAM, + token, + amount: BigInt(1), + gasLimit: BigInt(200_000), + gasPrice: BigInt(0), + maxPCForGas: BigInt(10) ** BigInt(15), + payload: c.payload, + revertRecipient: wallet, + }, + ], + }); + try { + await h.publicClient.simulateContract({ + address: wallet, + abi: v5.abis.wallet, + functionName: 'executeAsAgent', + args: [ + id, + key('00'), + v5.packSingle({ + target: h.addresses.gateway, + value: BigInt(11) * BigInt(10) ** BigInt(14), + data, + }), + ], + account: h.wallets[1].account!.address, + }); + } catch (e) { + contract = false; + expect(decodeAgenticRevert(e)).toBeDefined(); + } + if (contract) accepted++; + else rejected++; + if (sdk !== contract) + mismatches.push(c.name + `: sdk=${sdk},contract=${contract}`); + } + expect(accepted).toBeGreaterThan(0); + expect(rejected).toBeGreaterThan(0); + expect(mismatches).toEqual([]); + // Every case used eth_call; even successful probes cannot spend an asset. + expect( + await h.publicClient.readContract({ + address: token, + abi: parseAbi(['function balanceOf(address) view returns(uint256)']), + functionName: 'balanceOf', + args: [wallet], + }) + ).toBe(BigInt(100)); + console.log( + JSON.stringify({ + corpus: 'svm-payload', + seed, + cases: cases.length, + accepted, + rejected, + mismatches, + }) + ); + }); +}); diff --git a/packages/core/__agw-local__/svm-internals.spec.ts b/packages/core/__agw-local__/svm-internals.spec.ts new file mode 100644 index 000000000..24163e4d3 --- /dev/null +++ b/packages/core/__agw-local__/svm-internals.spec.ts @@ -0,0 +1,328 @@ +/** Actual v5 SVM URP checks on Anvil. No Solana execution/TSS is simulated. */ +import { encodeFunctionData, parseAbi, type Address, type Hex } from 'viem'; +import { CHAIN } from '../src/lib/constants/enums'; +import { buildSession, encodeEnvelope } from '../src/lib/agentic/codec/session'; +import { configId } from '../src/lib/agentic/codec/ids'; +import { + encodeSvmTerms, + SvmDataPinMode, + UINT64_MAX, + type SvmTermsWire, +} from '../src/lib/agentic/codec/svm-terms'; +import { deriveAgwSvmValueAccounts } from '../src/lib/agentic/codec/svm-accounts'; +import { encodeAgwSvmPayload } from '../src/lib/agentic/execution/svm-payload'; +import { composeAgwSvmAction } from '../src/lib/agentic/execution/svm-outbound'; +import { v5, type SvmConfigRead } from '../src/lib/agentic/contracts/v5'; +import { startHarness, type Harness } from './harness'; + +const key = (b: string) => `0x${b.repeat(32)}` as Hex; +const GATEWAY_PROGRAM = key('22'), + PROGRAM = key('33'), + MINT = key('44'); +const TOKEN = parseAbi([ + 'function mint(address,uint256)', + 'function approve(address,uint256) returns (bool)', +]); +const UINT256_MAX = BigInt(2) ** BigInt(256) - BigInt(1); + +describe('internal SVM codec/payloads against actual v5 contracts', () => { + let h: Harness, token: Address; + beforeAll(async () => { + h = await startHarness(18552); + token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.SOLANA_DEVNET, + 9, + ]); + }); + afterAll(async () => { + await h?.stop(); + }); + async function setup( + options: { dataless?: boolean; maxAccounts?: number } = {} + ) { + const owner = await h.client(0), + created = await owner.agentic.create('svm-internal', { rules: [] }); + const wallet = created.wallet; + const derived = deriveAgwSvmValueAccounts(wallet, GATEWAY_PROGRAM, [ + { mint: MINT }, + ]); + const t: SvmTermsWire = { + ...derived, + validUntil: Math.floor(Date.now() / 1000) + 3600, + assets: [{ token, maxPerCall: UINT256_MAX, maxTotal: UINT256_MAX }], + maxGasPerCall: BigInt(10) ** BigInt(16), + programs: [ + { + program: PROGRAM, + discriminator: '0x0100000000000000', + discriminatorLen: options.dataless ? 0 : 1, + dataless: options.dataless ?? false, + maxAccounts: options.maxAccounts ?? 0, + }, + ], + pins: [ + { ruleIndex: 0, accountIndex: 0, expected: derived.expectedCEA }, + { ruleIndex: 0, accountIndex: 1, expected: derived.ceaAccounts[1] }, + ], + dataPins: options.dataless + ? [] + : [ + { + ruleIndex: 0, + fromEnd: true, + offset: 4, + offsetB: 2, + len: 2, + mode: SvmDataPinMode.RATIO_GTE_LE, + expected: key('00'), + num: BigInt(9), + den: BigInt(10), + }, + ], + }; + const session = buildSession({ + validator: h.addresses.validator, + rulesPolicy: h.addresses.rulesPolicy, + agent: h.wallets[1].account!.address, + actions: [ + { + target: h.addresses.gateway, + selector: '0x77b86bec', + initData: encodeEnvelope(CHAIN.SOLANA_DEVNET, encodeSvmTerms(t, 0)), + }, + ], + }); + await h.write(0, wallet, v5.abis.wallet, 'grantRules', [session]); + const [id] = await h.publicClient.readContract({ + address: h.addresses.engine, + abi: v5.abis.engine, + functionName: 'getPermissionIDs', + args: [wallet], + }); + const [action] = await h.publicClient.readContract({ + address: h.addresses.engine, + abi: v5.abis.engine, + functionName: 'getEnabledActions', + args: [wallet, id], + }); + const cid = configId(wallet, id, action); + const cfg = (await h.publicClient.readContract({ + address: h.addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getSvmConfig', + args: [cid, wallet], + })) as SvmConfigRead; + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(100)]); + await h.write(0, wallet, v5.abis.wallet, 'execute', [ + key('00'), + v5.packSingle({ + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(100)], + }), + }), + ]); + const accounts = [ + { pubkey: t.expectedCEA, isWritable: true }, + { pubkey: t.ceaAccounts[1], isWritable: true }, + ]; + const payload = encodeAgwSvmPayload( + PROGRAM, + accounts, + options.dataless ? new Uint8Array() : new Uint8Array([1, 90, 0, 100, 0]) + ); + const input = { + wallet, + gateway: h.addresses.gateway, + token, + amount: BigInt(5), + recipient: PROGRAM, + payload, + gasLimit: BigInt(200_000), + protocolFee: BigInt(10) ** BigInt(14), + maxPCForGas: BigInt(10) ** BigInt(15), + }; + const call = composeAgwSvmAction(input, cfg, 0); + const simulate = (p: Hex, recipient = PROGRAM, amount = BigInt(5)) => { + const data = encodeFunctionData({ + abi: parseAbi([ + 'function sendUniversalTxOutbound((bytes recipient,address token,uint256 amount,uint256 gasLimit,uint256 gasPrice,uint256 maxPCForGas,bytes payload,address revertRecipient) req) payable', + ]), + functionName: 'sendUniversalTxOutbound', + args: [ + { + recipient, + token, + amount, + gasLimit: BigInt(200_000), + gasPrice: BigInt(0), + maxPCForGas: input.maxPCForGas, + payload: p, + revertRecipient: wallet, + }, + ], + }); + return h.publicClient.simulateContract({ + address: wallet, + abi: v5.abis.wallet, + functionName: 'executeAsAgent', + args: [ + id, + key('00'), + v5.packSingle({ + target: h.addresses.gateway, + value: call.value, + data, + }), + ], + account: h.wallets[1].account!.address, + }); + }; + async function reject( + p: Hex, + name: string, + recipient = PROGRAM, + amount = BigInt(5) + ) { + try { + await simulate(p, recipient, amount); + throw new Error('Expected policy rejection'); + } catch (error) { + const { decodeAgenticRevert } = await import( + '../src/lib/agentic/revert' + ); + expect(decodeAgenticRevert(error)?.name).toBe( + `PolicyCheckReverted(${name})` + ); + } + } + return { + wallet, + id, + cid, + t, + cfg, + accounts, + payload, + call, + simulate, + reject, + }; + } + it('grants SDK-encoded SVM terms and preserves every field/account', async () => { + const { cfg, t } = await setup(); + expect(cfg).toMatchObject({ + initialized: true, + expectedCEA: t.expectedCEA, + gatewayProgram: t.gatewayProgram, + ceaAccounts: t.ceaAccounts, + programs: t.programs, + pins: t.pins, + dataPins: t.dataPins, + assets: [ + { + token, + maxPerCall: UINT256_MAX, + maxTotal: UINT256_MAX, + spent: BigInt(0), + }, + ], + }); + }); + it('the internal composed call passes actual S1–S18 and meters its token', async () => { + const { wallet, id, cid, call } = await setup(); + await h.write(1, wallet, v5.abis.wallet, 'executeAsAgent', [ + id, + key('00'), + v5.packSingle(call), + ]); + const cfg = await h.publicClient.readContract({ + address: h.addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getSvmConfig', + args: [cid, wallet], + }); + expect(cfg.assets[0].spent).toBe(BigInt(5)); + }); + it('account substitution and CEA aliasing are rejected by URP', async () => { + const s = await setup(); + await s.reject( + encodeAgwSvmPayload( + PROGRAM, + [{ ...s.accounts[0], pubkey: key('77') }, s.accounts[1]], + new Uint8Array([1, 90, 0, 100, 0]) + ), + 'SvmAccountPinMismatch' + ); + await s.reject( + encodeAgwSvmPayload( + PROGRAM, + [...s.accounts, s.accounts[0]], + new Uint8Array([1, 90, 0, 100, 0]) + ), + 'CeaAccountAtUnpinnedIndex' + ); + }); + it('relative price floor and recipient/program identity are enforced on-chain', async () => { + const s = await setup(); + await s.reject( + encodeAgwSvmPayload( + PROGRAM, + s.accounts, + new Uint8Array([1, 89, 0, 100, 0]) + ), + 'SvmDataRatioNotMet' + ); + await s.reject(s.payload, 'RecipientTargetMismatch', key('66')); + }); + it('trailing bytes, non-execute IDs and u64 overflow are rejected', async () => { + const s = await setup(); + await s.reject(`${s.payload}00`, 'MalformedSvmPayload'); + const bytes = Buffer.from(s.payload.slice(2), 'hex'); + bytes[bytes.length - 33] = 1; + await s.reject(`0x${bytes.toString('hex')}`, 'SvmInstructionNotExecute'); + await s.reject( + s.payload, + 'AmountExceedsU64', + PROGRAM, + UINT64_MAX + BigInt(1) + ); + }); + it('dataless rules accept only empty instruction data', async () => { + const s = await setup({ dataless: true }); + await expect(s.simulate(s.payload)).resolves.toBeDefined(); + await s.reject( + encodeAgwSvmPayload(PROGRAM, s.accounts, new Uint8Array([1])), + 'ProgramNotAllowed' + ); + }); + it('fixed account counts reject appended accounts and failed calls do not change spend', async () => { + const s = await setup({ maxAccounts: 2 }); + await s.reject( + encodeAgwSvmPayload( + PROGRAM, + [...s.accounts, { pubkey: key('55'), isWritable: false }], + new Uint8Array([1, 90, 0, 100, 0]) + ), + 'SvmAccountCountMismatch' + ); + const cfg = await h.publicClient.readContract({ + address: h.addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getSvmConfig', + args: [s.cid, s.wallet], + }); + expect(cfg.assets[0].spent).toBe(BigInt(0)); + }); +}); diff --git a/packages/core/__agw-local__/svm-public.spec.ts b/packages/core/__agw-local__/svm-public.spec.ts new file mode 100644 index 000000000..1e8c423db --- /dev/null +++ b/packages/core/__agw-local__/svm-public.spec.ts @@ -0,0 +1,528 @@ +/** Public named-IDL API against actual AGW/URP; destination/gateway are fixtures. */ +import { PublicKey } from '@solana/web3.js'; +import type { Idl } from '@coral-xyz/anchor'; +import { + encodeFunctionData, + parseAbi, + zeroAddress, + type Address, + type Hex, +} from 'viem'; +import { CHAIN, PushChain, AgenticRevertError, type SolanaRule } from '../src'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; +import { v5 } from '../src/lib/agentic/contracts/v5'; +import { clearRegistry } from '../src/lib/orchestrator/svm-idl/registry'; +import { startHarness, type Harness } from './harness'; +const program = new PublicKey(Buffer.alloc(32, 51)).toBase58(); +const gateway = new PublicKey(Buffer.alloc(32, 34)).toBase58(); +const idl: Idl = { + address: program, + metadata: { name: 'named', version: '1', spec: '0.1.0' }, + instructions: [ + { + name: 'deposit', + discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + accounts: [{ name: 'cea_authority', writable: true }], + args: [{ name: 'amount', type: 'u64' }], + }, + ], +}; +const TOKEN = parseAbi([ + 'function mint(address,uint256)', + 'function setSourceTokenAddress(string)', + 'function approve(address,uint256) returns (bool)', +]); +const CORE = parseAbi(['function setGasToken(string,address)']); +const rt = (c: PushChain) => + (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; +describe('public Solana IDL rules on actual v5', () => { + let h: Harness, owner: PushChain, token: Address; + beforeAll(async () => { + h = await startHarness(18561); + owner = await h.client(0); + token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.SOLANA_DEVNET, + 9, + ]); + await h.write(0, token, TOKEN, 'setSourceTokenAddress', ['']); + await h.write(0, h.addresses.universalCore, CORE, 'setGasToken', [ + CHAIN.SOLANA_DEVNET, + token, + ]); + configure(owner); + }); + afterAll(async () => { + clearRegistry(); + await h?.stop(); + }); + function configure(client: PushChain) { + Object.assign(rt(client), { + svmMetadata: { + gateway: async (chain: string) => ({ + chainNamespace: chain, + program: gateway, + }), + mint: async () => { + throw new Error('native-only fixture'); + }, + }, + resolvePrc20: () => token, + quoteOutbound: async () => ({ + protocolFee: BigInt(10) ** BigInt(14), + nativeValueForGas: BigInt(10) ** BigInt(15), + gasLimitUsed: BigInt(100000), + }), + }); + } + const rule = (): SolanaRule => ({ + agent: h.wallets[1].account!.address, + chainNamespace: CHAIN.SOLANA_DEVNET, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(17), + assets: [ + { token: zeroAddress, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, + ], + allowedInstructions: [ + { + program, + instruction: { idl, name: 'deposit' }, + accounts: [{ name: 'cea_authority', expected: { kind: 'walletCEA' } }], + fields: [{ name: 'amount', max: BigInt(5) }], + }, + ], + }); + it('create/get/list/update/revoke use public named inputs and exact stored reads', async () => { + const made = await owner.agentic.create('public-svm', { rules: [rule()] }); + const wallet = owner.agentic.wallet(made.wallet); + const record = await wallet.rules.get(made.rulesIds[0]); + expect(record).not.toHaveProperty('ref'); + expect(record.rule).toMatchObject({ + format: 'decoded', + dataPins: [{ offset: 8, len: 8, mode: 2 }], + }); + expect((await wallet.rules.list()).rules).toHaveLength(1); + const update = await wallet.rules.update({ + rules: [{ rulesId: made.rulesIds[0], rule: rule() }], + }); + expect(update.rules[0].rulesId).not.toBe(made.rulesIds[0]); + await (await wallet.rules.revoke([update.rules[0].rulesId])).wait(); + expect((await wallet.rules.list()).rules).toHaveLength(0); + }); + it('agent public send uses wallet context, mapped errors and replay metadata', async () => { + const made = await owner.agentic.create('public-send', { rules: [rule()] }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: made.wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + await h.write(0, token, TOKEN, 'mint', [made.wallet, BigInt(20)]); + await h.write(0, made.wallet, v5.abis.wallet, 'execute', [ + `0x${'00'.repeat(32)}`, + v5.packSingle({ + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(20)], + }), + }), + ]); + const agent = await h.client(1, { agenticWallet: made.wallet }); + configure(agent); + const data = (n: bigint) => + PushChain.utils.helpers.encodeTxData({ + abi: idl, + functionName: 'deposit', + args: [n], + }); + const tx = await agent.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + data: data(BigInt(5)), + value: BigInt(5), + }); + expect(tx.from).toBe(made.wallet); + expect(tx.agentic?.destinationInstruction?.data).toBe(data(BigInt(5))); + expect( + (await h.publicClient.getTransactionReceipt({ hash: tx.hash as Hex })) + .status + ).toBe('success'); + const { adaptTrackedResponse } = await import( + '../src/lib/agentic/response' + ); + const raw: typeof tx = { + ...tx, + agentic: undefined, + from: tx.raw!.from, + to: tx.raw!.to, + data: tx.raw!.data, + }; + await adaptTrackedResponse(rt(agent), raw); + expect(raw.agentic?.destinationInstruction).toEqual( + tx.agentic?.destinationInstruction + ); + await expect( + agent.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + data: data(BigInt(6)), + value: BigInt(5), + }) + ).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmDataCeilingExceeded)' }, + }); + }); + it('owner sends use the wallet CEA without requiring an agent rule', async () => { + const made = await owner.agentic.create('svm-owner', { rules: [] }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: made.wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + const sender = await h.client(0, { agenticWallet: made.wallet }); + configure(sender); + const tx = await sender.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + data: PushChain.utils.helpers.encodeTxData({ + abi: idl, + functionName: 'deposit', + args: [BigInt(0)], + }), + }); + expect(tx.agentic?.door).toBe('owner'); + expect(tx.agentic?.rulesId).toBeUndefined(); + expect( + (await h.publicClient.getTransactionReceipt({ hash: tx.hash as Hex })) + .status + ).toBe('success'); + }); + it('owner funds-only sends use an empty payload and preserve transfer metadata in replay', async () => { + const made = await owner.agentic.create('svm-owner-transfer', { + rules: [], + }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: made.wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + await h.write(0, token, TOKEN, 'mint', [made.wallet, BigInt(10)]); + await h.write(0, made.wallet, v5.abis.wallet, 'execute', [ + `0x${'00'.repeat(32)}`, + v5.packSingle({ + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(10)], + }), + }), + ]); + const sender = await h.client(0, { agenticWallet: made.wallet }); + configure(sender); + clearRegistry(); // A simple transfer needs no program IDL. + const tx = await sender.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + value: BigInt(5), + }); + expect(tx.to).toBe('0x' + Buffer.alloc(32, 51).toString('hex')); + expect(tx.value).toBe(BigInt(5)); + expect(tx.agentic?.destinationInstruction).toBeUndefined(); + expect(tx.agentic?.destinationTransfer).toMatchObject({ + token, + amount: BigInt(5), + }); + const raw: typeof tx = { + ...tx, + agentic: undefined, + from: tx.raw!.from, + to: tx.raw!.to, + data: tx.raw!.data, + route: 'UOA_TO_PUSH', + }; + const { adaptTrackedResponse } = await import( + '../src/lib/agentic/response' + ); + await adaptTrackedResponse(rt(sender), raw); + expect(raw.to).toBe(tx.to); + expect(raw.data).toBe('0x'); + expect(raw.value).toBe(BigInt(5)); + expect(raw.agentic?.destinationTransfer).toEqual( + tx.agentic?.destinationTransfer + ); + expect(raw.route).toBe('UOA_TO_CEA'); + const baseReader = rt(sender).reader; + const unavailable = { + ...baseReader, + readContract: async ( + params: Parameters[0] + ) => { + if (params.functionName === 'gasTokenPRC20ByChainNamespace') + throw new Error('historical metadata unavailable'); + return baseReader.readContract(params); + }, + }; + const fallback: typeof tx = { + ...tx, + agentic: undefined, + from: tx.raw!.from, + to: tx.raw!.to, + data: tx.raw!.data, + route: 'UOA_TO_PUSH', + }; + await adaptTrackedResponse( + { network: rt(sender).network, reader: unavailable }, + fallback + ); + expect(fallback.route).toBe('UOA_TO_CEA'); + expect(fallback.agentic?.destinationTransfer).toBeUndefined(); + + expect( + (await h.publicClient.getTransactionReceipt({ hash: tx.hash as Hex })) + .status + ).toBe('success'); + }); + it('actual policy refusals return mapped contract errors and preserve counters', async () => { + const made = await owner.agentic.create('svm-policy-errors', { + rules: [rule()], + }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: made.wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + const agent = await h.client(1, { agenticWallet: made.wallet }); + configure(agent); + const handle = owner.agentic.wallet(made.wallet), + before = await handle.rules.get(made.rulesIds[0]); + const error = await agent.universal + .sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + data: PushChain.utils.helpers.encodeTxData({ + abi: idl, + functionName: 'deposit', + args: [BigInt(6)], + }), + }) + .catch((e) => e); + expect(error).toBeInstanceOf(AgenticRevertError); + expect(error.decodedError.name).toBe( + 'PolicyCheckReverted(SvmDataCeilingExceeded)' + ); + expect(await handle.rules.get(made.rulesIds[0])).toEqual(before); + await expect( + agent.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + value: BigInt(1), + }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + }); + it('SPL and output mints derive protected wallet ATAs through public grants', async () => { + const mint = new PublicKey(Buffer.alloc(32, 68)).toBase58(); + const output = new PublicKey(Buffer.alloc(32, 85)).toBase58(); + const spl = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN.SOLANA_DEVNET, + 6, + ]); + await h.write(0, spl, TOKEN, 'setSourceTokenAddress', [mint]); + const source = await h.client(0); + configure(source); + Object.assign(rt(source), { + resolvePrc20: () => spl, + svmMetadata: { + gateway: async (chain: string) => ({ + chainNamespace: chain, + program: gateway, + }), + mint: async (_chain: string, address: string) => ({ + mint: address, + tokenProgram: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }), + }, + }); + const input = rule(); + input.assets = [{ token: mint, maxPerCall: BigInt(5) }]; + input.outputTokens = [output]; + input.allowedInstructions[0].instruction = { + idl: { + ...idl, + instructions: [ + { + ...idl.instructions[0], + accounts: [ + { name: 'cea_authority' }, + { name: 'inputAta', writable: true }, + { name: 'outputAta', writable: true }, + ], + }, + ], + }, + name: 'deposit', + }; + input.allowedInstructions[0].accounts.push( + { name: 'inputAta', expected: { kind: 'walletATA', token: mint } }, + { name: 'outputAta', expected: { kind: 'walletATA', token: output } } + ); + const made = await source.agentic.create('public-spl', { rules: [input] }); + const read = await source.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (!('format' in read.rule)) + throw new Error('expected decoded Solana rule'); + expect(read.rule.ceaAccounts).toHaveLength(3); + expect(read.rule.pins.map((p) => p.expected)).toEqual( + read.rule.ceaAccounts + ); + expect(read.rule.assets[0].maxTotal).toBe( + BigInt(2) ** BigInt(256) - BigInt(1) + ); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: made.wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + await h.write(0, spl, TOKEN, 'mint', [made.wallet, BigInt(10)]); + await h.write(0, made.wallet, v5.abis.wallet, 'execute', [ + `0x${'00'.repeat(32)}`, + v5.packSingle({ + target: spl, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(10)], + }), + }), + ]); + const sender = await h.client(0, { agenticWallet: made.wallet }); + Object.assign(rt(sender), { + svmMetadata: rt(source).svmMetadata, + resolvePrc20: rt(source).resolvePrc20, + quoteOutbound: rt(source).quoteOutbound, + }); + const quote = jest.spyOn(rt(sender), 'quoteOutbound'); + const tx = await sender.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + funds: { + token: { + address: mint, + symbol: 'MOCK', + decimals: 6, + mechanism: 'approve', + }, + amount: BigInt(5), + }, + }); + expect(tx.value).toBe(BigInt(0)); + expect(tx.agentic?.destinationTransfer).toMatchObject({ + token: spl, + amount: BigInt(5), + }); + expect(quote.mock.calls[0][3]).toMatchObject({ + wallet: made.wallet, + splMintBase58: mint, + burnAmount: BigInt(5), + }); + expect( + (await h.publicClient.getTransactionReceipt({ hash: tx.hash as Hex })) + .status + ).toBe('success'); + }); + it('invalid named input and removed ref never submit a transaction', async () => { + const before = await h.publicClient.getTransactionCount({ + address: h.wallets[0].account!.address, + }); + const r = rule(); + r.allowedInstructions[0].accounts[0].name = 'typo'; + await expect( + owner.agentic.create('invalid', { rules: [r] }) + ).rejects.toThrow('unknown IDL account'); + await expect( + owner.agentic.create('removed-ref', { + rules: [{ ...rule(), ref: `0x${'11'.repeat(32)}` } as never], + }) + ).rejects.toThrow('ref is not supported'); + expect( + await h.publicClient.getTransactionCount({ + address: h.wallets[0].account!.address, + }) + ).toBe(before); + }); + it('the contract rejects an outbound above its PC cap through the same error mapper', async () => { + const input = rule(); + input.maxGasPerCall = BigInt(1); + const made = await owner.agentic.create('svm-pc-cap', { rules: [input] }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: made.wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + const agent = await h.client(1, { agenticWallet: made.wallet }); + configure(agent); + const error = await agent.universal + .sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + data: PushChain.utils.helpers.encodeTxData({ + abi: idl, + functionName: 'deposit', + args: [BigInt(0)], + }), + }) + .catch((e) => e); + expect(error).toBeInstanceOf(AgenticRevertError); + expect(error.decodedError.name).toBe( + 'PolicyCheckReverted(PCValueExceedsCap)' + ); + }); + it('expired SVM permissions are refused by the actual contract rather than a local policy check', async () => { + const input = rule(); + input.validUntil = + Number((await h.publicClient.getBlock()).timestamp) + 120; + const made = await owner.agentic.create('svm-expired', { rules: [input] }); + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + to: made.wallet, + value: BigInt(10) ** BigInt(18), + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + }), + }); + const agent = await h.client(1, { agenticWallet: made.wallet }); + configure(agent); + await h.publicClient.request({ + method: 'evm_setNextBlockTimestamp' as never, + params: [input.validUntil + 1] as never, + }); + await h.publicClient.request({ method: 'evm_mine' as never }); + const error = await agent.universal + .sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: program }, + data: PushChain.utils.helpers.encodeTxData({ + abi: idl, + functionName: 'deposit', + args: [BigInt(0)], + }), + }) + .catch((e) => e); + expect(error).toBeInstanceOf(AgenticRevertError); + expect(error.decodedError.name).toBe('PolicyCheckReverted(RulesExpired)'); + }); +}); diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts new file mode 100644 index 000000000..0d5f04fef --- /dev/null +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -0,0 +1,367 @@ +/** Actual v5 AGW/URP/engine. Token/core/gateway and destination lookup are local fixtures; no bridge/TSS proof. */ +import { + encodeFunctionData, + getAddress, + parseAbi, + type Address, + type Hex, +} from 'viem'; +import { CHAIN, type PushChain, type UniversalRule } from '../src'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; +import { updateRules } from '../src/lib/agentic/management/rules-write'; +import { createWallet } from '../src/lib/agentic/management/create'; +import { configId } from '../src/lib/agentic/codec/ids'; +import { UINT256_MAX } from '../src/lib/agentic/codec/defaults'; +import { buildSession } from '../src/lib/agentic/codec/session'; +import { + encodeNativeTerms, + nativeRuleToTerms, +} from '../src/lib/agentic/codec/native'; +import { startHarness, type Harness } from './harness'; + +const TOKEN = parseAbi([ + 'function mint(address,uint256)', + 'function approve(address,uint256) returns (bool)', +]); +const CORE = parseAbi([ + 'function setGasToken(string,address)', + 'function getOutboundTxGasAndFees(address,uint256) view returns (address,uint256,uint256,uint256,string,uint256)', +]); +const CHAIN_NS = CHAIN.ETHEREUM_SEPOLIA; +const CEA = getAddress('0x000000000000000000000000000000000000cea1'); +const DEST = getAddress('0x0000000000000000000000000000000000001234'); +const internal = (c: PushChain) => + (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; + +describe('v5 multi-asset SDK management and execution', () => { + let h: Harness, owner: PushChain, a: Address, b: Address; + beforeAll(async () => { + h = await startHarness(18551); + owner = await h.client(0); + a = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [CHAIN_NS, 6]); + b = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + CHAIN_NS, + 18, + ]); + await h.write(0, h.addresses.universalCore, CORE, 'setGasToken', [ + CHAIN_NS, + a, + ]); + configure(internal(owner)); + }); + afterAll(async () => { + await h?.stop(); + }); + function configure(rt: AgenticRuntime) { + // Fixture source token addresses equal fixture PRC20s; production uses the token registry. + rt.resolvePrc20 = (token) => + getAddress(typeof token === 'string' ? token : token?.address ?? a); + rt.resolveCEA = async () => ({ cea: CEA, isDeployed: false }); + rt.quoteOutbound = async (token, limit) => { + const [, nativeValueForGas, protocolFee, , , gasLimitUsed] = + await h.publicClient.readContract({ + address: h.addresses.universalCore, + abi: CORE, + functionName: 'getOutboundTxGasAndFees', + args: [token, limit], + }); + return { nativeValueForGas, protocolFee, gasLimitUsed }; + }; + } + function rule( + assets: UniversalRule['assets'] = [ + { token: a, maxPerCall: BigInt(5), maxTotal: BigInt(7) }, + { token: b, maxPerCall: BigInt(9), maxTotal: BigInt(30) }, + ] + ): UniversalRule { + return { + agent: h.wallets[1].account!.address, + chainNamespace: CHAIN_NS, + assets, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(16), + allowedCalls: [{ target: DEST, selector: 'increment()' }], + }; + } + async function setup(input = rule()) { + const created = await owner.agentic.create('v5', { rules: [input] }); + const wallet = created.wallet, + id = created.rulesIds[0]; + await h.publicClient.waitForTransactionReceipt({ + hash: await h.wallets[4].sendTransaction({ + account: h.wallets[4].account!, + chain: h.wallets[4].chain, + to: wallet, + value: BigInt(10) ** BigInt(18), + }), + }); + for (const token of [a, b]) { + await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(1000)]); + const w = await h.client(0, { agenticWallet: wallet }); + await ( + await w.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [h.addresses.gateway, BigInt(1000)], + }), + }) + ).wait(); + } + const agent = await h.client(1, { agenticWallet: wallet }); + configure(internal(agent)); + const send = (token: Address, amount: bigint) => + agent.universal.sendTransaction({ + to: { address: DEST, chain: CHAIN_NS }, + funds: { + amount, + token: { + symbol: 'fixture', + decimals: 6, + address: token, + mechanism: 'approve', + }, + }, + data: [{ to: DEST, data: '0xd09de08a', value: BigInt(0) }], + }); + return { wallet, id, agent, send }; + } + async function waitPush( + tx: ReturnType + ) { + const response = await tx; + return h.publicClient.waitForTransactionReceipt({ + hash: response.hash as Hex, + }); + } + async function stored(wallet: Address, id: Hex) { + const [action] = await h.publicClient.readContract({ + address: h.addresses.engine, + abi: h.generation.contracts.abis.engine, + functionName: 'getEnabledActions', + args: [wallet, id], + }); + return h.publicClient.readContract({ + address: h.addresses.rulesPolicy, + abi: h.generation.contracts.abis.policy, + functionName: 'getConfig', + args: [configId(wallet, id, action), wallet], + }); + } + it('public create encodes two ordered assets; public get reconstructs source-token caps', async () => { + const { wallet, id } = await setup(); + expect((await stored(wallet, id)).assets.map((x) => x.token)).toEqual([ + a, + b, + ]); + expect( + (await owner.agentic.wallet(wallet).rules.get(id)).rule + ).toMatchObject({ + assets: [ + { token: a, maxPerCall: BigInt(5), maxTotal: BigInt(7) }, + { token: b, maxPerCall: BigInt(9), maxTotal: BigInt(30) }, + ], + }); + }); + it('a token from the wrong chain fails before deployment', async () => { + const wrong = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ + 'eip155:1', + 6, + ]); + const count = await h.publicClient.readContract({ + address: h.addresses.factory, + abi: h.generation.contracts.abis.factory, + functionName: 'walletCount', + args: [h.wallets[0].account!.address], + }); + await expect( + owner.agentic.create('wrong-chain', { + rules: [rule([{ token: wrong, maxPerCall: BigInt(1) }])], + }) + ).rejects.toMatchObject({ code: 'ASSET_CHAIN_MISMATCH' }); + expect( + await h.publicClient.readContract({ + address: h.addresses.factory, + abi: h.generation.contracts.abis.factory, + functionName: 'walletCount', + args: [h.wallets[0].account!.address], + }) + ).toBe(count); + }); + it('a same-chain registry identity mismatch fails before deployment', async () => { + const rt = internal(owner); + const resolve = rt.resolvePrc20; + rt.resolvePrc20 = () => b; + try { + await expect( + owner.agentic.create('wrong-identity', { + rules: [rule([{ token: a, maxPerCall: BigInt(1) }])], + }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + } finally { + rt.resolvePrc20 = resolve; + } + }); + it('both tokens execute under one rule and keep independent counters/limits', async () => { + const { wallet, id, send } = await setup(); + await waitPush(send(a, BigInt(5))); + await expect(send(a, BigInt(3))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(TotalSpendCapExceeded)' }, + }); + await waitPush(send(b, BigInt(9))); + expect((await stored(wallet, id)).assets.map((x) => x.spent)).toEqual([ + BigInt(5), + BigInt(9), + ]); + }); + it('omitted total encodes maxUint256; explicit zero prohibits movement', async () => { + const { wallet, id, send } = await setup( + rule([ + { token: a, maxPerCall: BigInt(5) }, + { token: b, maxPerCall: BigInt(9), maxTotal: BigInt(0) }, + ]) + ); + expect((await stored(wallet, id)).assets.map((x) => x.maxTotal)).toEqual([ + UINT256_MAX, + BigInt(0), + ]); + await expect(send(b, BigInt(1))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(TotalSpendCapExceeded)' }, + }); + expect((await stored(wallet, id)).assets[1].spent).toBe(BigInt(0)); + }); + it('empty user assets grants one zero-cap gas token and executes a call-only request', async () => { + const { wallet, id, send } = await setup(rule([])); + expect((await stored(wallet, id)).assets).toEqual([ + { + token: a, + maxPerCall: BigInt(0), + maxTotal: BigInt(0), + spent: BigInt(0), + }, + ]); + await waitPush(send(a, BigInt(0))); + await expect(send(a, BigInt(1))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(AmountExceedsCap)' }, + }); + }); + it('public update resets both counters in one batch and records five checkpoints', async () => { + const { wallet, id, send } = await setup(); + await waitPush(send(b, BigInt(4))); + const count = await h.publicClient.readContract({ + address: wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'checkpointCount', + }); + const result = await owner.agentic + .wallet(wallet) + .rules.update({ rules: [{ rulesId: id, rule: rule() }] }); + expect( + (await stored(wallet, result.rules[0].rulesId)).assets.map((x) => x.spent) + ).toEqual([BigInt(0), BigInt(0)]); + expect( + await h.publicClient.readContract({ + address: wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(count + BigInt(5)); + }); + it('spend on the second asset after snapshot rolls back revoke/grant and checkpoint writes', async () => { + const { wallet, id, send } = await setup(); + const base = internal(owner); + const count = await h.publicClient.readContract({ + address: wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'checkpointCount', + }); + const racing = { + ...base, + execute: async (...args: Parameters) => { + await waitPush(send(b, BigInt(1))); + return base.execute(...args); + }, + }; + await expect( + updateRules(racing, h.generation, wallet, { + rules: [{ rulesId: id, rule: rule() }], + }) + ).rejects.toMatchObject({ decodedError: { name: 'AssetSpentMismatch' } }); + expect( + await h.publicClient.readContract({ + address: h.addresses.engine, + abi: h.generation.contracts.abis.engine, + functionName: 'getPermissionIDs', + args: [wallet], + }) + ).toEqual([id]); + expect( + await h.publicClient.readContract({ + address: wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(count); + }); + it('duplicate grants are allowed, but an ambiguous send fails before any signature', async () => { + const created = await owner.agentic.create('multiple', { + rules: [rule(), rule()], + }); + expect(new Set(created.rulesIds).size).toBe(2); + const agent = await h.client(1, { agenticWallet: created.wallet }); + await expect( + agent.universal.sendTransaction({ + to: { address: DEST, chain: CHAIN_NS }, + data: '0xd09de08a', + }) + ).rejects.toMatchObject({ code: 'AMBIGUOUS_RULE' }); + }); + it('a legacy two-field envelope is refused by the actual v5 grant path', async () => { + const bare = await owner.agentic.create('old-envelope', { rules: [] }); + const { encodeAbiParameters } = await import('viem'); + const terms = nativeRuleToTerms( + { + agent: h.wallets[1].account!.address, + target: h.addresses.target, + selector: 'increment()', + validUntil: Math.floor(Date.now() / 1000) + 3600, + }, + { nowSeconds: 0 } + ).terms; + const session = buildSession({ + validator: h.addresses.validator, + rulesPolicy: h.addresses.rulesPolicy, + agent: h.wallets[1].account!.address, + actions: [ + { + target: terms.target, + selector: terms.selector, + initData: encodeAbiParameters( + [{ type: 'string' }, { type: 'bytes' }], + ['eip155:9000', encodeNativeTerms(terms)] + ), + }, + ], + }); + await expect( + h.publicClient.simulateContract({ + address: bare.wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'grantRules', + args: [session as never], + account: h.wallets[0].account!.address, + }) + ).rejects.toMatchObject({ + cause: { data: { errorName: 'Panic', args: [BigInt(65)] } }, + }); + expect( + await h.publicClient.readContract({ + address: h.addresses.engine, + abi: h.generation.contracts.abis.engine, + functionName: 'getPermissionIDs', + args: [bare.wallet], + }) + ).toEqual([]); + }); +}); diff --git a/packages/core/__agw-local__/vectors.spec.ts b/packages/core/__agw-local__/vectors.spec.ts new file mode 100644 index 000000000..ed603c0d7 --- /dev/null +++ b/packages/core/__agw-local__/vectors.spec.ts @@ -0,0 +1,220 @@ +/** + * Contract-generated vectors for the pure AGW codecs. Every `expected` value + * below is computed BY THE PINNED CONTRACTS (pure engine/factory views, or + * state read back after a real grant), never by the SDK under test. + * + * AGW_WRITE_VECTORS=1 → (re)write src/lib/agentic/__fixtures__/v5-vectors.json + * otherwise → assert the committed file still matches the contracts + */ +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { getAddress, numberToHex, type Address, type Hex } from 'viem'; +import { buildSession } from '../src/lib/agentic/codec/session'; +import { agentConfig } from '../src/lib/agentic/codec/ids'; +import { + encodeNativeTerms, + nativeRuleToTerms, +} from '../src/lib/agentic/codec/native'; +import { + encodeEnvelope, + encodeSession, +} from '../src/lib/agentic/codec/session'; +import type { NativeRule } from '../src/lib/agentic/agentic.types'; +import { startHarness, type Harness } from './harness'; + +const FIXTURE = join( + __dirname, + '..', + 'src', + 'lib', + 'agentic', + '__fixtures__', + 'v5-vectors.json' +); +const bigintJson = (v: unknown) => + JSON.stringify( + v, + (_k, x) => (typeof x === 'bigint' ? { $bigint: x.toString() } : x), + 2 + ); + +describe('contract-generated AGW vectors (v5)', () => { + let h: Harness; + beforeAll(async () => { + h = await startHarness(18548); + }); + afterAll(async () => { + await h?.stop(); + }); + + it('matches the committed fixture (or writes it)', async () => { + const pc = h.publicClient; + const gen = h.generation; + const validator = h.addresses.validator; + const agents: Address[] = [ + h.wallets[1].account!.address, + h.wallets[2].account!.address, + getAddress('0x00000000000000000000000000000000000000ff'), + ]; + + // rulesId: SmartSession.getPermissionId is pure over (validator, initData, salt). + const rulesId = []; + for (const agent of agents) { + for (const grantNonce of [ + BigInt(0), + BigInt(1), + BigInt(7), + BigInt(2) ** BigInt(63), + ]) { + const session = { + ...buildSession({ + validator, + agent, + rulesPolicy: h.addresses.rulesPolicy, + actions: [], + }), + salt: numberToHex(grantNonce, { size: 32 }), + }; + const expected = (await pc.readContract({ + address: h.addresses.engine, + abi: gen.contracts.abis.engine, + functionName: 'getPermissionId', + args: [session as never], + })) as Hex; + rulesId.push({ + validator, + agent, + grantNonce, + agentConfig: agentConfig(agent), + expected, + }); + } + } + + // deriveWallet: factory.predictWallet for fresh owners (index 0) and a used owner (0..next). + const deriveWallet = []; + const owners: Address[] = [ + h.wallets[3].account!.address, + getAddress('0x1111111111111111111111111111111111111111'), + ]; + for (const owner of owners) { + const [expected] = (await pc.readContract({ + address: h.addresses.factory, + abi: gen.contracts.abis.factory, + functionName: 'predictWallet', + args: [owner, BigInt(0)], + })) as readonly [Address, boolean]; + deriveWallet.push({ + factory: h.addresses.factory, + walletImplementation: h.addresses.walletImplementation, + owner, + index: 0, + expected, + }); + } + + // Native session round trip: SDK-encoded grant, contract-stored terms and IDs. + const owner = await h.client(0); + const rule: NativeRule = { + agent: agents[0], + target: h.addresses.target, + selector: 'deposit(address,uint256)', + validUntil: 4102444800, // 2100-01-01, fixed so the vector is reproducible + pins: [{ arg: 0, expected: agents[1] }], + amount: { arg: 1, maxPerCall: BigInt(10), maxTotal: BigInt(100) }, + maxValuePerCall: BigInt(5), + maxValueTotal: BigInt(50), + maxCalls: 3, + }; + const created = await owner.agentic.create('vectors', { rules: [rule] }); + const wallet = created.wallet; + const rid = created.rulesIds[0]; + const [actionId] = (await pc.readContract({ + address: h.addresses.engine, + abi: gen.contracts.abis.engine, + functionName: 'getEnabledActions', + args: [wallet, rid], + })) as Hex[]; + const { terms } = nativeRuleToTerms(rule, { nowSeconds: 0 }); + const nativeChain = `eip155:${await pc.getChainId()}`; + const session = buildSession({ + validator, + agent: rule.agent, + rulesPolicy: h.addresses.rulesPolicy, + actions: [ + { + target: terms.target, + selector: terms.selector, + initData: encodeEnvelope(nativeChain, encodeNativeTerms(terms)), + }, + ], + }); + // configId is recovered by searching what the policy accepts: the stored config is only + // initialized under the correct key, so a successful read proves the derivation. + const { configId } = await import('../src/lib/agentic/codec/ids'); + const cid = configId(wallet, rid, actionId); + const stored = (await pc.readContract({ + address: h.addresses.rulesPolicy, + abi: gen.contracts.abis.policy, + functionName: 'getNativeConfig', + args: [cid, wallet], + })) as { + initialized: boolean; + target: Address; + selector: Hex; + pins: readonly { offset: number; expected: Hex }[]; + amount: { offset: number }; + }; + const native = { + rule, + nativeChain, + wallet, + rulesId: rid, + actionId, + configId: cid, + configInitialized: stored.initialized, + storedTarget: stored.target, + storedSelector: stored.selector, + storedPins: stored.pins.map((p) => ({ + offset: Number(p.offset), + expected: p.expected, + })), + storedAmountOffset: Number(stored.amount.offset), + encodedSession: encodeSession(session), + }; + + const vectors = { + provenance: { + agwCommit: h.generation.sourceCommit, + generatedBy: 'packages/core/__agw-local__/vectors.spec.ts', + toolchain: safe( + () => execSync('forge --version').toString().split('\n')[0] + ), + chainId: await pc.getChainId(), + note: 'Addresses are deterministic for a fresh anvil + the harness deploy order.', + }, + rulesId, + deriveWallet, + native, + }; + + if (process.env['AGW_WRITE_VECTORS'] === '1') { + mkdirSync(join(FIXTURE, '..'), { recursive: true }); + writeFileSync(FIXTURE, bigintJson(vectors) + '\n'); + } + const committed = JSON.parse(readFileSync(FIXTURE, 'utf8')); + const { provenance: _a, ...current } = JSON.parse(bigintJson(vectors)); + const { provenance: _b, ...saved } = committed; + expect(current).toEqual(saved); + expect(native.configInitialized).toBe(true); + }); +}); + +function safe(f: () => string): string { + try { + return f(); + } catch { + return 'unknown'; + } +} diff --git a/packages/core/__e2e__/agw-svm-wire/outbound.spec.ts b/packages/core/__e2e__/agw-svm-wire/outbound.spec.ts new file mode 100644 index 000000000..673594983 --- /dev/null +++ b/packages/core/__e2e__/agw-svm-wire/outbound.spec.ts @@ -0,0 +1,174 @@ +/** Internal wire acceptance alongside public Solana coverage. Policy refusals come from URP simulation. */ +import '@e2e/shared/setup'; +import { PushChain } from '../../src'; +import { Keypair, PublicKey, type ParsedTransactionWithMeta } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { bytesToHex, createPublicClient, http, encodeFunctionData, erc20Abi, parseAbi, parseEther, type Address, type Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; +import type { AgenticRuntime } from '../../src/lib/agentic/runtime'; +import { resolveWalletGeneration } from '../../src/lib/agentic/deployments'; +import { Snapshot } from '../../src/lib/agentic/reads/snapshot'; +import { readSvmRule } from '../../src/lib/agentic/reads/svm'; +import { grantSvmWire } from '../../src/lib/agentic/management/svm'; +import { createSvmMetadataProvider } from '../../src/lib/agentic/management/svm-metadata'; +import { resolveSvmContext } from '../../src/lib/agentic/management/svm-context'; +import { sendSvmAgentWire, prepareSvmAgentExecution, type PreparedSvmRequest } from '../../src/lib/agentic/execution/svm-send'; +import { SvmDataPinMode, type SvmTermsWire } from '../../src/lib/agentic/codec/svm-terms'; +import { svmKey } from '../../src/lib/agentic/codec/svm-accounts'; +import { adaptTrackedResponse } from '../../src/lib/agentic/response'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { setupAgw, evmClient, inSeconds, type AgwFixture } from '../agw/_fixture'; +import { loadAgwManifest, verifyAgwManifest } from '../agw/_manifest'; +import { inspectSvmWirePrograms, svmWireConnection, SVM_WIRE_CHAIN, SVM_WIRE_GATEWAY, SVM_WIRE_PROGRAM, SVM_WIRE_COUNTER, decodeWireCounter } from '../shared/agw-svm-preflight'; +import { probeSvmWirePolicies } from '../shared/agw-svm-policy-probe'; + +const d = process.env['AGW_SVM_WIRE_E2E'] === '1' ? describe : describe.skip; +// Load the public entry point before internal modules (the existing harness +// does the same); keeping this runtime use prevents TypeScript erasing it. +if (PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET !== SVM_WIRE_CHAIN) throw new Error('Solana fixture chain mismatch'); +const AMOUNT = BigInt(10_000); // 0.00001 SOL, twice at most per run. +const MAX_PC = parseEther('20'); +const RECEIVE = '0x79f4fa0308e5e101' as Hex; +const DECREMENT = '0x6ae3a83bf81b9665' as Hex; +const ZERO = `0x${'00'.repeat(32)}` as Hex; +const rt = (c: PushChain) => (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; +const ixData = (disc: Hex, amount: bigint) => { + const b = Buffer.alloc(16); Buffer.from(disc.slice(2), 'hex').copy(b); b.writeBigUInt64LE(amount, 8); return b; +}; + +function transferFromCea(tx: ParsedTransactionWithMeta, cea: string, recipient: string, amount: bigint) { + return tx.meta?.innerInstructions?.some((group) => group.instructions.some((ix) => + 'parsed' in ix && ix.program === 'system' && ix.parsed.type === 'transfer' && + ix.parsed.info.source === cea && ix.parsed.info.destination === recipient && + BigInt(ix.parsed.info.lamports) === amount + )); +} + +d('agw svm wire', () => { + let f: AgwFixture, wallet: Address, token: Address, rulesId: Hex, agent: PushChain; + let gen: Awaited>; + let recipient: PublicKey, cea: PublicKey; + const sol = svmWireConnection(); + const metadata = createSvmMetadataProvider({ [SVM_WIRE_CHAIN]: { + gatewayProgram: SVM_WIRE_GATEWAY, rpcUrls: CHAIN_INFO[SVM_WIRE_CHAIN].defaultRPC, + } }, (url) => svmWireConnection(url)); + const accounts = (failure = false) => failure + ? [{ pubkey: svmKey(SVM_WIRE_COUNTER.toBase58()), isWritable: true }, { pubkey: svmKey(cea.toBase58()), isWritable: false }] + : [{ pubkey: svmKey(SVM_WIRE_COUNTER.toBase58()), isWritable: true }, { pubkey: svmKey(recipient.toBase58()), isWritable: true }, { pubkey: svmKey(cea.toBase58()), isWritable: true }, { pubkey: ZERO, isWritable: false }]; + const request = (failure = false): PreparedSvmRequest => ({ wallet, rulesId, token, amount: AMOUNT, gasLimit: BigInt(0), + program: SVM_WIRE_PROGRAM, instructionData: ixData(failure ? DECREMENT : RECEIVE, failure ? BigInt(1) : AMOUNT), accounts: accounts(failure) }); + const stored = () => Snapshot.at(rt(f.owner).reader).then((snap) => readSvmRule(snap, gen, wallet, rulesId, rt(f.owner).pushChainNamespace)); + + beforeAll(async () => { + // Every cluster/program check runs before setupAgw can fund anything. + const preflight = await inspectSvmWirePrograms(sol); + const key = process.env['SOLANA_PRIVATE_KEY']; + if (!key) throw new Error('SOLANA_PRIVATE_KEY is needed only to identify the owned recipient'); + try { recipient = Keypair.fromSecretKey(bs58.decode(key)).publicKey; } + catch { recipient = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(key))).publicKey; } + const destination = await sol.getAccountInfo(recipient); + if (!destination || destination.executable || !destination.owner.equals(PublicKey.default)) throw new Error('Owned recipient must be an existing System account'); + if (parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5') > parseEther('0.5')) throw new Error('Wire suite agent funding is bounded to 0.5 PC'); + const manifest = loadAgwManifest(); + if (manifest.network !== PUSH_NETWORK.TESTNET_DONUT) throw new Error('Wire suite is restricted to Donut'); + const push = createPublicClient({ chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]) }); + await verifyAgwManifest(push, manifest); + const ownerKey = process.env['PUSH_PRIVATE_KEY'] as Hex | undefined; + if (!ownerKey) throw new Error('PUSH_PRIVATE_KEY is required'); + const ownerAddress = privateKeyToAccount(ownerKey).address; + token = await push.readContract({ address: '0x00000000000000000000000000000000000000C0', + abi: parseAbi(['function gasTokenPRC20ByChainNamespace(string) view returns (address)']), + functionName: 'gasTokenPRC20ByChainNamespace', args: [SVM_WIRE_CHAIN] }); + const ownerTokens = await push.readContract({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [ownerAddress] }); + if (ownerTokens < AMOUNT * BigInt(2)) throw new Error('Push owner needs 0.00002 pSOL for this bounded suite'); + if (await push.getBalance({ address: ownerAddress }) < parseEther('45')) throw new Error('Push owner needs 45 PC to cover bounded setup and outbound budgets'); + f = await setupAgw(); + f.evidence('svm-wire-program-preflight', preflight); + wallet = (await f.owner.agentic.create('e2e-internal-svm-wire', { rules: [] })).wallet; + gen = await resolveWalletGeneration(rt(f.owner).reader, f.manifest.network, wallet); + const resolved = await resolveSvmContext(await Snapshot.at(rt(f.owner).reader), wallet, SVM_WIRE_CHAIN, + [{ token, maxPerCall: AMOUNT, maxTotal: AMOUNT * BigInt(2) }], metadata); + cea = new PublicKey(Buffer.from(resolved.expectedCEA.slice(2), 'hex')); + if (cea.toBase58() === preflight.counter.authority) throw new Error('Failure fixture requires a counter authority different from the fresh AGW CEA'); + const terms: SvmTermsWire = { ...resolved, validUntil: inSeconds(7200), maxGasPerCall: MAX_PC, + programs: [RECEIVE, DECREMENT].map((discriminator, i) => ({ program: svmKey(SVM_WIRE_PROGRAM), discriminator, + discriminatorLen: 8, dataless: false, maxAccounts: i === 0 ? 4 : 2 })), + pins: [false, true].flatMap((failure, ruleIndex) => accounts(failure).map((a, accountIndex) => ({ ruleIndex, accountIndex, expected: a.pubkey }))), + dataPins: [AMOUNT, BigInt(1)].map((limit, ruleIndex) => ({ ruleIndex, fromEnd: false, offset: 8, offsetB: 0, len: 8, + mode: SvmDataPinMode.LTE_LE, expected: `0x${limit.toString(16).padStart(64, '0')}`, num: BigInt(0), den: BigInt(0) })), + }; + const grant = await grantSvmWire(rt(f.owner), gen, wallet, { agent: f.agentAddress, chainNamespace: SVM_WIRE_CHAIN, terms }); + rulesId = grant.rulesId; + await f.fundPC(wallet, MAX_PC * BigInt(2) + parseEther('1')); + await (await f.owner.universal.sendTransaction({ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [wallet, AMOUNT * BigInt(2)] }) })).wait(); + const ownerWallet = await evmClient(process.env['PUSH_PRIVATE_KEY'] as Hex, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet); + await (await ownerWallet.universal.sendTransaction({ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [f.manifest.addresses.gateway, AMOUNT * BigInt(2)] }) })).wait(); + agent = await f.agent(wallet); + f.evidence('svm-wire-setup', { wallet, rulesId, grantHash: grant.tx.hash, agent: f.agentAddress, recipient: recipient.toBase58(), cea: cea.toBase58(), token, terms, walletPCFunded: MAX_PC * BigInt(2) + parseEther('1'), tokenFunded: AMOUNT * BigInt(2) }); + }, 900_000); + afterAll(() => f?.teardown()); + + it('1. positive outbound: actual CEA transfer and counter execution, receipt and replay', async () => { + const before = decodeWireCounter((await sol.getAccountInfo(SVM_WIRE_COUNTER))!.data).value; + const count = await f.push.readContract({ address: wallet, abi: v5.abis.wallet, functionName: 'checkpointCount' }); + const { tx } = await sendSvmAgentWire(rt(agent), gen, request(), metadata); + f.evidence('svm-wire-positive-submitted', { wallet, rulesId, hash: tx.hash }); + // Existing verified-wallet replay adaptation enables normal outbound wait; + // it uses the gateway wire call as the logical summary, not a new SVM API. + await adaptTrackedResponse(rt(agent), tx); + expect(tx.from).toBe(wallet); expect(tx.route).toBe('UOA_TO_CEA'); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + f.evidence('svm-wire-positive-receipt', { hash: tx.hash, receipt }); + expect(receipt.status).toBe(1); expect(receipt.externalStatus).toBe('success'); + expect(receipt.from).toBe(wallet); expect(receipt.externalChain).toBe(SVM_WIRE_CHAIN); + const destination = await sol.getParsedTransaction(receipt.externalTxHash!, { commitment: 'confirmed', maxSupportedTransactionVersion: 0 }); + expect(destination).not.toBeNull(); expect(destination!.meta!.err).toBeNull(); + expect(transferFromCea(destination!, cea.toBase58(), recipient.toBase58(), AMOUNT)).toBe(true); + expect(destination!.meta!.innerInstructions?.some((g) => g.instructions.some((ix) => + 'data' in ix && ix.programId.toBase58() === SVM_WIRE_PROGRAM && bytesToHex(bs58.decode(ix.data)) === bytesToHex(ixData(RECEIVE, AMOUNT)) && + ix.accounts.some((a) => a.equals(cea)) + ))).toBe(true); + expect(decodeWireCounter((await sol.getAccountInfo(SVM_WIRE_COUNTER))!.data).value).toBeGreaterThanOrEqual(before + AMOUNT); + expect((await stored()).config.assets[0].spent).toBe(AMOUNT); + expect(await f.push.readContract({ address: wallet, abi: v5.abis.wallet, functionName: 'checkpointCount' })).toBe(count); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.from).toBe(wallet); expect(replay.route).toBe('UOA_TO_CEA'); + expect(replay.agentic).toMatchObject({ door: 'agent', rulesId }); + const replayReceipt = await replay.wait({ outboundTimeoutMs: 600_000 }); + expect(replayReceipt.externalTxHash).toBe(receipt.externalTxHash); + f.evidence('svm-wire-positive-destination', { hash: tx.hash, externalTxHash: receipt.externalTxHash, destination, replayReceipt }); + }, 900_000); + + it('2. substituted account and oversized instruction amount fail before submission', async () => { + const base = request(); + const wrong = [...base.accounts!]; wrong[2] = { ...wrong[2], pubkey: svmKey(recipient.toBase58()) }; + const nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + await expect(sendSvmAgentWire(rt(agent), gen, { ...base, accounts: wrong }, metadata)).rejects.toMatchObject({ name: 'AgenticRevertError', decodedError: { name: 'PolicyCheckReverted(SvmAccountPinMismatch)' } }); + await expect(sendSvmAgentWire(rt(agent), gen, { ...base, instructionData: ixData(RECEIVE, AMOUNT + BigInt(1)) }, metadata)).rejects.toMatchObject({ name: 'AgenticRevertError', decodedError: { name: 'PolicyCheckReverted(SvmDataCeilingExceeded)' } }); + const prepared = await prepareSvmAgentExecution(rt(agent), gen, base, metadata); + const policy = await probeSvmWirePolicies(f.push, { from: f.agentAddress, wallet, rulesId, call: prepared.call, blockNumber: await f.push.getBlockNumber() }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe(nonce); + f.evidence('svm-wire-preflight-refusals', { wallet, rulesId, nonce, policy, transactionsSent: 0 }); + }); + + it('3. destination program failure is classified and does not restore policy spend', async () => { + const prior = (await stored()).config.assets[0].spent; + const { tx } = await sendSvmAgentWire(rt(agent), gen, request(true), metadata); + f.evidence('svm-wire-failure-submitted', { wallet, rulesId, hash: tx.hash }); + await adaptTrackedResponse(rt(agent), tx); + // The observed node rejection lifecycle exceeded ten minutes. Keep the + // failed assertion strict while allowing the bounded terminalization window. + const receipt = await tx.wait({ outboundTimeoutMs: 1_200_000 }); + f.evidence('svm-wire-failure-receipt', { hash: tx.hash, receipt }); + const pushReceipt = await f.push.getTransactionReceipt({ hash: tx.hash as Hex }); + expect(pushReceipt.status).toBe('success'); expect(receipt.externalStatus).toBe('failed'); + expect((await stored()).config.assets[0].spent).toBe(prior + AMOUNT); + if (receipt.externalTxHash) { + const destination = await sol.getParsedTransaction(receipt.externalTxHash, { commitment: 'confirmed', maxSupportedTransactionVersion: 0 }); + expect(destination).not.toBeNull(); expect(destination!.meta!.err).not.toBeNull(); + f.evidence('svm-wire-failure-destination', { hash: tx.hash, externalTxHash: receipt.externalTxHash, destination }); + } + }, 1_500_000); +}); diff --git a/packages/core/__e2e__/agw-svm-wire/replay.spec.ts b/packages/core/__e2e__/agw-svm-wire/replay.spec.ts new file mode 100644 index 000000000..3ea2196cc --- /dev/null +++ b/packages/core/__e2e__/agw-svm-wire/replay.spec.ts @@ -0,0 +1,59 @@ +/** Read-only acceptance of recorded wire transactions; no signer or funding required. */ +import { PushChain } from '../../src'; +import { createPublicClient, http, type Hex } from 'viem'; +import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; +import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { chainReaderFromPublicClient } from '../../src/lib/agentic/contracts/reader'; +import { resolveWalletGeneration } from '../../src/lib/agentic/deployments'; +import { Snapshot } from '../../src/lib/agentic/reads/snapshot'; +import { readSvmRule } from '../../src/lib/agentic/reads/svm'; +import { probeSvmWirePolicies } from '../shared/agw-svm-policy-probe'; + +const d = process.env['AGW_SVM_WIRE_REPLAY_E2E'] === '1' ? describe : describe.skip; +const POSITIVE = '0x50a96ff863722ced3464e3e8e5e451621903396c9bc83fcc010756dc1aee73b8' as Hex; +const REJECTED = '0x51e2227e833e8090844f151c2d5d959225d3ec24828bf11a1c6467e2a74410fc' as Hex; +const EXTERNAL = 'kGUaPUfASg9Poo93aMsztHJjUk27MKuRJBfDZK1WP3FFSaXSYcoPcxjUMFyDxaK6aU6mQkZeowSu3MtvK5PXKgK'; + +d('agw svm recorded replay', () => { + const push = createPublicClient({ chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]) }); + let source: Awaited>; + let decoded: Extract>, { kind: 'executeAsAgent' }>; + let client: PushChain; + beforeAll(async () => { + source = await push.getTransaction({ hash: POSITIVE }); + const call = v5.decodeWalletCall(source.input); + if (!source.to || call?.kind !== 'executeAsAgent') throw new Error('Recorded fixture is not an AGW agent call'); + decoded = call; + client = await PushChain.initialize({ chain: PushChain.CONSTANTS.CHAIN.PUSH_TESTNET_DONUT, address: source.from }, { network: PUSH_NETWORK.TESTNET_DONUT }); + expect(client.isReadMode).toBe(true); + }); + it('1. positive replay retains wallet, signer origin and actual Solana signature', async () => { + const tx = await client.universal.trackTransaction(POSITIVE); + expect(tx.from.toLowerCase()).toBe(source.to!.toLowerCase()); + expect(tx.origin.toLowerCase()).toBe(`eip155:42101:${source.from}`.toLowerCase()); + expect(tx.agentic?.rulesId).toBe(decoded.rulesId); expect(tx.route).toBe('UOA_TO_CEA'); + const receipt = await tx.wait({ outboundTimeoutMs: 30_000 }); + expect(receipt.status).toBe(1); expect(receipt.externalStatus).toBe('success'); expect(receipt.externalTxHash).toBe(EXTERNAL); + }, 120_000); + it('2. later terminal rejection is failed, while the source succeeded and spend remains charged', async () => { + const tx = await client.universal.trackTransaction(REJECTED); + const receipt = await tx.wait({ outboundTimeoutMs: 30_000 }); + expect(receipt.status).toBe(1); expect(receipt.externalStatus).toBe('failed'); + expect(receipt.externalError).toContain('tx not executed on destination chain'); + expect(tx.from.toLowerCase()).toBe(source.to!.toLowerCase()); + // Historical snapshot after the actual refund block, immune to later owner changes. + const reader = chainReaderFromPublicClient(push); + const gen = await resolveWalletGeneration(reader, PUSH_NETWORK.TESTNET_DONUT, source.to!); + const snapshot = new Snapshot(reader, BigInt(23950160)); + const rule = await readSvmRule(snapshot, gen, source.to!, decoded.rulesId, 'eip155:42101'); + expect(rule.config.assets[0].spent).toBe(BigInt(20_000)); + }, 120_000); + it('3. pinned live policy calls refuse account and data mutations without submitting', async () => { + const receipt = await push.getTransactionReceipt({ hash: POSITIVE }); + const result = await probeSvmWirePolicies(push, { from: source.from, wallet: source.to!, rulesId: decoded.rulesId, call: decoded.calls[0], blockNumber: receipt.blockNumber }); + expect(result.transactionsSent).toBe(0); expect(result.observations.map((r) => r.decoded.name)).toEqual([ + 'PolicyCheckReverted(SvmAccountPinMismatch)', 'PolicyCheckReverted(SvmDataCeilingExceeded)', + ]); + }); +}); diff --git a/packages/core/__e2e__/agw/_fixture.ts b/packages/core/__e2e__/agw/_fixture.ts new file mode 100644 index 000000000..649ccbb26 --- /dev/null +++ b/packages/core/__e2e__/agw/_fixture.ts @@ -0,0 +1,138 @@ +/** + * Shared AGW E2E fixture. Everything here is setup: the behaviour under test + * is always exercised through public SDK methods in the specs. + * + * Accounts (never logged): + * PUSH_PRIVATE_KEY Push-native owner (the Push master); pays owner gas, + * wallet PC and the bounded per-run agent funding. + * EVM_PRIVATE_KEY external EVM agent, acting through its Sepolia-origin UEA. + * SOLANA_PRIVATE_KEY Solana-origin agent through its UEA (Push-destination only). + * A fresh Push-native agent key is generated per run and funded with at most + * AGW_E2E_AGENT_PC (default 0.5 PC). Wallets are fresh per run; nothing shared + * is revoked or drained. + */ +import '@e2e/shared/setup'; +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createPublicClient, createWalletClient, defineChain, http, parseEther, type Address, type Hex, type PublicClient } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { PushChain } from '../../src'; +import { CHAIN_INFO } from '../../src/lib/constants/chain'; +import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { registerAgenticGeneration, resetAgenticGenerations } from '../../src/lib/agentic/deployments'; +import { createEvmPushClient } from '@e2e/shared/evm-client'; +import { AgwPrerequisiteError, loadAgwManifest, verifyAgwManifest, type AgwManifest } from './_manifest'; + +export const AGW_E2E_ENABLED = process.env['AGW_E2E'] === '1'; + +export interface AgwFixture { + manifest: AgwManifest; + push: PublicClient; + owner: PushChain; + ownerAddress: Address; + agentKey: Hex; + agentAddress: Address; + agent(wallet: Address, progressHook?: (e: unknown) => void): Promise; + fundPC(to: Address, amount: bigint): Promise; + evidence(name: string, data: Record): void; + teardown(): void; +} + +const pushChainFor = (network: PUSH_NETWORK) => + network === PUSH_NETWORK.MAINNET ? CHAIN.PUSH_MAINNET : network === PUSH_NETWORK.LOCALNET ? CHAIN.PUSH_LOCALNET : CHAIN.PUSH_TESTNET_DONUT; + +/** + * Strict prerequisite gate: throws (failing beforeAll, hence every test) when + * the manifest, keys or wiring are missing. A selected agw run can never pass + * without exercising a verified deployment. + */ +export async function setupAgw(): Promise { + const manifest = loadAgwManifest(); + if (manifest.network !== PUSH_NETWORK.TESTNET_DONUT) { + throw new AgwPrerequisiteError('live AGW fixtures require Donut'); + } + const pushKey = process.env['PUSH_PRIVATE_KEY'] as Hex | undefined; + if (!pushKey) throw new AgwPrerequisiteError('PUSH_PRIVATE_KEY is not set'); + const chain = pushChainFor(manifest.network); + const info = CHAIN_INFO[chain]; + const push = createPublicClient({ + chain: defineChain({ + id: Number(info.chainId), + name: 'push', + nativeCurrency: { name: 'PC', symbol: 'PC', decimals: 18 }, + rpcUrls: { default: { http: info.defaultRPC } }, + }), + transport: http(info.defaultRPC[0]), + }) as PublicClient; + await verifyAgwManifest(push, manifest); + resetAgenticGenerations(); + registerAgenticGeneration({ + id: manifest.generation, + sourceCommit: manifest.sourceCommit, + network: manifest.network, + advertised: false, + addresses: manifest.addresses, + startBlock: manifest.startBlock, + accountId: manifest.accountId, + capabilities: manifest.capabilities, + }); + + const { pushClient: owner, account } = await createEvmPushClient({ chain, privateKey: pushKey, network: manifest.network }); + const agentKey = generatePrivateKey(); + const agentAddress = privateKeyToAccount(agentKey).address; + const fundPC = async (to: Address, amount: bigint) => { + const tx = await owner.universal.sendTransaction({ to, value: amount }); + const r = await tx.wait(); + if (r.status !== 1) throw new Error(`funding ${to} failed`); + }; + await fundPC(agentAddress, parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5')); + + const logDir = join(__dirname, '..', '..', 'e2e-logs'); + mkdirSync(logDir, { recursive: true }); + const evidence = (name: string, data: Record) => + appendFileSync( + join(logDir, 'agw-evidence.jsonl'), + JSON.stringify( + { name, at: new Date().toISOString(), network: manifest.network, sourceCommit: manifest.sourceCommit, ...data }, + (_k, v) => (typeof v === 'bigint' ? v.toString() : v) + ) + '\n' + ); + + return { + manifest, + push, + owner, + ownerAddress: account.address, + agentKey, + agentAddress, + agent: (wallet, progressHook) => evmClient(agentKey, chain, manifest.network, wallet, progressHook), + fundPC, + evidence, + teardown: () => resetAgenticGenerations(), + }; +} + +export const inSeconds = (s: number) => Math.floor(Date.now() / 1000) + s; + +/** A client for an EVM key on `origin` (Push-native or external), optionally acting as an AGW. */ +export async function evmClient( + key: Hex, + origin: CHAIN, + network: PUSH_NETWORK, + agenticWallet?: Address, + progressHook?: (e: unknown) => void +): Promise { + const walletClient = createWalletClient({ + account: privateKeyToAccount(key), + transport: http(CHAIN_INFO[origin].defaultRPC[0]), + }); + const signer = await PushChain.utils.signer.toUniversalFromKeypair(walletClient, { + chain: origin, + library: PushChain.CONSTANTS.LIBRARY.ETHEREUM_VIEM, + }); + return PushChain.initialize(signer, { + network, + ...(agenticWallet ? { agenticWallet } : {}), + ...(progressHook ? { progressHook: progressHook as never } : {}), + }); +} diff --git a/packages/core/__e2e__/agw/_manifest.ts b/packages/core/__e2e__/agw/_manifest.ts new file mode 100644 index 000000000..d3dc1efc5 --- /dev/null +++ b/packages/core/__e2e__/agw/_manifest.ts @@ -0,0 +1,203 @@ +/** + * AGW deployment manifest for live E2E — READ-ONLY. Loaded from the JSON file + * optionally named by AGW_DEPLOYMENT_MANIFEST, otherwise the built-in Donut v5 + * manifest. Code/wiring/version are verified before tests or preflight funding. + * + * Manifest shape: + * { + * "network": "TESTNET_DONUT", + * "generation": "v5", + * "sourceCommit": "<40-hex AGW commit>", + * "startBlock": "", + * "accountId": "push.agw.1.0.0", + * "addresses": { "factory", "walletImplementation", "sessionEngine", + * "rulesPolicy", "sessionValidator", "gateway" }, + * "capabilities": ["nativeRules", ...] // optional; defaults to the generation's + * } + */ +import { readFileSync } from 'node:fs'; +import { + getAddress, + isAddress, + type Address, + type PublicClient, + toFunctionSelector, +} from 'viem'; +import { PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { AgenticCapability } from '../../src/lib/agentic/capabilities'; +import { V5_CAPABILITIES } from '../../src/lib/agentic/deployments'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { + DONUT_V5_ADDRESSES, + DONUT_V5_START_BLOCK, + V5_SOURCE_COMMIT, +} from '../../src/lib/agentic/contracts/donut-v5'; + +export const AGW_PREREQ_HINT = + 'AGW live E2E needs checked v5 wiring, signer keys and testnet funding. ' + + 'The default is the checked Donut v5 manifest; custom manifests must match the v5 source.'; + +export interface AgwManifest { + network: PUSH_NETWORK; + generation: 'v5'; + sourceCommit: string; + startBlock: bigint; + accountId: string; + addresses: { + factory: Address; + walletImplementation: Address; + sessionEngine: Address; + rulesPolicy: Address; + sessionValidator: Address; + gateway: Address; + }; + capabilities: ReadonlySet; +} + +export class AgwPrerequisiteError extends Error { + constructor(reason: string) { + super(`AGW prerequisite not met: ${reason}. ${AGW_PREREQ_HINT}`); + this.name = 'AgwPrerequisiteError'; + } +} + +export function loadAgwManifest( + path = process.env['AGW_DEPLOYMENT_MANIFEST'] +): AgwManifest { + if (!path) + return { + network: PUSH_NETWORK.TESTNET_DONUT, + generation: 'v5', + sourceCommit: V5_SOURCE_COMMIT, + startBlock: DONUT_V5_START_BLOCK, + accountId: 'push.agw.1.0.0', + addresses: DONUT_V5_ADDRESSES, + capabilities: V5_CAPABILITIES, + }; + let raw: Record; + try { + raw = JSON.parse(readFileSync(path, 'utf8')); + } catch (cause) { + throw new AgwPrerequisiteError( + `cannot read manifest ${path}: ${(cause as Error).message}` + ); + } + const network = raw['network'] as PUSH_NETWORK; + if (!Object.values(PUSH_NETWORK).includes(network)) + throw new AgwPrerequisiteError(`unknown network ${String(network)}`); + if (raw['generation'] !== v5.id) { + throw new AgwPrerequisiteError( + `generation ${String(raw['generation'])} has no SDK adapter` + ); + } + if (raw['sourceCommit'] !== V5_SOURCE_COMMIT) { + throw new AgwPrerequisiteError( + 'sourceCommit must match the supported v5 source pin' + ); + } + const a = (raw['addresses'] ?? {}) as Record; + const keys = [ + 'factory', + 'walletImplementation', + 'sessionEngine', + 'rulesPolicy', + 'sessionValidator', + 'gateway', + ] as const; + for (const k of keys) { + if (!isAddress(a[k] ?? '', { strict: false })) + throw new AgwPrerequisiteError(`addresses.${k} is missing or invalid`); + } + const caps = Array.isArray(raw['capabilities']) + ? new Set( + (raw['capabilities'] as string[]).map((c) => { + if (!V5_CAPABILITIES.has(c as AgenticCapability)) + throw new AgwPrerequisiteError(`unknown capability ${c}`); + return c as AgenticCapability; + }) + ) + : V5_CAPABILITIES; + return { + network, + generation: 'v5', + sourceCommit: raw['sourceCommit'], + startBlock: BigInt(String(raw['startBlock'] ?? '0')), + accountId: String(raw['accountId'] ?? 'push.agw.1.0.0'), + addresses: Object.fromEntries( + keys.map((k) => [k, getAddress(a[k])]) + ) as AgwManifest['addresses'], + capabilities: caps, + }; +} + +/** Read-only on-chain check that the manifest's wiring is what it claims. */ +export async function verifyAgwManifest( + client: PublicClient, + m: AgwManifest +): Promise { + const read = ( + address: Address, + abi: readonly unknown[], + functionName: string + ) => + client.readContract({ + address, + abi: abi as never, + functionName: functionName as never, + } as never) as Promise; + for (const [name, addr] of Object.entries(m.addresses)) { + if (name === 'gateway') continue; // a precompile may report no code + const code = await client.getCode({ address: addr }); + if (!code || code === '0x') + throw new AgwPrerequisiteError(`${name} ${addr} has no code`); + } + const impl = getAddress( + (await read( + m.addresses.factory, + v5.abis.factory, + 'walletImplementation' + )) as string + ); + if (impl !== m.addresses.walletImplementation) { + throw new AgwPrerequisiteError( + `factory implementation is ${impl}, manifest says ${m.addresses.walletImplementation}` + ); + } + const implementationCode = await client.getCode({ address: impl }); + for (const signature of [ + 'label()', + 'setLabel(string)', + 'initializeAccount(string)', + ]) { + if (!implementationCode?.includes(toFunctionSelector(signature).slice(2))) { + throw new AgwPrerequisiteError( + `implementation is missing the v5 ${signature} selector` + ); + } + } + const wiring: [string, Address][] = [ + ['SESSION_ENGINE', m.addresses.sessionEngine], + ['RULES_POLICY', m.addresses.rulesPolicy], + ['SESSION_VALIDATOR', m.addresses.sessionValidator], + ['UNIVERSAL_GATEWAY_PC', m.addresses.gateway], + ]; + for (const [fn, expected] of wiring) { + const got = getAddress((await read(impl, v5.abis.wallet, fn)) as string); + if (got !== expected) + throw new AgwPrerequisiteError( + `implementation ${fn} is ${got}, manifest says ${expected}` + ); + } + const policyVersion = await read( + m.addresses.rulesPolicy, + v5.abis.policy, + 'version' + ); + if (policyVersion !== '3.1.0') + throw new AgwPrerequisiteError( + `unsupported URP version ${String(policyVersion)}` + ); + const accountId = (await read(impl, v5.abis.wallet, 'accountId')) as string; + if (accountId !== m.accountId) + throw new AgwPrerequisiteError(`implementation accountId is ${accountId}`); +} diff --git a/packages/core/__e2e__/agw/create.spec.ts b/packages/core/__e2e__/agw/create.spec.ts new file mode 100644 index 000000000..177f72010 --- /dev/null +++ b/packages/core/__e2e__/agw/create.spec.ts @@ -0,0 +1,87 @@ +/** + * Scenario 1 — create: bare wallet and wallet with rules. Strictly gated on a + * verified AGW deployment manifest (see _manifest.ts); beforeAll throws when + * it is missing so a selected run cannot pass vacuously. + */ +import { getAddress, type Address } from 'viem'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { + AGW_E2E_ENABLED, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; + +d('agw create', () => { + let f: AgwFixture; + beforeAll(async () => { + f = await setupAgw(); + }, 300_000); + afterAll(() => f?.teardown()); + + it('1. bare wallet: factory records the owner and index, no rules, no implicit funding', async () => { + const before = await f.owner.agentic.derive(); + const created = await f.owner.agentic.create('e2e-bare', { rules: [] }); + expect(created.wallet).toBe(before.address); + expect(created.index).toBe(before.index); + expect(created.rulesIds).toEqual([]); + const factoryAbi = v5.abis.factory; + const [ownerOf, indexOf, balance] = await Promise.all([ + f.push.readContract({ + address: f.manifest.addresses.factory, + abi: factoryAbi, + functionName: 'ownerOf', + args: [created.wallet], + }), + f.push.readContract({ + address: f.manifest.addresses.factory, + abi: factoryAbi, + functionName: 'indexOf', + args: [created.wallet], + }), + f.push.getBalance({ address: created.wallet }), + ]); + expect(getAddress(ownerOf as Address)).toBe(f.ownerAddress); + expect(Number(indexOf)).toBe(created.index); + expect(balance).toBe(BigInt(0)); + const info = await f.owner.agentic.wallet(created.wallet).info(); + expect(info).toMatchObject({ + label: 'e2e-bare', + deployed: true, + rulesCount: 0, + owner: f.ownerAddress, + }); + f.evidence('create-bare', { + wallet: created.wallet, + index: created.index, + tx: created.tx.hash, + block: created.tx.blockNumber, + }); + }); + + it('2. wallet with a native rule returns the receipt-confirmed rule ID', async () => { + const created = await f.owner.agentic.create('e2e-rules', { + rules: [ + { + agent: f.agentAddress, + target: f.ownerAddress, + selector: 'value-only', + validUntil: inSeconds(3600), + maxValuePerCall: BigInt(1), + }, + ], + }); + expect(created.rulesIds).toHaveLength(1); + const { rules } = await f.owner.agentic.wallet(created.wallet).rules.list(); + expect(rules.map((r) => r.rulesId)).toEqual(created.rulesIds); + expect(rules[0].agent).toBe(getAddress(f.agentAddress)); + f.evidence('create-with-rule', { + wallet: created.wallet, + rulesIds: created.rulesIds, + tx: created.tx.hash, + atomic: created.tx.atomic, + }); + }); +}); diff --git a/packages/core/__e2e__/agw/funding-allowance.spec.ts b/packages/core/__e2e__/agw/funding-allowance.spec.ts new file mode 100644 index 000000000..064cd5bb5 --- /dev/null +++ b/packages/core/__e2e__/agw/funding-allowance.spec.ts @@ -0,0 +1,47 @@ +/** + * Scenario 2 — funding and bounded gateway allowance are ordinary sends, + * separate from create, and wallet PC is separate from signer gas. + */ +import { encodeFunctionData, erc20Abi, parseEther, type Address, type Hex } from 'viem'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import { AGW_E2E_ENABLED, evmClient, setupAgw, type AgwFixture } from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; + +d('agw funding', () => { + let f: AgwFixture; + let wallet: Address; + beforeAll(async () => { + f = await setupAgw(); + wallet = (await f.owner.agentic.create('e2e-funding', { rules: [] })).wallet; + }, 300_000); + afterAll(() => f?.teardown()); + + it('1. wallet PC is funded by an ordinary send and is separate from the signer balance', async () => { + const signerBefore = await f.push.getBalance({ address: f.ownerAddress }); + await f.fundPC(wallet, parseEther('0.01')); + expect(await f.push.getBalance({ address: wallet })).toBe(parseEther('0.01')); + expect(await f.push.getBalance({ address: f.ownerAddress })).toBeLessThan(signerBefore); + }); + + it('2. owner sets and removes a bounded gateway allowance through the owner door', async () => { + const token = getNativePRC20ForChain(CHAIN.ETHEREUM_SEPOLIA, f.manifest.network); + const ownerAgw = await evmClient(process.env['PUSH_PRIVATE_KEY'] as Hex, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet); + const allowance = () => + f.push.readContract({ address: token, abi: erc20Abi, functionName: 'allowance', args: [wallet, f.manifest.addresses.gateway] }); + const approve = (amount: bigint) => + ownerAgw.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [f.manifest.addresses.gateway, amount] }), + }); + const set = await approve(BigInt(1000)); + expect((await set.wait()).status).toBe(1); + expect(set.from).toBe(wallet); + expect(set.agentic?.door).toBe('owner'); + expect(await allowance()).toBe(BigInt(1000)); + await (await approve(BigInt(0))).wait(); + expect(await allowance()).toBe(BigInt(0)); + f.evidence('allowance-set-remove', { wallet, token, setTx: set.hash }); + }); +}); diff --git a/packages/core/__e2e__/agw/identity-gas.spec.ts b/packages/core/__e2e__/agw/identity-gas.spec.ts new file mode 100644 index 000000000..7d79af4e8 --- /dev/null +++ b/packages/core/__e2e__/agw/identity-gas.spec.ts @@ -0,0 +1,164 @@ +/** + * Scenario 9 — external-key agents act through the UEA for their actual + * origin chain; first-use vs subsequent gas. Solana-ORIGIN agents are covered + * here on a Push destination; Solana-DESTINATION rules are a separate, + * capability-gated feature (see multi-asset-svm.spec.ts). + */ +import { Keypair } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { + createPublicClient, + createWalletClient, + fallback, + getAddress, + http, + parseEther, + type Address, + type Hex, +} from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { sepolia } from 'viem/chains'; +import { PushChain } from '../../src'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { CHAIN_INFO } from '../../src/lib/constants/chain'; +import { createProgressTracker } from '@e2e/shared/progress-tracker'; +import { + AGW_E2E_ENABLED, + evmClient, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const SINK = getAddress('0x000000000000000000000000000000000000dEaD'); + +async function ueaOf(address: string, chain: CHAIN): Promise
{ + const r = await PushChain.utils.account.deriveExecutorAccount( + PushChain.utils.account.toUniversal(address, { chain }), + { + skipNetworkCheck: true, + } + ); + return getAddress(r.address); +} + +d('agw identity', () => { + let f: AgwFixture; + beforeAll(async () => { + f = await setupAgw(); + }, 300_000); + afterAll(() => f?.teardown()); + + const walletFor = async (agent: Address) => { + const created = await f.owner.agentic.create('e2e-identity', { + rules: [ + { + agent, + target: SINK, + selector: 'value-only', + validUntil: inSeconds(3600), + maxValuePerCall: BigInt(1000), + maxValueTotal: BigInt(10_000), + }, + ], + }); + await f.fundPC(created.wallet, parseEther('0.001')); + return created.wallet; + }; + + it('1. an EVM key on Sepolia acts through its Sepolia UEA; origin and account status stay signer-scoped', async () => { + const key = process.env['EVM_PRIVATE_KEY'] as Hex; + if (!key) throw new Error('EVM_PRIVATE_KEY is required'); + const uea = await ueaOf( + privateKeyToAccount(key).address, + CHAIN.ETHEREUM_SEPOLIA + ); + const wallet = await walletFor(uea); + const agent = await evmClient( + key, + CHAIN.ETHEREUM_SEPOLIA, + f.manifest.network, + wallet + ); + expect(agent.universal.account).toBe(wallet); + expect(agent.universal.origin.chain).toBe(CHAIN.ETHEREUM_SEPOLIA); + expect((await agent.getAccountStatus()).uea.deployed).toBe(true); + const tx = await agent.universal.sendTransaction({ + to: SINK, + value: BigInt(1000), + }); + expect((await tx.wait()).status).toBe(1); + expect(tx.from).toBe(wallet); + expect(tx.origin.startsWith('eip155:11155111:')).toBe(true); + f.evidence('external-evm-agent', { wallet, uea, tx: tx.hash }); + }, 600_000); + + it('2. first use of an undeployed external UEA takes the one-time fee lock; the next action pays from the UEA', async () => { + const key = generatePrivateKey(); + const addr = privateKeyToAccount(key).address; + const transport = fallback( + CHAIN_INFO[CHAIN.ETHEREUM_SEPOLIA].defaultRPC.map((url) => http(url)) + ); + const master = createWalletClient({ + account: privateKeyToAccount(process.env['EVM_PRIVATE_KEY'] as Hex), + chain: sepolia, + transport, + }); + const origin = createPublicClient({ chain: sepolia, transport }); + const funded = await master.sendTransaction({ + to: addr, + value: parseEther(process.env['AGW_E2E_FIRST_USE_ETH'] ?? '0.003'), + account: master.account!, + chain: sepolia, + }); + await origin.waitForTransactionReceipt({ hash: funded }); + const uea = await ueaOf(addr, CHAIN.ETHEREUM_SEPOLIA); + const wallet = await walletFor(uea); + const first = createProgressTracker(); + const agent = await evmClient( + key, + CHAIN.ETHEREUM_SEPOLIA, + f.manifest.network, + wallet, + first.hook as (e: unknown) => void + ); + await ( + await agent.universal.sendTransaction({ to: SINK, value: BigInt(1) }) + ).wait(); + expect(first.hasEvent('SEND-TX-105-01')).toBe(true); + const second = createProgressTracker(); + await ( + await agent.universal.sendTransaction( + { to: SINK, value: BigInt(1) }, + { progressHook: second.hook } + ) + ).wait(); + expect(second.hasEvent('SEND-TX-105-01')).toBe(false); + f.evidence('first-use-uea', { wallet, uea }); + }, 900_000); + + it('3. a Solana-origin key acts through its Solana UEA on a Push destination', async () => { + const raw = process.env['SOLANA_PRIVATE_KEY']; + if (!raw) throw new Error('SOLANA_PRIVATE_KEY is required'); + const kp = Keypair.fromSecretKey(bs58.decode(raw)); + const uea = await ueaOf(kp.publicKey.toBase58(), CHAIN.SOLANA_DEVNET); + const wallet = await walletFor(uea); + const signer = await PushChain.utils.signer.toUniversalFromKeypair(kp, { + chain: CHAIN.SOLANA_DEVNET, + library: PushChain.CONSTANTS.LIBRARY.SOLANA_WEB3JS, + }); + const agent = await PushChain.initialize(signer, { + network: f.manifest.network, + agenticWallet: wallet, + }); + const tx = await agent.universal.sendTransaction({ + to: SINK, + value: BigInt(1), + }); + expect((await tx.wait()).status).toBe(1); + expect(tx.from).toBe(wallet); + expect(tx.origin.startsWith('solana:')).toBe(true); + f.evidence('solana-origin-agent', { wallet, uea, tx: tx.hash }); + }, 600_000); +}); diff --git a/packages/core/__e2e__/agw/labels.spec.ts b/packages/core/__e2e__/agw/labels.spec.ts new file mode 100644 index 000000000..8940c1ea0 --- /dev/null +++ b/packages/core/__e2e__/agw/labels.spec.ts @@ -0,0 +1,461 @@ +/** Public SDK labels against checked Donut v5; fresh wallets only. */ +import { + decodeEventLog, + createWalletClient, + http, + getAddress, + parseEther, + type Address, + type Hex, +} from 'viem'; +import { Keypair } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; +import { readNativeCounters } from '@e2e/shared/agw-state'; +import { privateKeyToAccount } from 'viem/accounts'; +import { PushChain } from '../../src'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { v5, MODE_SINGLE } from '../../src/lib/agentic/contracts/v5'; +import { decodeAgenticRevert } from '../../src/lib/agentic/revert'; +import { + AGW_E2E_ENABLED, + evmClient, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +d('agw labels', () => { + let f: AgwFixture; + beforeAll(async () => { + f = await setupAgw(); + }, 300_000); + afterAll(() => f?.teardown()); + const count = (address: Address) => + f.push.readContract({ + address, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + + it('1. default and custom deployment labels are stored and listed', async () => { + const empty = await f.owner.agentic.create('', { rules: [] }); + const custom = await f.owner.agentic.create('e2e-v5-custom', { rules: [] }); + expect((await f.owner.agentic.wallet(empty.wallet).info()).label).toBe( + `AGW ${empty.index + 1}` + ); + expect((await f.owner.agentic.wallet(custom.wallet).info()).label).toBe( + 'e2e-v5-custom' + ); + const { wallets } = await f.owner.agentic.list(); + expect(wallets.find((w) => w.address === empty.wallet)?.label).toBe( + `AGW ${empty.index + 1}` + ); + expect(wallets.find((w) => w.address === custom.wallet)?.label).toBe( + 'e2e-v5-custom' + ); + const renamed = await f.owner.agentic + .wallet(custom.wallet) + .setLabel('isolated-rename'); + expect((await f.owner.agentic.wallet(custom.wallet).info()).label).toBe( + 'isolated-rename' + ); + expect((await f.owner.agentic.wallet(empty.wallet).info()).label).toBe( + `AGW ${empty.index + 1}` + ); + expect(await count(empty.wallet)).toBe(BigInt(0)); + expect(await count(custom.wallet)).toBe(BigInt(1)); + f.evidence('v5-label-default-custom', { + wallets: [empty.wallet, custom.wallet], + indices: [empty.index, custom.index], + hashes: [empty.tx.hash, custom.tx.hash, renamed.hash], + }); + }); + + it('2. owner rename and empty reset update info/list, emit LabelSet and tick the owner door', async () => { + const made = await f.owner.agentic.create('before', { rules: [] }); + const w = f.owner.agentic.wallet(made.wallet), + before = await count(made.wallet), + hook = jest.fn(); + const tx = await w.setLabel('after', { progressHook: hook }); + const receipt = await tx.wait(); + const labels = receipt.logs.flatMap((log) => { + if (log.address.toLowerCase() !== made.wallet.toLowerCase()) return []; + try { + const ev = decodeEventLog({ + abi: v5.abis.wallet, + data: log.data, + topics: log.topics, + }); + return ev.eventName === 'LabelSet' ? [ev.args.label] : []; + } catch { + return []; + } + }); + expect(labels).toEqual(['after']); + expect((await w.info()).label).toBe('after'); + expect( + (await f.owner.agentic.list()).wallets.find( + (x) => x.address === made.wallet + )?.label + ).toBe('after'); + expect(await count(made.wallet)).toBe(before + BigInt(1)); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ id: 'AGENTIC-TX-199-01' }) + ); + const reset = await w.setLabel(''); + expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); + expect(await count(made.wallet)).toBe(before + BigInt(2)); + f.evidence('v5-label-rename-reset', { + wallet: made.wallet, + hashes: [tx.hash, reset.hash], + checkpointsBefore: before, + checkpointsAfter: await count(made.wallet), + }); + }); + + it('3. UTF-8 byte boundaries are accepted or rejected without changing wallet state', async () => { + const made = await f.owner.agentic.create('🙂'.repeat(16), { rules: [] }); + const w = f.owner.agentic.wallet(made.wallet); + await w.setLabel('a'.repeat(64)); + const before = await count(made.wallet), + next = await f.owner.agentic.derive(); + for (const label of ['a'.repeat(65), '🙂'.repeat(17)]) { + await expect(w.setLabel(label)).rejects.toMatchObject({ + code: 'INVALID_RULE', + }); + await expect( + f.owner.agentic.create(label, { rules: [] }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + await expect( + f.push.simulateContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'setLabel', + args: [label], + account: f.ownerAddress, + }) + ).rejects.toThrow('LabelTooLong'); + } + expect(await f.owner.agentic.derive()).toEqual(next); + expect((await w.info()).label).toBe('a'.repeat(64)); + expect(await count(made.wallet)).toBe(before); + f.evidence('v5-label-byte-boundary', { + wallet: made.wallet, + checkpoints: before, + unchangedNextSlot: next.index, + }); + }); + + it('4. an agent cannot rename through either the SDK or the contract', async () => { + const made = await f.owner.agentic.create('owner-only', { + rules: [ + { + agent: f.agentAddress, + target: f.ownerAddress, + selector: 'value-only', + validUntil: inSeconds(3600), + }, + ], + }); + const before = await count(made.wallet); + const agent = await f.agent(made.wallet); + await expect( + agent.agentic.wallet(made.wallet).setLabel('attack') + ).rejects.toMatchObject({ code: 'NOT_WALLET_OWNER' }); + await expect( + f.push.simulateContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'setLabel', + args: ['attack'], + account: f.agentAddress, + }) + ).rejects.toThrow('CallerIsNotOwner'); + const agentDoorError = await f.push + .simulateContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'executeAsAgent', + args: [ + made.rulesIds[0], + MODE_SINGLE, + v5.packSingle({ + target: made.wallet, + value: BigInt(0), + data: v5.encodeSetLabel('attack'), + }), + ], + account: f.agentAddress, + }) + .then( + () => undefined, + (error: unknown) => error + ); + // Normal grants cannot authorize wallet-self: the engine rejects the + // absent action policy before the wallet's additional dispatch guard. + expect(decodeAgenticRevert(agentDoorError)).toMatchObject({ + name: 'NoPoliciesSet', + }); + expect((await f.owner.agentic.wallet(made.wallet).info()).label).toBe( + 'owner-only' + ); + expect(await count(made.wallet)).toBe(before); + f.evidence('v5-label-agent-refused', { + wallet: made.wallet, + checkpoints: before, + }); + }); + + it('5. an external EVM owner creates and renames through its UEA', async () => { + const key = process.env['EVM_PRIVATE_KEY'] as Hex; + if (!key) throw new Error('EVM_PRIVATE_KEY is required'); + const origin = privateKeyToAccount(key).address; + const uea = getAddress( + ( + await PushChain.utils.account.deriveExecutorAccount( + PushChain.utils.account.toUniversal(origin, { + chain: CHAIN.ETHEREUM_SEPOLIA, + }), + { skipNetworkCheck: true } + ) + ).address + ); + await f.fundPC(uea, parseEther('3')); + const owner = await evmClient( + key, + CHAIN.ETHEREUM_SEPOLIA, + f.manifest.network + ); + const made = await owner.agentic.create('e2e-uea-before', { rules: [] }); + const w = owner.agentic.wallet(made.wallet); + expect((await w.owner()).owner).toBe(uea); + const before = await count(made.wallet); + const rename = await w.setLabel('e2e-uea-after'); + expect((await w.info()).label).toBe('e2e-uea-after'); + expect( + (await owner.agentic.list()).wallets.find( + (entry) => entry.address === made.wallet + )?.label + ).toBe('e2e-uea-after'); + const reset = await w.setLabel(''); + expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); + expect(await count(made.wallet)).toBe(before + BigInt(2)); + f.evidence('v5-label-uea-owner', { + wallet: made.wallet, + owner: uea, + hashes: [made.tx.hash, rename.hash, reset.hash], + checkpointsBefore: before, + checkpointsAfter: await count(made.wallet), + }); + }); + it('6. a Solana-origin owner creates, renames and resets through its UEA', async () => { + const raw = process.env['SOLANA_PRIVATE_KEY']; + if (!raw) throw new Error('SOLANA_PRIVATE_KEY is required'); + let kp: Keypair; + try { + kp = Keypair.fromSecretKey(bs58.decode(raw)); + } catch { + kp = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw))); + } + const uea = getAddress( + ( + await PushChain.utils.account.deriveExecutorAccount( + PushChain.utils.account.toUniversal(kp.publicKey.toBase58(), { + chain: CHAIN.SOLANA_DEVNET, + }), + { skipNetworkCheck: true } + ) + ).address + ); + await f.fundPC(uea, parseEther('3')); + const signer = await PushChain.utils.signer.toUniversalFromKeypair(kp, { + chain: CHAIN.SOLANA_DEVNET, + library: PushChain.CONSTANTS.LIBRARY.SOLANA_WEB3JS, + }); + const owner = await PushChain.initialize(signer, { + network: f.manifest.network, + }); + const made = await owner.agentic.create('', { rules: [] }); + const w = owner.agentic.wallet(made.wallet); + expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); + expect((await w.owner()).owner).toBe(uea); + const before = await count(made.wallet); + const rename = await w.setLabel('Solana owner'); + expect((await w.info()).label).toBe('Solana owner'); + expect( + (await owner.agentic.list()).wallets.find( + (entry) => entry.address === made.wallet + )?.label + ).toBe('Solana owner'); + const reset = await w.setLabel(''); + expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); + expect(await count(made.wallet)).toBe(before + BigInt(2)); + f.evidence('v5-label-solana-owner', { + wallet: made.wallet, + owner: uea, + hashes: [made.tx.hash, rename.hash, reset.hash], + checkpointsBefore: before, + checkpointsAfter: await count(made.wallet), + }); + }, 600_000); + + it('7. renaming an active wallet preserves rules, spend and remaining agent budget', async () => { + const made = await f.owner.agentic.create('active-before', { + rules: [ + { + agent: f.agentAddress, + target: f.ownerAddress, + selector: 'value-only', + validUntil: inSeconds(3600), + maxValuePerCall: BigInt(1), + maxValueTotal: BigInt(2), + maxCalls: 3, + }, + ], + }); + await f.fundPC(made.wallet, BigInt(10)); + const w = f.owner.agentic.wallet(made.wallet), + agent = await f.agent(made.wallet); + const first = await agent.universal.sendTransaction({ + to: f.ownerAddress, + value: BigInt(1), + }); + await first.wait(); + const ruleBefore = await w.rules.get(made.rulesIds[0]); + const spendBefore = await readNativeCounters( + f.push, + f.manifest.addresses, + made.wallet, + made.rulesIds[0] + ); + const checkpointsBefore = await count(made.wallet); + const nonceBefore = await f.push.readContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'grantNonce', + }); + const rename = await w.setLabel('active-after'); + expect((await w.info()).label).toBe('active-after'); + expect(await w.rules.get(made.rulesIds[0])).toEqual(ruleBefore); + expect( + await readNativeCounters( + f.push, + f.manifest.addresses, + made.wallet, + made.rulesIds[0] + ) + ).toEqual(spendBefore); + expect( + await f.push.readContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'grantNonce', + }) + ).toBe(nonceBefore); + expect(await count(made.wallet)).toBe(checkpointsBefore + BigInt(1)); + const second = await agent.universal.sendTransaction({ + to: f.ownerAddress, + value: BigInt(1), + }); + await second.wait(); + expect(second.agentic?.rulesId).toBe(made.rulesIds[0]); + const spendAfter = await readNativeCounters( + f.push, + f.manifest.addresses, + made.wallet, + made.rulesIds[0] + ); + expect(spendAfter).toMatchObject({ callsUsed: 2, valueSpent: BigInt(2) }); + const agentNonce = await f.push.getTransactionCount({ + address: f.agentAddress, + }); + await expect( + agent.universal.sendTransaction({ to: f.ownerAddress, value: BigInt(1) }) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(TotalValueExceeded)' }, + }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + agentNonce + ); + expect( + await readNativeCounters( + f.push, + f.manifest.addresses, + made.wallet, + made.rulesIds[0] + ) + ).toEqual(spendAfter); + expect(await count(made.wallet)).toBe(checkpointsBefore + BigInt(1)); + f.evidence('v5-label-active-rule', { + wallet: made.wallet, + rulesId: made.rulesIds[0], + hashes: [first.hash, rename.hash, second.hash], + spendBefore, + spendAfter, + grantNonce: nonceBefore, + }); + }); + + it('8. a failed owner batch rolls back the earlier rename and all checkpoints', async () => { + const made = await f.owner.agentic.create('batch-before', { rules: [] }); + const w = f.owner.agentic.wallet(made.wallet), + before = await count(made.wallet); + const key = process.env['PUSH_PRIVATE_KEY'] as Hex; + const owner = await evmClient( + key, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + made.wallet + ); + const calls = [ + { + to: made.wallet, + value: BigInt(0), + data: v5.encodeSetLabel('temporary'), + }, + { + to: made.wallet, + value: BigInt(0), + data: v5.encodeSetLabel('a'.repeat(65)), + }, + ]; + await expect( + owner.universal.sendTransaction({ to: made.wallet, data: calls }) + ).rejects.toMatchObject({ decodedError: { name: 'LabelTooLong' } }); + expect((await w.info()).label).toBe('batch-before'); + // The SDK refuses this during simulation. A bounded raw transaction using + // the same SDK encoding additionally proves rollback in a mined receipt. + const client = createWalletClient({ + account: privateKeyToAccount(key), + chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), + transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]), + }); + const hash = await client.sendTransaction({ + chain: client.chain, + to: made.wallet, + gas: BigInt(400_000), + data: v5.encodeExecute( + calls.map((call) => ({ + target: call.to, + value: call.value, + data: call.data, + })) + ), + }); + const receipt = await f.push.waitForTransactionReceipt({ hash }); + expect(receipt.status).toBe('reverted'); + expect(receipt.logs).toHaveLength(0); + expect((await w.info()).label).toBe('batch-before'); + expect(await count(made.wallet)).toBe(before); + f.evidence('v5-label-batch-rollback', { + wallet: made.wallet, + hash, + block: receipt.blockNumber, + gasUsed: receipt.gasUsed, + status: receipt.status, + checkpoints: before, + }); + }); +}); diff --git a/packages/core/__e2e__/agw/lifecycle.spec.ts b/packages/core/__e2e__/agw/lifecycle.spec.ts new file mode 100644 index 000000000..105a5ea24 --- /dev/null +++ b/packages/core/__e2e__/agw/lifecycle.spec.ts @@ -0,0 +1,127 @@ +import { readNativeCounters } from '@e2e/shared/agw-state'; +/** + * Scenarios 5 and 6 — add / update / revoke lifecycle and checkpoint counts. + * A replacement is five checkpoint ticks; allowance pulls are not ticks. + * The stale-spend race (intervening agent spend) is verified against the + * real contracts in the local harness; it cannot be timed reliably live. + */ +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { getAddress, parseEther, type Address, type Hex } from 'viem'; +import { AGENTIC_ERROR_CODE } from '../../src/lib/agentic/errors'; +import { + AGW_E2E_ENABLED, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; + +d('agw lifecycle', () => { + let f: AgwFixture; + let wallet: Address; + let original: Hex; + const sink = getAddress('0x000000000000000000000000000000000000dEaD'); + const rule = (maxValuePerCall: bigint) => ({ + agent: f.agentAddress, + target: sink, + selector: 'value-only' as const, + validUntil: inSeconds(3600), + maxValuePerCall, + maxValueTotal: maxValuePerCall * BigInt(10), + }); + const count = () => + f.push.readContract({ + address: wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) as Promise; + + beforeAll(async () => { + f = await setupAgw(); + wallet = (await f.owner.agentic.create('e2e-lifecycle', { rules: [] })) + .wallet; + await f.fundPC(wallet, parseEther('0.01')); + }, 300_000); + afterAll(() => f?.teardown()); + + it('1. rules.add grants with one RULES_GRANTED checkpoint', async () => { + const before = await count(); + const added = await f.owner.agentic + .wallet(wallet) + .rules.add([rule(BigInt(1000))]); + original = added.rulesIds[0]; + expect(await count()).toBe(before + BigInt(1)); + const { checkpoints } = await f.owner.agentic + .wallet(wallet) + .checkpoints({ sinceBlock: added.tx.blockNumber }); + expect(checkpoints.at(-1)).toMatchObject({ + kind: 'RULES_GRANTED', + ref: original, + }); + }); + + it('2. rules.update: old ID disabled, new ID usable with reset counters, five checkpoint ticks', async () => { + const agent = await f.agent(wallet); + await ( + await agent.universal.sendTransaction({ to: sink, value: BigInt(10) }) + ).wait(); + const spentBefore = await readNativeCounters( + f.push, + f.manifest.addresses, + wallet, + original + ); + expect(spentBefore).toMatchObject({ + valueSpent: BigInt(10), + callsUsed: 1, + }); + const before = await count(); + const res = await f.owner.agentic.wallet(wallet).rules.update({ + rules: [{ rulesId: original, rule: rule(BigInt(2000)) }], + }); + expect(await count()).toBe(before + BigInt(5)); + const replacement = res.rules[0].rulesId; + expect(res.rules[0].replaced).toBe(original); + expect( + await readNativeCounters( + f.push, + f.manifest.addresses, + wallet, + replacement + ) + ).toMatchObject({ valueSpent: BigInt(0), callsUsed: 0 }); + await expect( + f.owner.agentic.wallet(wallet).rules.get(original) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.RULE_NOT_FOUND }); + const tx = await agent.universal.sendTransaction({ + to: sink, + value: BigInt(1500), + }); + expect(tx.agentic?.rulesId).toBe(replacement); + f.evidence('rules-update', { + wallet, + original, + replacement, + tx: res.tx.hash, + checkpointDelta: 5, + }); + }); + + it('3. checkpoint history is contiguous and matches checkpointCount()', async () => { + const { checkpoints } = await f.owner.agentic.wallet(wallet).checkpoints(); + expect(checkpoints.map((c) => c.seq)).toEqual( + checkpoints.map((_, i) => i + 1) + ); + expect(BigInt(checkpoints.length)).toBe(await count()); + }); + + it('4. revoke({ all: true }) leaves no enabled rules', async () => { + await ( + await f.owner.agentic.wallet(wallet).rules.revoke({ all: true }) + ).wait(); + expect((await f.owner.agentic.wallet(wallet).rules.list()).rules).toEqual( + [] + ); + }); +}); diff --git a/packages/core/__e2e__/agw/management-extended.spec.ts b/packages/core/__e2e__/agw/management-extended.spec.ts new file mode 100644 index 000000000..6c5bb6b38 --- /dev/null +++ b/packages/core/__e2e__/agw/management-extended.spec.ts @@ -0,0 +1,283 @@ +import { PushChain } from '../../src'; +import { getAddress, type Address } from 'viem'; +import type { AgenticRuntime } from '../../src/lib/agentic/runtime'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { setupAgw, inSeconds, type AgwFixture } from './_fixture'; +import { readNativeCounters } from '../shared/agw-state'; + +const d = process.env['AGW_E2E'] === '1' ? describe : describe.skip; +const runtime = (client: PushChain) => + (client as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; + +d('agw extended management', () => { + let f: AgwFixture; + beforeAll(async () => { + f = await setupAgw(); + }, 300_000); + afterAll(() => f?.teardown()); + const rule = (agent = f.agentAddress) => ({ + agent, + target: f.ownerAddress, + selector: 'value-only' as const, + validUntil: inSeconds(3600), + maxValuePerCall: BigInt(10), + maxValueTotal: BigInt(100), + maxCalls: 10, + }); + const count = (wallet: Address) => + f.push.readContract({ + address: wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + it('1. prefunding a derived address survives deployment and info/list keep owner index and label', async () => { + const predicted = await f.owner.agentic.derive(); + expect(predicted.deployed).toBe(false); + await f.fundPC(predicted.address, BigInt(10)); + const made = await f.owner.agentic.create('e2e-prefunded-identity', { + rules: [], + }); + expect(made.wallet).toBe(predicted.address); + expect(made.index).toBe(predicted.index); + expect(await f.push.getBalance({ address: made.wallet })).toBe(BigInt(10)); + const w = f.owner.agentic.wallet(made.wallet); + expect(await w.info()).toMatchObject({ + owner: f.ownerAddress, + index: made.index, + label: 'e2e-prefunded-identity', + deployed: true, + rulesCount: 0, + }); + expect(await w.owner()).toEqual({ owner: f.ownerAddress }); + expect((await f.owner.agentic.list()).wallets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + address: made.wallet, + index: made.index, + label: 'e2e-prefunded-identity', + }), + ]) + ); + expect(await f.owner.agentic.derive({ index: made.index })).toMatchObject({ + address: made.wallet, + deployed: true, + }); + f.evidence('extended-prefunding', { + wallet: made.wallet, + index: made.index, + txHash: made.tx.hash, + }); + }); + it('2. same-agent multiplicity is ambiguous before signing and selective revoke restores uncached lookup', async () => { + const made = await f.owner.agentic.create('e2e-ambiguous', { + rules: [rule(), rule()], + }); + await f.fundPC(made.wallet, BigInt(10)); + const agent = await f.agent(made.wallet), + nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + await expect( + agent.universal.sendTransaction({ to: f.ownerAddress, value: BigInt(1) }) + ).rejects.toMatchObject({ + code: 'AMBIGUOUS_RULE', + details: { rulesIds: expect.arrayContaining(made.rulesIds) }, + }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + nonce + ); + await ( + await f.owner.agentic.wallet(made.wallet).rules.revoke([made.rulesIds[0]]) + ).wait(); + const tx = await agent.universal.sendTransaction({ + to: f.ownerAddress, + value: BigInt(1), + }); + expect((await tx.wait()).status).toBe(1); + expect(tx.agentic?.rulesId).toBe(made.rulesIds[1]); + f.evidence('extended-uncached-lookup', { + wallet: made.wallet, + removed: made.rulesIds[0], + selected: made.rulesIds[1], + txHash: tx.hash, + }); + }); + it('3. multi-rule add and revoke are atomic batches with exact checkpoint counts', async () => { + const made = await f.owner.agentic.create('e2e-management-batches', { + rules: [], + }), + w = f.owner.agentic.wallet(made.wallet); + const added = await w.rules.add([ + rule(), + rule(getAddress('0x000000000000000000000000000000000000bEEF')), + ]); + expect(await count(made.wallet)).toBe(BigInt(4)); + expect(new Set(added.rulesIds).size).toBe(2); + expect((await w.rules.list()).rules.map((r) => r.rulesId)).toEqual( + expect.arrayContaining(added.rulesIds) + ); + await (await w.rules.revoke(added.rulesIds)).wait(); + expect(await count(made.wallet)).toBe(BigInt(8)); + expect((await w.rules.list()).rules).toEqual([]); + const cp = await w.checkpoints({ sinceBlock: added.tx.blockNumber }); + expect(cp.checkpoints).toHaveLength(8); + f.evidence('extended-management-batches', { + wallet: made.wallet, + rulesIds: added.rulesIds, + addHash: added.tx.hash, + checkpoints: 8, + }); + }); + it('4. invalid revoke targets preserve enabled rules and signer nonce', async () => { + const made = await f.owner.agentic.create('e2e-revoke-guards', { + rules: [rule()], + }), + w = f.owner.agentic.wallet(made.wallet), + nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); + await expect(w.rules.revoke([])).rejects.toMatchObject({ + code: 'REVOKE_NEEDS_TARGET', + }); + await expect( + w.rules.revoke([made.rulesIds[0], made.rulesIds[0]]) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + await expect( + w.rules.revoke([`0x${'12'.repeat(32)}`]) + ).rejects.toMatchObject({ code: 'RULE_NOT_FOUND' }); + expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( + nonce + ); + expect((await w.rules.list()).rules.map((r) => r.rulesId)).toEqual( + made.rulesIds + ); + }); + it('5. intervening agent spend refuses a stale replacement without changing grants or checkpoints', async () => { + const made = await f.owner.agentic.create('e2e-live-stale-update', { + rules: [rule()], + }); + await f.fundPC(made.wallet, BigInt(10)); + const agent = await f.agent(made.wallet), + rt = runtime(f.owner), + original = rt.execute; + let injected = false, + hash: string | undefined; + const before = await count(made.wallet), + grantNonce = await f.push.readContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'grantNonce', + }); + rt.execute = async (params, opts) => { + if (!injected && params.to === made.wallet) { + injected = true; + const tx = await agent.universal.sendTransaction({ + to: f.ownerAddress, + value: BigInt(1), + }); + await tx.wait(); + hash = tx.hash; + } + return original(params, opts); + }; + try { + const error = await f.owner.agentic + .wallet(made.wallet) + .rules.update({ rules: [{ rulesId: made.rulesIds[0], rule: rule() }] }) + .then( + () => undefined, + (e: unknown) => e + ); + expect(error).toMatchObject({ + decodedError: { name: expect.stringContaining('SpentMismatch') }, + }); + expect(injected).toBe(true); + } finally { + rt.execute = original; + } + expect(await count(made.wallet)).toBe(before); + expect( + await f.push.readContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'grantNonce', + }) + ).toBe(grantNonce); + expect( + (await f.owner.agentic.wallet(made.wallet).rules.list()).rules.map( + (r) => r.rulesId + ) + ).toEqual(made.rulesIds); + expect( + await readNativeCounters( + f.push, + f.manifest.addresses, + made.wallet, + made.rulesIds[0] + ) + ).toMatchObject({ valueSpent: BigInt(1), callsUsed: 1 }); + f.evidence('extended-stale-update', { + wallet: made.wallet, + rulesId: made.rulesIds[0], + spendHash: hash, + checkpoints: before, + grantNonce, + }); + }); + it('6. index race commits no grants to the competing wallet and retry takes the next slot', async () => { + const expectedNext = await f.owner.agentic.derive(); + const rt = runtime(f.owner), + original = rt.execute; + let competing: + | Awaited> + | undefined, + injected = false; + rt.execute = async (params, opts) => { + if (!injected && Array.isArray(params.data)) { + injected = true; + competing = await f.owner.agentic.create('e2e-race-winner', { + rules: [], + }); + } + return original(params, opts); + }; + let raceError: unknown; + try { + await f.owner.agentic.create('e2e-race-loser', { rules: [rule()] }); + } catch (error) { + raceError = error; + } + try { + const afterRace = await f.owner.agentic.derive(); + const competingRules = competing + ? (await f.owner.agentic.wallet(competing.wallet).rules.list()).rules + : undefined; + f.evidence('extended-index-race-error', { + code: (raceError as { code?: string } | undefined)?.code, + expectedWallet: expectedNext.address, + expectedIndex: expectedNext.index, + competingWallet: competing?.wallet, + competingIndex: competing?.index, + nextAfterRace: afterRace, + competingRuleIds: competingRules?.map((r) => r.rulesId), + }); + expect(raceError).toMatchObject({ + code: 'INDEX_RACE', + details: { committed: false }, + }); + } finally { + rt.execute = original; + } + if (!competing) throw new Error('Race injection did not run'); + expect( + (await f.owner.agentic.wallet(competing.wallet).rules.list()).rules + ).toEqual([]); + const retried = await f.owner.agentic.create('e2e-race-retry', { + rules: [rule()], + }); + expect(retried.index).toBe(competing.index + 1); + expect(retried.wallet).not.toBe(competing.wallet); + f.evidence('extended-index-race', { + competing: competing.wallet, + winnerHash: competing.tx.hash, + retried: retried.wallet, + retryHash: retried.tx.hash, + }); + }); +}); diff --git a/packages/core/__e2e__/agw/native-batch.spec.ts b/packages/core/__e2e__/agw/native-batch.spec.ts new file mode 100644 index 000000000..5a8e06acd --- /dev/null +++ b/packages/core/__e2e__/agw/native-batch.spec.ts @@ -0,0 +1,99 @@ +/** Opt-in live cases: require the existing verified-manifest gate and native + * EIP-7702 transport. These never accept a sequential fallback as success. */ +import { getAddress, parseEther, type Hex } from 'viem'; +import { AgenticRevertError } from '../../src'; +import { readNativeCounters } from '@e2e/shared/agw-state'; +import { + AGW_E2E_ENABLED, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const SINK = getAddress('0x000000000000000000000000000000000000dEaD'); +const calls = () => + Array.from({ length: 2 }, () => ({ + to: SINK, + value: BigInt(1000), + data: '0x' as Hex, + })); + +d('agw native batch', () => { + let f: AgwFixture; + beforeAll(async () => { + f = await setupAgw(); + }, 300_000); + afterAll(() => f?.teardown()); + + const create = async (maxCalls: number) => { + const created = await f.owner.agentic.create('e2e-native-batch', { + rules: [ + { + agent: f.agentAddress, + target: SINK, + selector: 'value-only', + validUntil: inSeconds(3600), + maxValuePerCall: BigInt(1000), + maxValueTotal: BigInt(5000), + maxCalls, + }, + ], + }); + await f.fundPC(created.wallet, parseEther('0.001')); + return { ...created, agent: await f.agent(created.wallet) }; + }; + + it('1. sender-preserving atomic native batch meters both actions', async () => { + const { wallet, rulesIds, agent } = await create(3); + const before = await f.push.getBalance({ address: wallet }); + const tx = await agent.universal.sendTransaction({ + to: SINK, + data: calls(), + }); + expect((await tx.wait()).status).toBe(1); + expect(tx.agentic?.nativeCalls).toEqual(calls()); + expect(tx.from).toBe(wallet); + expect(await f.push.getBalance({ address: wallet })).toBe( + before - BigInt(2000) + ); + expect( + await readNativeCounters( + f.push, + f.manifest.addresses, + wallet, + rulesIds[0] + ) + ).toMatchObject({ callsUsed: 2, valueSpent: BigInt(2000) }); + const raw = await f.push.getTransaction({ hash: tx.hash as Hex }); + expect(raw.type).toBe('eip7702'); + expect(getAddress(raw.from)).toBe(f.agentAddress); + expect(getAddress(raw.to as Hex)).toBe(f.agentAddress); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.agentic?.nativeCalls).toEqual(calls()); + expect(replay.from).toBe(wallet); + f.evidence('native-agent-batch', { + wallet, + rulesId: rulesIds[0], + tx: tx.hash, + }); + }); + + it('2. a later policy failure rolls back earlier native actions', async () => { + const { wallet, rulesIds, agent } = await create(1); + const before = await f.push.getBalance({ address: wallet }); + await expect( + agent.universal.sendTransaction({ to: SINK, data: calls() }) + ).rejects.toBeInstanceOf(AgenticRevertError); + expect(await f.push.getBalance({ address: wallet })).toBe(before); + expect( + await readNativeCounters( + f.push, + f.manifest.addresses, + wallet, + rulesIds[0] + ) + ).toMatchObject({ callsUsed: 0, valueSpent: BigInt(0) }); + f.evidence('native-agent-batch-rollback', { wallet, rulesId: rulesIds[0] }); + }); +}); diff --git a/packages/core/__e2e__/agw/native-exec.spec.ts b/packages/core/__e2e__/agw/native-exec.spec.ts new file mode 100644 index 000000000..7217c1459 --- /dev/null +++ b/packages/core/__e2e__/agw/native-exec.spec.ts @@ -0,0 +1,101 @@ +/** + * Scenarios 3 and 4 — native owner and agent execution with independent + * state checks, and unauthorized / revoked / expired / over-cap failures with + * unchanged protected state. + */ +import { getAddress, parseEther, type Address, type Hex } from 'viem'; +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'; +import { AgenticError, AgenticRevertError } from '../../src'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { AGENTIC_ERROR_CODE } from '../../src/lib/agentic/errors'; +import { AGW_E2E_ENABLED, evmClient, inSeconds, setupAgw, type AgwFixture } from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const CAP = parseEther('0.001'); + +d('agw native', () => { + let f: AgwFixture; + let wallet: Address; + let rulesId: Hex; + const recipient = getAddress(privateKeyToAccount(generatePrivateKey()).address); + + beforeAll(async () => { + f = await setupAgw(); + const created = await f.owner.agentic.create('e2e-native', { + rules: [ + { + agent: f.agentAddress, + target: recipient, + selector: 'value-only', + validUntil: inSeconds(3600), + maxValuePerCall: CAP, + maxValueTotal: CAP * BigInt(3), + }, + ], + }); + wallet = created.wallet; + rulesId = created.rulesIds[0]; + await f.fundPC(wallet, parseEther('0.01')); + }, 300_000); + afterAll(() => f?.teardown()); + + it('1. agent door: value moves from the wallet under its rule; signer is origin, wallet is from', async () => { + const agent = await f.agent(wallet); + expect(agent.universal.account).toBe(wallet); + const before = await f.push.getBalance({ address: recipient }); + const tx = await agent.universal.sendTransaction({ to: recipient, value: CAP }); + const receipt = await tx.wait(); + expect(receipt.status).toBe(1); + expect(await f.push.getBalance({ address: recipient })).toBe(before + CAP); + expect(tx.from).toBe(wallet); + expect(receipt.from).toBe(wallet); + expect(tx.origin.toLowerCase()).toContain(f.agentAddress.toLowerCase()); + expect(tx.agentic).toMatchObject({ door: 'agent', rulesId }); + f.evidence('native-agent-send', { wallet, rulesId, tx: tx.hash, block: tx.blockNumber }); + }); + + it('2. owner door: value moves from the wallet without rules', async () => { + const owner = await evmClient(process.env['PUSH_PRIVATE_KEY'] as Hex, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet); + const before = await f.push.getBalance({ address: recipient }); + const tx = await owner.universal.sendTransaction({ to: recipient, value: CAP * BigInt(2) }); + expect((await tx.wait()).status).toBe(1); + expect(await f.push.getBalance({ address: recipient })).toBe(before + CAP * BigInt(2)); + expect(tx.agentic?.door).toBe('owner'); + }); + + it('3. over-cap value reverts with the decoded policy gate and moves nothing', async () => { + const agent = await f.agent(wallet); + const before = await f.push.getBalance({ address: recipient }); + const err = await agent.universal.sendTransaction({ to: recipient, value: CAP + BigInt(1) }).catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(err.decodedError?.name).toMatch(/^PolicyCheckReverted/); + expect(await f.push.getBalance({ address: recipient })).toBe(before); + }); + + it('4. an unrelated signer cannot initialize against the wallet', async () => { + const stranger = generatePrivateKey(); + await expect(evmClient(stranger, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet)).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.NOT_OWNER_OR_AGENT, + }); + }); + + it('5. after revocation the agent fails before signing with NO_RULES_FOR_CHAIN', async () => { + const agent = await f.agent(wallet); + await (await f.owner.agentic.wallet(wallet).rules.revoke([rulesId])).wait(); + const err = await agent.universal.sendTransaction({ to: recipient, value: BigInt(1) }).catch((e) => e); + expect(err).toBeInstanceOf(AgenticError); + expect(err.code).toBe(AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN); + }); + + it('6. an expired rule still identifies the agent; the send is refused on-chain', async () => { + const created = await f.owner.agentic.create('e2e-expiry', { + rules: [{ agent: f.agentAddress, target: recipient, selector: 'value-only', validUntil: inSeconds(45), maxValuePerCall: CAP, maxValueTotal: CAP }], + }); + await f.fundPC(created.wallet, CAP * BigInt(2)); + await new Promise((r) => setTimeout(r, 60_000)); + const agent = await f.agent(created.wallet); + const err = await agent.universal.sendTransaction({ to: recipient, value: BigInt(1) }).catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(err.decodedError?.name).toBe('PolicyCheckReverted(RulesExpired)'); + }, 300_000); +}); diff --git a/packages/core/__e2e__/agw/native-policy-extended.spec.ts b/packages/core/__e2e__/agw/native-policy-extended.spec.ts new file mode 100644 index 000000000..72c315026 --- /dev/null +++ b/packages/core/__e2e__/agw/native-policy-extended.spec.ts @@ -0,0 +1,277 @@ +/** Native policy limits, confirmed zero defaults and lossless raw-offset reuse. */ +import { PushChain, AgenticRevertError, type NativeRule } from '../../src'; +import { encodeFunctionData, erc20Abi, type Address } from 'viem'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import { readNativeCounters } from '../shared/agw-state'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { setupAgw, inSeconds, type AgwFixture } from './_fixture'; + +const d = process.env['AGW_E2E'] === '1' ? describe : describe.skip; +const chain = PushChain.CONSTANTS.CHAIN.PUSH_TESTNET_DONUT; + +d('agw extended native policy', () => { + let f: AgwFixture, token: Address; + beforeAll(async () => { + f = await setupAgw(); + token = getNativePRC20ForChain(CHAIN.ETHEREUM_SEPOLIA, f.manifest.network); + }, 300_000); + afterAll(() => f?.teardown()); + const transfer = (to: Address, amount: bigint) => + encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [to, amount], + }); + async function fixture( + balance = BigInt(50), + maxCalls = 2, + total = BigInt(15), + omitPc = false + ) { + const rule = { + agent: f.agentAddress, + target: token, + selector: 'transfer(address,uint256)' as const, + validUntil: inSeconds(3600), + ...(omitPc + ? {} + : { maxValuePerCall: BigInt(0), maxValueTotal: BigInt(0) }), + maxCalls, + pins: [{ arg: 0, expected: f.ownerAddress }], + amount: { arg: 1, maxPerCall: BigInt(10), maxTotal: total }, + }; + const made = await f.owner.agentic.create('e2e-native-meter', { + rules: [rule], + }); + if (balance) + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: transfer(made.wallet, balance), + }) + ).wait(); + const agent = await f.agent(made.wallet); + const counters = () => + readNativeCounters( + f.push, + f.manifest.addresses, + made.wallet, + made.rulesIds[0] + ); + const tokenBalance = () => + f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [made.wallet], + }); + const send = (amount: bigint, to = f.ownerAddress, value = BigInt(0)) => + agent.universal.sendTransaction({ + to: { address: token, chain }, + value, + data: transfer(to, amount), + }); + return { ...made, agent, counters, tokenBalance, send }; + } + async function refused( + s: Awaited>, + action: () => Promise, + gate?: string + ) { + const before = await s.counters(), + balance = await s.tokenBalance(); + const error = await action().then( + () => undefined, + (e: unknown) => e + ); + expect(error).toBeInstanceOf(AgenticRevertError); + if (gate) + expect((error as AgenticRevertError).decodedError?.name).toBe( + `PolicyCheckReverted(${gate})` + ); + expect(await s.counters()).toEqual(before); + expect(await s.tokenBalance()).toBe(balance); + f.evidence('extended-native-refusal', { + wallet: s.wallet, + rulesId: s.rulesIds[0], + gate: gate ?? 'callee/engine', + counters: before, + tokenBalance: balance, + }); + } + it('1. pinned recipient and metered amount decode losslessly and transfer through explicit Push chain', async () => { + const s = await fixture(); + const record = await f.owner.agentic + .wallet(s.wallet) + .rules.get(s.rulesIds[0]); + expect(record.rule).toMatchObject({ + pins: [ + { + offset: 4, + expected: `0x${f.ownerAddress + .slice(2) + .toLowerCase() + .padStart(64, '0')}`, + }, + ], + amount: { offset: 36, maxPerCall: BigInt(10), maxTotal: BigInt(15) }, + }); + const before = await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + }); + const tx = await s.send(BigInt(10)); + expect((await tx.wait()).status).toBe(1); + expect(await s.tokenBalance()).toBe(BigInt(40)); + expect(await s.counters()).toMatchObject({ + valueSpent: BigInt(0), + amountSpent: BigInt(10), + callsUsed: 1, + }); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + }) + ).toBe(before + BigInt(10)); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.from).toBe(s.wallet); + expect(replay.agentic?.rulesId).toBe(s.rulesIds[0]); + f.evidence('extended-native-meter', { + wallet: s.wallet, + rulesId: s.rulesIds[0], + txHash: tx.hash, + }); + }); + it('2. wrong recipient pin is refused with unchanged token balance and counters', async () => { + const s = await fixture(); + await refused(s, () => s.send(BigInt(1), f.agentAddress), 'ArgPinMismatch'); + }); + it('3. per-call amount cap refuses cap plus one', async () => { + const s = await fixture(); + await refused(s, () => s.send(BigInt(11)), 'NativeAmountExceedsCap'); + }); + it('4. cumulative amount accepts the exact total and refuses the next unit', async () => { + const s = await fixture(BigInt(50), 10); + await (await s.send(BigInt(10))).wait(); + await (await s.send(BigInt(5))).wait(); + expect((await s.counters()).amountSpent).toBe(BigInt(15)); + await refused(s, () => s.send(BigInt(1)), 'TotalNativeAmountExceeded'); + }); + it('5. call count accepts the exact count and refuses a zero-amount extra call', async () => { + const s = await fixture(); + await (await s.send(BigInt(1))).wait(); + await (await s.send(BigInt(1))).wait(); + expect((await s.counters()).callsUsed).toBe(2); + await refused(s, () => s.send(BigInt(0)), 'CallLimitReached'); + }); + it('6. explicit zero total forbids positive amount but permits a metered zero call', async () => { + const s = await fixture(BigInt(50), 10, BigInt(0)); + await refused(s, () => s.send(BigInt(1)), 'TotalNativeAmountExceeded'); + await (await s.send(BigInt(0))).wait(); + expect(await s.counters()).toMatchObject({ + amountSpent: BigInt(0), + callsUsed: 1, + }); + }); + it('7. failed token callee leaves all policy counters unchanged', async () => { + const s = await fixture(BigInt(5)); + await refused(s, () => s.send(BigInt(10))); + }); + it('8. an ungranted selector is refused without changing balances or allowance', async () => { + const s = await fixture(); + const before = await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [s.wallet, f.agentAddress], + }); + await refused(s, () => + s.agent.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [f.agentAddress, BigInt(1)], + }), + }) + ); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [s.wallet, f.agentAddress], + }) + ).toBe(before); + }); + it('9. omitted PC limits forbid value while still allowing a token transfer', async () => { + const s = await fixture(BigInt(5), 3, BigInt(15), true); + await f.fundPC(s.wallet, BigInt(10)); + const record = await f.owner.agentic + .wallet(s.wallet) + .rules.get(s.rulesIds[0]); + expect(record.rule).toMatchObject({ + maxValuePerCall: BigInt(0), + maxValueTotal: BigInt(0), + }); + await refused(s, () => s.send(BigInt(1), f.ownerAddress, BigInt(1)), 'ValueExceedsCap'); + const sent = await s.send(BigInt(1)); + expect((await sent.wait()).status).toBe(1); + expect(await s.counters()).toMatchObject({ + valueSpent: BigInt(0), + amountSpent: BigInt(1), + }); + f.evidence('zero-pc-defaults', { wallet: s.wallet, txHash: sent.hash }); + }); + it('10. decoded raw-offset rules can be granted again without fabricated ref metadata', async () => { + const s = await fixture(BigInt(0)); + const original = await f.owner.agentic + .wallet(s.wallet) + .rules.get(s.rulesIds[0]); + expect(original).not.toHaveProperty('ref'); + const made = await f.owner.agentic.create('raw-offset-reuse', { + rules: [original.rule as NativeRule], + }); + const reread = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + expect(reread.rule).toEqual(original.rule); + expect(reread).not.toHaveProperty('ref'); + f.evidence('raw-offset-reuse', { + wallet: made.wallet, + txHash: made.tx.hash, + }); + }); + it('11. removed ref and conflicting argument-offset inputs fail before signing', async () => { + const nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); + const rule = { + agent: f.agentAddress, + target: token, + selector: 'transfer(address,uint256)', + validUntil: inSeconds(3600), + }; + await expect( + f.owner.agentic.create('invalid-ref', { + rules: [{ ...rule, ref: `0x${'11'.repeat(32)}` } as never], + }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + await expect( + f.owner.agentic.create('invalid-pin', { + rules: [ + { + ...rule, + pins: [{ arg: 0, offset: 4, expected: `0x${'00'.repeat(32)}` }], + } as never, + ], + }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( + nonce + ); + }); +}); diff --git a/packages/core/__e2e__/agw/responses-hooks.spec.ts b/packages/core/__e2e__/agw/responses-hooks.spec.ts new file mode 100644 index 000000000..b026a005a --- /dev/null +++ b/packages/core/__e2e__/agw/responses-hooks.spec.ts @@ -0,0 +1,51 @@ +/** + * Scenario 10 — init-time and per-call hooks through wait without + * duplicates, and AGW `from` / signer `origin` on live and replayed + * responses. + */ +import { getAddress, parseEther, type Address } from 'viem'; +import type { ProgressEvent } from '../../src/lib/progress-hook/progress-hook.types'; +import { AGW_E2E_ENABLED, inSeconds, setupAgw, type AgwFixture } from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const SINK = getAddress('0x000000000000000000000000000000000000dEaD'); + +d('agw responses', () => { + let f: AgwFixture; + let wallet: Address; + beforeAll(async () => { + f = await setupAgw(); + const created = await f.owner.agentic.create('e2e-hooks', { + rules: [{ agent: f.agentAddress, target: SINK, selector: 'value-only', validUntil: inSeconds(3600), maxValuePerCall: BigInt(10), maxValueTotal: BigInt(100) }], + }); + wallet = created.wallet; + await f.fundPC(wallet, parseEther('0.001')); + }, 300_000); + afterAll(() => f?.teardown()); + + it('1. init and per-call hooks each see every event once, through wait', async () => { + const init: ProgressEvent[] = []; + const perCall: ProgressEvent[] = []; + const agent = await f.agent(wallet, (e) => init.push(e as ProgressEvent)); + const tx = await agent.universal.sendTransaction({ to: SINK, value: BigInt(1) }, { progressHook: (e) => perCall.push(e) }); + await tx.wait(); + const ids = (xs: ProgressEvent[]) => xs.map((e) => e.id); + expect(ids(perCall)).toEqual(ids(init)); + expect(new Set(ids(perCall).filter((i) => i.startsWith('AGENTIC-TX'))).size).toBe(ids(perCall).filter((i) => i.startsWith('AGENTIC-TX')).length); + expect(ids(perCall)).toEqual(expect.arrayContaining(['AGENTIC-TX-107', 'AGENTIC-TX-199-01', 'SEND-TX-199-01'])); + expect(ids(perCall)).not.toContain('AGENTIC-TX-103'); + }); + + it('2. live and replayed responses agree on wallet from and signer origin', async () => { + const agent = await f.agent(wallet); + const tx = await agent.universal.sendTransaction({ to: SINK, value: BigInt(1) }); + await tx.wait(); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.from).toBe(tx.from); + expect(replay.from).toBe(wallet); + expect(replay.origin).toBe(tx.origin); + expect(replay.to).toBe(SINK); + expect((await replay.wait()).from).toBe(wallet); + f.evidence('replay-identity', { wallet, tx: tx.hash }); + }); +}); diff --git a/packages/core/__e2e__/agw/svm-public.spec.ts b/packages/core/__e2e__/agw/svm-public.spec.ts new file mode 100644 index 000000000..79ff90896 --- /dev/null +++ b/packages/core/__e2e__/agw/svm-public.spec.ts @@ -0,0 +1,445 @@ +/** Public named-IDL lifecycle and destination delivery on Donut/Solana devnet. */ +import '@e2e/shared/setup'; +import { PushChain, type SolanaRule } from '../../src'; +import type { Idl } from '@coral-xyz/anchor'; +import { PublicKey, Keypair } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { + createPublicClient, + http, + erc20Abi, + encodeFunctionData, + parseAbi, + parseEther, + zeroAddress, + type Address, + type Hex, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; +import counterIdl from '../../src/lib/orchestrator/svm-idl/__fixtures__/test_counter.idl.json'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { setupAgw, evmClient, inSeconds, type AgwFixture } from './_fixture'; +import { loadAgwManifest, verifyAgwManifest } from './_manifest'; +import { + inspectSvmWirePrograms, + svmWireConnection, + SVM_WIRE_CHAIN, + SVM_WIRE_PROGRAM, + SVM_WIRE_COUNTER, +} from '../shared/agw-svm-preflight'; + +const d = process.env['AGW_E2E'] === '1' ? describe : describe.skip; +const amount = BigInt(10_000), + pc = parseEther('41'); +d('agw public svm', () => { + let f: AgwFixture, + wallet: Address, + id: Hex, + token: Address, + agent: PushChain, + recipient: PublicKey, + cea: PublicKey, + idl: Idl; + const sol = svmWireConnection(); + const rule = (): SolanaRule => ({ + agent: f.agentAddress, + chainNamespace: SVM_WIRE_CHAIN, + validUntil: inSeconds(3600), + assets: [{ token: zeroAddress, maxPerCall: amount, maxTotal: amount }], + maxGasPerCall: parseEther('20'), + allowedInstructions: [ + { + program: SVM_WIRE_PROGRAM, + instruction: { idl, name: 'receive_sol' }, + accounts: [ + { + name: 'counter', + expected: { kind: 'address', address: SVM_WIRE_COUNTER.toBase58() }, + }, + { + name: 'recipient', + expected: { kind: 'address', address: recipient.toBase58() }, + }, + { name: 'cea_authority', expected: { kind: 'walletCEA' } }, + { + name: 'system_program', + expected: { + kind: 'address', + address: PublicKey.default.toBase58(), + }, + }, + ], + fields: [{ name: 'amount', max: amount }], + }, + ], + }); + const data = (value = amount, source = idl) => + PushChain.utils.helpers.encodeTxData({ + abi: source, + functionName: 'receive_sol', + args: [value], + }); + beforeAll(async () => { + await inspectSvmWirePrograms(sol); + const manifest = loadAgwManifest(); + if (manifest.network !== PUSH_NETWORK.TESTNET_DONUT) + throw new Error('Public SVM fixture requires Donut'); + if ( + parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5') > parseEther('0.5') + ) + throw new Error('Agent funding exceeds fixture budget'); + const key = process.env['SOLANA_PRIVATE_KEY']; + if (!key) throw new Error('Missing recipient identity'); + try { + recipient = Keypair.fromSecretKey(bs58.decode(key)).publicKey; + } catch { + recipient = Keypair.fromSecretKey( + Uint8Array.from(JSON.parse(key)) + ).publicKey; + } + const recipientInfo = await sol.getAccountInfo(recipient); + if ( + !recipientInfo || + recipientInfo.executable || + !recipientInfo.owner.equals(PublicKey.default) + ) + throw new Error('Owned recipient must be an existing System account'); + const push = createPublicClient({ + chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), + transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]), + }); + await verifyAgwManifest(push, manifest); + const ownerKey = process.env['PUSH_PRIVATE_KEY'] as Hex; + if (!ownerKey) throw new Error('Missing owner'); + const ownerAddress = privateKeyToAccount(ownerKey).address; + token = await push.readContract({ + address: '0x00000000000000000000000000000000000000C0', + abi: parseAbi([ + 'function gasTokenPRC20ByChainNamespace(string) view returns (address)', + ]), + functionName: 'gasTokenPRC20ByChainNamespace', + args: [SVM_WIRE_CHAIN], + }); + if ( + (await push.getBalance({ address: ownerAddress })) < + pc + parseEther('2') || + (await push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [ownerAddress], + })) < + amount * BigInt(2) + BigInt(1) + ) + throw new Error('Insufficient bounded fixture funds'); + idl = JSON.parse(JSON.stringify(counterIdl)) as Idl; + const receive = idl.instructions.find((i) => i.name === 'receive_sol')!; + const destination = receive.accounts.find((a) => a.name === 'recipient')!; + Object.assign(destination, { address: recipient.toBase58() }); + f = await setupAgw(); + const made = await f.owner.agentic.create('public-idl-svm', { + rules: [rule()], + }); + wallet = made.wallet; + id = made.rulesIds[0]; + const record = await f.owner.agentic.wallet(wallet).rules.get(id); + if (!('expectedCEA' in record.rule)) + throw new Error('Missing decoded destination account'); + cea = new PublicKey(Buffer.from(record.rule.expectedCEA.slice(2), 'hex')); + await f.fundPC(wallet, pc); + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [wallet, amount * BigInt(2)], + }), + }) + ).wait(); + const owner = await evmClient( + ownerKey, + CHAIN.PUSH_TESTNET_DONUT, + manifest.network, + wallet + ); + await ( + await owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [manifest.addresses.gateway, amount * BigInt(2)], + }), + }) + ).wait(); + agent = await f.agent(wallet); + f.evidence('public-svm-setup', { + wallet, + id, + grantHash: made.tx.hash, + cea: cea.toBase58(), + recipient: recipient.toBase58(), + }); + }, 900_000); + afterAll(() => f?.teardown()); + it('1. named IDL grant reads exact constraints and replaces atomically', async () => { + const handle = f.owner.agentic.wallet(wallet), + record = await handle.rules.get(id); + expect(record).not.toHaveProperty('ref'); + expect(record.rule).toMatchObject({ + format: 'decoded', + dataPins: [{ offset: 8, len: 8, mode: 2 }], + }); + const count = (await handle.checkpoints()).checkpoints.length; + const updated = await handle.rules.update({ + rules: [{ rulesId: id, rule: rule() }], + }); + const previous = id; + id = updated.rules[0].rulesId; + await expect(handle.rules.get(previous)).rejects.toMatchObject({ + code: 'RULE_NOT_FOUND', + }); + expect((await handle.checkpoints()).checkpoints.length).toBe(count + 5); + f.evidence('public-svm-replacement', { + wallet, + id, + txHash: updated.tx.hash, + }); + }); + it('2. public send confirms actual CEA transfer and preserves replay identity', async () => { + const tx = await agent.universal.sendTransaction({ + to: { chain: SVM_WIRE_CHAIN, address: SVM_WIRE_PROGRAM }, + value: amount, + gasLimit: BigInt(0), + data: data(), + }); + f.evidence('public-svm-submitted', { wallet, id, hash: tx.hash }); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.externalStatus).toBe('success'); + expect(receipt.from).toBe(wallet); + const destination = await sol.getParsedTransaction( + receipt.externalTxHash!, + { commitment: 'confirmed', maxSupportedTransactionVersion: 0 } + ); + expect(destination?.meta?.err).toBeNull(); + expect( + destination?.meta?.innerInstructions?.some((g) => + g.instructions.some( + (ix) => + 'parsed' in ix && + ix.program === 'system' && + ix.parsed.type === 'transfer' && + ix.parsed.info.source === cea.toBase58() && + ix.parsed.info.destination === recipient.toBase58() && + BigInt(ix.parsed.info.lamports) === amount + ) + ) + ).toBe(true); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.from).toBe(wallet); + expect(replay.agentic?.destinationInstruction).toEqual( + tx.agentic?.destinationInstruction + ); + expect( + (await replay.wait({ outboundTimeoutMs: 600_000 })).externalTxHash + ).toBe(receipt.externalTxHash); + f.evidence('public-svm-delivered', { + wallet, + hash: tx.hash, + destinationHash: receipt.externalTxHash, + instruction: tx.agentic?.destinationInstruction, + }); + }, 900_000); + it('3. contract simulation maps account and data refusals without state changes', async () => { + const config = PushChain.utils.agentic.configId( + wallet, + id, + PushChain.utils.agentic.actionId( + f.manifest.addresses.gateway, + '0x77b86bec' + ) + ); + const state = async () => { + const [tokens, pcBalance, checkpoints, cfg] = await Promise.all([ + f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [wallet], + }), + f.push.getBalance({ address: wallet }), + f.push.readContract({ + address: wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }), + f.push.readContract({ + address: f.manifest.addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getSvmConfig', + args: [config, wallet], + }), + ]); + return { + tokens, + pcBalance, + checkpoints, + spent: cfg.assets.map((a) => a.spent), + }; + }; + const before = await state(); + const nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + const wrong = JSON.parse(JSON.stringify(idl)) as Idl; + Object.assign( + wrong.instructions + .find((i) => i.name === 'receive_sol')! + .accounts.find((a) => a.name === 'recipient')!, + { address: PublicKey.default.toBase58() } + ); + await expect( + agent.universal.sendTransaction({ + to: { chain: SVM_WIRE_CHAIN, address: SVM_WIRE_PROGRAM }, + gasLimit: BigInt(0), + data: data(BigInt(1), wrong), + }) + ).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmAccountPinMismatch)' }, + }); + await expect( + agent.universal.sendTransaction({ + to: { chain: SVM_WIRE_CHAIN, address: SVM_WIRE_PROGRAM }, + gasLimit: BigInt(0), + data: data(amount + BigInt(1)), + }) + ).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmDataCeilingExceeded)' }, + }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + nonce + ); + expect(await state()).toEqual(before); + f.evidence('public-svm-contract-refusals', { + wallet, + id, + agentNonce: nonce, + state: before, + }); + }); + it('4. public revoke removes the Solana rule and subsequent sends are refused', async () => { + const handle = f.owner.agentic.wallet(wallet); + await (await handle.rules.revoke([id])).wait(); + expect((await handle.rules.list()).rules).toHaveLength(0); + await expect( + agent.universal.sendTransaction({ + to: { chain: SVM_WIRE_CHAIN, address: SVM_WIRE_PROGRAM }, + data: data(), + }) + ).rejects.toMatchObject({ code: 'NO_RULES_FOR_CHAIN' }); + }); + it('5. owner funds-only transfer reaches the recipient and replays with the same amount', async () => { + const ownerKey = process.env['PUSH_PRIVATE_KEY'] as Hex; + const sender = await evmClient( + ownerKey, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); + const before = await sol.getBalance(recipient); + const balance = await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [wallet], + }); + const tx = await sender.universal.sendTransaction({ + to: { chain: SVM_WIRE_CHAIN, address: recipient.toBase58() }, + value: amount, + gasLimit: BigInt(0), + }); + f.evidence('public-svm-owner-transfer-submitted', { + wallet, + hash: tx.hash, + }); + expect(tx.agentic?.door).toBe('owner'); + expect(tx.agentic?.rulesId).toBeUndefined(); + expect(tx.agentic?.destinationInstruction).toBeUndefined(); + expect(tx.agentic?.destinationTransfer).toMatchObject({ token, amount }); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.externalStatus).toBe('success'); + expect(receipt.from).toBe(wallet); + expect(await sol.getBalance(recipient)).toBe(before + Number(amount)); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [wallet], + }) + ).toBe(balance - amount); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.to).toBe(tx.to); + expect(replay.data).toBe('0x'); + expect(replay.value).toBe(amount); + expect(replay.agentic?.destinationTransfer).toEqual( + tx.agentic?.destinationTransfer + ); + expect( + (await replay.wait({ outboundTimeoutMs: 600_000 })).externalTxHash + ).toBe(receipt.externalTxHash); + f.evidence('public-svm-owner-transfer-delivered', { + wallet, + hash: tx.hash, + destinationHash: receipt.externalTxHash, + recipient: recipient.toBase58(), + amount, + }); + }, 900_000); + it('6. agents still require an instruction and owner allowance failures do not sign', async () => { + const agentNonce = await f.push.getTransactionCount({ + address: f.agentAddress, + }); + await expect( + agent.universal.sendTransaction({ + to: { chain: SVM_WIRE_CHAIN, address: recipient.toBase58() }, + value: amount, + }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + agentNonce + ); + // Both funded sends have consumed the bounded allowance. Funding only the + // token lets this case isolate the separate allowance guard. + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [wallet, BigInt(1)], + }), + }) + ).wait(); + const sender = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); + const nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); + await expect( + sender.universal.sendTransaction({ + to: { chain: SVM_WIRE_CHAIN, address: recipient.toBase58() }, + value: BigInt(1), + gasLimit: BigInt(0), + }) + ).rejects.toMatchObject({ code: 'GATEWAY_ALLOWANCE_INSUFFICIENT' }); + expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( + nonce + ); + }); +}); diff --git a/packages/core/__e2e__/agw/svm-spl-transfer.spec.ts b/packages/core/__e2e__/agw/svm-spl-transfer.spec.ts new file mode 100644 index 000000000..e35a1110e --- /dev/null +++ b/packages/core/__e2e__/agw/svm-spl-transfer.spec.ts @@ -0,0 +1,224 @@ +/** Funds-only SPL delivery through the public AGW owner path on Donut/devnet. */ +import '@e2e/shared/setup'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { encodeFunctionData, erc20Abi, parseEther, type Hex } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; +import { getPRC20Address } from '../../src/lib/universal/prc20-address'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { + AGW_E2E_ENABLED, + evmClient, + setupAgw, + type AgwFixture, +} from './_fixture'; +import { loadAgwManifest } from './_manifest'; +import { + inspectSvmWirePrograms, + svmWireConnection, +} from '@e2e/shared/agw-svm-preflight'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const TOKEN_PROGRAM = new PublicKey( + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +); +const ATA_PROGRAM = new PublicKey( + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' +); +const amount = BigInt(1000); // 0.001 USDT; fixture funding is bounded to this. + +d('agw svm spl transfer', () => { + let f: AgwFixture; + beforeAll(async () => { + if (loadAgwManifest().network !== PUSH_NETWORK.TESTNET_DONUT) + throw new Error('SPL fixture requires Donut'); + await inspectSvmWirePrograms(svmWireConnection()); + f = await setupAgw(); + }, 300_000); + afterAll(() => f?.teardown()); + + it('1. owner funds-only SPL delivery credits the recipient ATA and preserves burn, allowance and replay', async () => { + const sol = svmWireConnection(), + raw = process.env['SOLANA_PRIVATE_KEY']; + if (!raw) throw new Error('SOLANA_PRIVATE_KEY is required'); + let kp: Keypair; + try { + kp = Keypair.fromSecretKey(bs58.decode(raw)); + } catch { + kp = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw))); + } + const recipient = kp.publicKey, + asset = MOVEABLE_TOKEN_CONSTANTS.SOLANA_DEVNET.USDT; + const mint = new PublicKey(asset.address); + const mintInfo = await sol.getAccountInfo(mint); + if (!mintInfo?.owner.equals(TOKEN_PROGRAM)) + throw new Error('Fixture mint is not a standard SPL mint'); + const ata = PublicKey.findProgramAddressSync( + [recipient.toBuffer(), TOKEN_PROGRAM.toBuffer(), mint.toBuffer()], + ATA_PROGRAM + )[0]; + const recipientBalance = async () => { + const info = await sol.getAccountInfo(ata); + if (!info) return BigInt(0); + if (!info.owner.equals(TOKEN_PROGRAM)) + throw new Error('Recipient ATA has unexpected owner'); + return BigInt((await sol.getTokenAccountBalance(ata)).value.amount); + }; + const token = getPRC20Address( + { chain: CHAIN.SOLANA_DEVNET, address: asset.address }, + { network: f.manifest.network } + ).address; + if ( + (await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + })) < amount + ) + throw new Error('Insufficient mapped SPL fixture funds'); + if ( + (await f.push.getBalance({ address: f.ownerAddress })) < parseEther('22') + ) + throw new Error('Insufficient bounded PC fixture funds'); + const made = await f.owner.agentic.create('v5-spl-owner-transfer', { + rules: [], + }); + await f.fundPC(made.wallet, parseEther('21')); + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [made.wallet, amount], + }), + }) + ).wait(); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + made.wallet + ); + await ( + await owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [f.manifest.addresses.gateway, amount], + }), + }) + ).wait(); + const beforeRecipient = await recipientBalance(); + const beforeCheckpoints = await f.push.readContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + const tx = await owner.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: recipient.toBase58() }, + funds: { token: asset, amount }, + gasLimit: BigInt(0), + }); + expect(tx.value).toBe(BigInt(0)); + expect(tx.agentic).toMatchObject({ + door: 'owner', + destinationTransfer: { + recipient: `0x${recipient.toBuffer().toString('hex')}`, + token, + amount, + }, + }); + expect(tx.agentic?.destinationInstruction).toBeUndefined(); + f.evidence('v5-spl-owner-submitted', { + wallet: made.wallet, + hash: tx.hash, + mint: mint.toBase58(), + recipient: recipient.toBase58(), + ata: ata.toBase58(), + amount, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.externalStatus).toBe('success'); + expect(receipt.from).toBe(made.wallet); + expect(await recipientBalance()).toBe(beforeRecipient + amount); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [made.wallet], + }) + ).toBe(BigInt(0)); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [made.wallet, f.manifest.addresses.gateway], + }) + ).toBe(BigInt(0)); + expect( + await f.push.readContract({ + address: made.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(beforeCheckpoints + BigInt(1)); + let external = await sol.getParsedTransaction(receipt.externalTxHash!, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + for (let retry = 0; !external && retry < 5; retry++) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + external = await sol.getParsedTransaction(receipt.externalTxHash!, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + } + expect(external?.meta?.err).toBeNull(); + const accountIndex = external?.transaction.message.accountKeys.findIndex( + (entry) => entry.pubkey.equals(ata) + ); + expect(accountIndex).toBeGreaterThanOrEqual(0); + const prior = external?.meta?.preTokenBalances?.find( + (balance) => + balance.accountIndex === accountIndex && + balance.mint === mint.toBase58() + ); + const credited = external?.meta?.postTokenBalances?.find( + (balance) => + balance.accountIndex === accountIndex && + balance.mint === mint.toBase58() + ); + expect(credited?.owner).toBe(recipient.toBase58()); + expect( + BigInt(credited?.uiTokenAmount.amount ?? '0') - + BigInt(prior?.uiTokenAmount.amount ?? '0') + ).toBe(amount); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.from).toBe(made.wallet); + expect(replay.to).toBe(tx.to); + expect(replay.value).toBe(BigInt(0)); + expect(replay.agentic?.destinationTransfer).toEqual( + tx.agentic?.destinationTransfer + ); + expect( + (await replay.wait({ outboundTimeoutMs: 600_000 })).externalTxHash + ).toBe(receipt.externalTxHash); + f.evidence('v5-spl-owner-delivered', { + wallet: made.wallet, + hash: tx.hash, + destinationHash: receipt.externalTxHash, + mint: mint.toBase58(), + recipient: recipient.toBase58(), + ata: ata.toBase58(), + beforeRecipient, + afterRecipient: await recipientBalance(), + amount, + }); + }, 900_000); +}); diff --git a/packages/core/__e2e__/agw/universal-evm.spec.ts b/packages/core/__e2e__/agw/universal-evm.spec.ts new file mode 100644 index 000000000..a617a1788 --- /dev/null +++ b/packages/core/__e2e__/agw/universal-evm.spec.ts @@ -0,0 +1,244 @@ +/** + * Scenarios 7 and 8 — positive-amount EVM universal execution from the + * wallet, and destination failure classification. + * + * Rules are created through the public v5 UniversalRule SDK path. Destination + * receipt/replay checks remain opt-in funded live tests. + * + * creditRevert accounting is inert on the current executor (gap G07): a + * destination revert after URP passed still counts the spend. + */ +import { + createPublicClient, + encodeFunctionData, + erc20Abi, + fallback, + http, + parseAbi, + parseEventLogs, + parseEther, + type Address, + type Hex, +} from 'viem'; +import { sepolia } from 'viem/chains'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { CHAIN_INFO } from '../../src/lib/constants/chain'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; +import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { EVM_CHAIN_FIXTURES } from '@e2e/shared/chain-fixtures'; +import { COUNTER_ABI } from '@e2e/shared/outbound-helpers'; +import { verifyExternalTransaction } from '@e2e/shared/external-tx-verifier'; +import { + AGW_E2E_ENABLED, + evmClient, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const SEPOLIA = CHAIN.ETHEREUM_SEPOLIA; +const counter = EVM_CHAIN_FIXTURES.find((x) => x.chain === SEPOLIA)!.contracts + .counter as Address; +const INCREMENT = '0xd09de08a' as Hex; +const MISSING = '0xdeadbeef' as Hex; // not implemented by the counter → destination revert +const AMOUNT = BigInt(10) ** BigInt(12); // 0.000001 pETH +// Fresh CEA finalization currently needs ~1.3m gas on Sepolia; the core +// default of 500k was independently shown to revert. Keep a bounded explicit +// destination budget and preserve that default-budget evidence separately. +const DESTINATION_GAS = BigInt(2_000_000); +const MAX_PC = parseEther(process.env['AGW_E2E_MAX_PC_PER_CALL'] ?? '20'); + +d('agw universal evm', () => { + let f: AgwFixture; + let wallet: Address; + let token: Address; + let rulesId: Hex; + let cea: Address; + + beforeAll(async () => { + f = await setupAgw(); + const created = await f.owner.agentic.create('e2e-universal', { + rules: [ + { + agent: f.agentAddress, + chainNamespace: SEPOLIA, + assets: [ + { + token: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH, + maxPerCall: AMOUNT * BigInt(2), + maxTotal: AMOUNT * BigInt(10), + }, + ], + validUntil: inSeconds(3600), + maxGasPerCall: MAX_PC, + allowedCalls: [ + { target: counter, selector: INCREMENT }, + { target: counter, selector: MISSING }, + ], + }, + ], + }); + wallet = created.wallet; + rulesId = created.rulesIds[0]; + token = getNativePRC20ForChain(SEPOLIA, f.manifest.network); + cea = (await getCEAAddress(wallet, SEPOLIA)).cea as Address; + // Separate funding: wallet PC for outbound fees and pETH to burn. + await f.fundPC(wallet, MAX_PC + parseEther('1')); + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [wallet, AMOUNT * BigInt(3)], + }), + }) + ).wait(); + // Bounded allowance through the owner door. + const ownerAgw = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); + await ( + await ownerAgw.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [f.manifest.addresses.gateway, AMOUNT * BigInt(2)], + }), + }) + ).wait(); + }, 900_000); + afterAll(() => f?.teardown()); + + it('1. positive-amount outbound: allowance pull/burn, the wallet CEA executes on Sepolia, receipts and replay identity', async () => { + const sep = createPublicClient({ + chain: sepolia, + transport: fallback( + CHAIN_INFO[SEPOLIA].defaultRPC.map((url) => http(url)) + ), + }); + const countBefore = (await sep.readContract({ + address: counter, + abi: COUNTER_ABI, + functionName: 'count', + })) as bigint; + const balBefore = await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [wallet], + }); + const agent = await f.agent(wallet); + const ceaBefore = await sep.getBalance({ address: cea }); + // Explicit call array: the bridged ETH lands in the wallet's CEA and the + // allow-listed call runs with zero value. A single `data` follows ordinary + // Route 2 semantics and forwards the bridged value to the target. + const tx = await agent.universal.sendTransaction({ + to: { address: counter, chain: SEPOLIA }, + data: [{ to: counter, value: BigInt(0), data: INCREMENT }], + gasLimit: DESTINATION_GAS, + funds: { + amount: AMOUNT, + token: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH, + }, + }); + expect(tx).toMatchObject({ from: wallet, route: 'UOA_TO_CEA' }); + expect(tx.agentic).toMatchObject({ + door: 'agent', + rulesId, + destinationAccount: cea, + }); + f.evidence('v5-evm-outbound-submitted', { + wallet, + cea, + pushTx: tx.hash, + destinationGasLimit: DESTINATION_GAS, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.status).toBe(1); + expect(receipt.from).toBe(wallet); + expect(receipt.externalStatus).toBe('success'); + expect(receipt.externalTxHash).toBeTruthy(); + await verifyExternalTransaction(receipt.externalTxHash as string, SEPOLIA); + const destinationReceipt = await sep.getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + // Attribute the call to this wallet's CEA, independently of a shared + // counter increase. The event is emitted by CEA for each executed step. + const executed = parseEventLogs({ + abi: parseAbi([ + 'event UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data)', + ]), + logs: destinationReceipt.logs, + }); + expect( + executed.some( + (event) => + event.address.toLowerCase() === cea.toLowerCase() && + event.args.originCaller.toLowerCase() === wallet.toLowerCase() && + event.args.target.toLowerCase() === counter.toLowerCase() && + event.args.data === INCREMENT + ) + ).toBe(true); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [wallet], + }) + ).toBe(balBefore - AMOUNT); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [wallet, f.manifest.addresses.gateway], + }) + ).toBe(AMOUNT); + expect( + (await sep.readContract({ + address: counter, + abi: COUNTER_ABI, + functionName: 'count', + })) as bigint + ).toBeGreaterThan(countBefore); + // Independent destination check: the wallet's own CEA received the bridged amount. + expect(await sep.getBalance({ address: cea })).toBe(ceaBefore + AMOUNT); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.from).toBe(wallet); + expect(replay.agentic).toMatchObject({ door: 'agent', rulesId }); + f.evidence('universal-outbound', { + wallet, + cea, + rulesId, + pushTx: tx.hash, + externalTx: receipt.externalTxHash, + amount: AMOUNT, + }); + }, 900_000); + + it('2. destination revert is classified on the receipt; the Push leg succeeded', async () => { + const agent = await f.agent(wallet); + const tx = await agent.universal.sendTransaction({ + to: { address: counter, chain: SEPOLIA }, + data: MISSING, + gasLimit: DESTINATION_GAS, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.status).toBe(1); + expect(receipt.externalStatus).toMatch(/fail|revert/i); + f.evidence('universal-destination-revert', { + wallet, + pushTx: tx.hash, + externalStatus: receipt.externalStatus, + }); + }, 900_000); +}); diff --git a/packages/core/__e2e__/agw/v5-multiasset.spec.ts b/packages/core/__e2e__/agw/v5-multiasset.spec.ts new file mode 100644 index 000000000..8c3a3cb42 --- /dev/null +++ b/packages/core/__e2e__/agw/v5-multiasset.spec.ts @@ -0,0 +1,344 @@ +/** Opt-in funded Donut acceptance. Nothing in this file runs in unit/local validation. */ +import type { UniversalRule } from '../../src'; +import { + createPublicClient, + encodeFunctionData, + erc20Abi, + fallback, + http, + parseAbi, + parseEventLogs, + parseEther, + type Address, + type Hex, +} from 'viem'; +import { sepolia } from 'viem/chains'; +import { CHAIN_INFO } from '../../src/lib/constants/chain'; +import { getPRC20Address } from '../../src/lib/universal/prc20-address'; +import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; +import { configId } from '../../src/lib/agentic/codec/ids'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { EVM_CHAIN_FIXTURES } from '@e2e/shared/chain-fixtures'; +import { verifyExternalTransaction } from '@e2e/shared/external-tx-verifier'; +import { + AGW_E2E_ENABLED, + evmClient, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const chain = CHAIN.ETHEREUM_SEPOLIA; +const counter = EVM_CHAIN_FIXTURES.find((x) => x.chain === chain)!.contracts + .counter as `0x${string}`; + +d('agw v5 multi-asset', () => { + let f: AgwFixture; + beforeAll(async () => { + f = await setupAgw(); + }); + afterAll(() => f?.teardown()); + it('1. public create reads and replaces an ordered two-token rule', async () => { + const rule: UniversalRule = { + agent: f.agentAddress, + chainNamespace: chain, + validUntil: inSeconds(3600), + maxGasPerCall: parseEther('20'), + assets: [ + { + token: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH, + maxPerCall: BigInt(1000), + }, + { + token: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.USDC, + maxPerCall: BigInt(10), + maxTotal: BigInt(100), + }, + ], + allowedCalls: [{ target: counter, selector: 'increment()' as const }], + }; + const created = await f.owner.agentic.create('v5-two-token', { + rules: [rule], + }); + const w = f.owner.agentic.wallet(created.wallet); + const before = await w.rules.get(created.rulesIds[0]); + expect(before.rule).toMatchObject({ + assets: [ + { + maxPerCall: BigInt(1000), + maxTotal: BigInt(2) ** BigInt(256) - BigInt(1), + }, + { maxPerCall: BigInt(10), maxTotal: BigInt(100) }, + ], + }); + expect(before).not.toHaveProperty('spent'); + const count = await f.push.readContract({ + address: created.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }); + const replacement = await w.rules.update({ + rules: [{ rulesId: created.rulesIds[0], rule }], + }); + expect( + await f.push.readContract({ + address: created.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(count + BigInt(5)); + expect((await w.rules.list()).rules.map((x) => x.rulesId)).toEqual([ + replacement.rules[0].rulesId, + ]); + f.evidence('v5-two-token-replacement', { + wallet: created.wallet, + oldId: created.rulesIds[0], + newId: replacement.rules[0].rulesId, + txHash: replacement.tx.hash, + }); + }); + it('2. empty user assets routes a call-only outbound with zero token spend', async () => { + const created = await f.owner.agentic.create('v5-call-only', { + rules: [ + { + agent: f.agentAddress, + chainNamespace: chain, + assets: [], + validUntil: inSeconds(3600), + maxGasPerCall: parseEther('20'), + allowedCalls: [{ target: counter, selector: 'increment()' }], + }, + ], + }); + const record = await f.owner.agentic + .wallet(created.wallet) + .rules.get(created.rulesIds[0]); + expect(record.rule).toMatchObject({ + assets: [{ maxPerCall: BigInt(0), maxTotal: BigInt(0) }], + }); + await f.fundPC(created.wallet, parseEther('21')); + const agent = await f.agent(created.wallet); + const tx = await agent.universal.sendTransaction({ + to: { address: counter, chain }, + data: '0xd09de08a', + gasLimit: BigInt(2_000_000), + }); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.status).toBe(1); + expect(receipt.externalTxHash).toBeTruthy(); + await verifyExternalTransaction(receipt.externalTxHash as string, chain); + const [action] = await f.push.readContract({ + address: f.manifest.addresses.sessionEngine, + abi: v5.abis.engine, + functionName: 'getEnabledActions', + args: [created.wallet, created.rulesIds[0]], + }); + const { configId } = await import('../../src/lib/agentic/codec/ids'); + const cfg = await f.push.readContract({ + address: f.manifest.addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getConfig', + args: [ + configId(created.wallet, created.rulesIds[0], action), + created.wallet, + ], + }); + expect(cfg.assets.map((x) => x.spent)).toEqual([BigInt(0)]); + f.evidence('v5-call-only', { + wallet: created.wallet, + pushTx: tx.hash, + externalTx: receipt.externalTxHash, + }); + }, 900_000); + it('3. both assets execute independently after one token budget is exhausted', async () => { + const eth = MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH; + const usdc = MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.USDC; + const amounts = [BigInt(10) ** BigInt(12), BigInt(10)]; + const tokens = [eth, usdc].map( + (token) => + getPRC20Address( + { chain, address: token.address }, + { network: f.manifest.network } + ).address + ); + // Keep an extra token unit and allowance available so total-limit failures + // reach URP instead of being masked by the SDK balance/allowance checks. + for (let i = 0; i < tokens.length; i++) { + const available = await f.push.readContract({ + address: tokens[i], + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + }); + if (available < amounts[i] + BigInt(1)) + throw new Error('Insufficient master funds for two-asset fixture'); + } + const made = await f.owner.agentic.create('v5-independent-budgets', { + rules: [ + { + agent: f.agentAddress, + chainNamespace: chain, + validUntil: inSeconds(3600), + maxGasPerCall: parseEther('20'), + assets: [eth, usdc].map((token, i) => ({ + token, + maxPerCall: amounts[i], + maxTotal: amounts[i], + })), + allowedCalls: [{ target: counter, selector: 'increment()' }], + }, + ], + }); + await f.fundPC(made.wallet, parseEther('41')); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + made.wallet + ); + for (let i = 0; i < tokens.length; i++) { + await ( + await f.owner.universal.sendTransaction({ + to: tokens[i], + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [made.wallet, amounts[i] + BigInt(1)], + }), + }) + ).wait(); + await ( + await owner.universal.sendTransaction({ + to: tokens[i], + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [f.manifest.addresses.gateway, amounts[i] + BigInt(1)], + }), + }) + ).wait(); + } + const agent = await f.agent(made.wallet); + const [action] = await f.push.readContract({ + address: f.manifest.addresses.sessionEngine, + abi: v5.abis.engine, + functionName: 'getEnabledActions', + args: [made.wallet, made.rulesIds[0]], + }); + const key = configId(made.wallet, made.rulesIds[0], action); + const spent = async () => + ( + await f.push.readContract({ + address: f.manifest.addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getConfig', + args: [key, made.wallet], + }) + ).assets.map((asset) => asset.spent); + const sep = createPublicClient({ + chain: sepolia, + transport: fallback(CHAIN_INFO[chain].defaultRPC.map((url) => http(url))), + }); + const cea = (await getCEAAddress(made.wallet, chain)).cea as Address; + const params = (i: number, amount = amounts[i]) => ({ + to: { address: counter, chain }, + data: [{ to: counter, value: BigInt(0), data: '0xd09de08a' as Hex }], + funds: { token: [eth, usdc][i], amount }, + gasLimit: BigInt(2_000_000), + }); + const hashes: string[] = []; + expect(await spent()).toEqual([BigInt(0), BigInt(0)]); + for (let i = 0; i < tokens.length; i++) { + const beforeDestination = + i === 0 + ? await sep.getBalance({ address: cea }) + : await sep.readContract({ + address: usdc.address as Address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [cea], + }); + const tx = await agent.universal.sendTransaction(params(i)); + f.evidence('v5-budget-outbound-submitted', { + wallet: made.wallet, + rulesId: made.rulesIds[0], + token: tokens[i], + amount: amounts[i], + pushTx: tx.hash, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.externalStatus).toBe('success'); + await verifyExternalTransaction(receipt.externalTxHash!, chain); + const external = await sep.getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + const executed = parseEventLogs({ + abi: parseAbi([ + 'event UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data)', + ]), + logs: external.logs, + }); + expect( + executed.some( + (event) => + event.address.toLowerCase() === cea.toLowerCase() && + event.args.originCaller.toLowerCase() === + made.wallet.toLowerCase() && + event.args.target.toLowerCase() === counter.toLowerCase() + ) + ).toBe(true); + const afterDestination = + i === 0 + ? await sep.getBalance({ address: cea }) + : await sep.readContract({ + address: usdc.address as Address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [cea], + }); + expect(afterDestination).toBe(beforeDestination + amounts[i]); + expect( + await f.push.readContract({ + address: tokens[i], + abi: erc20Abi, + functionName: 'balanceOf', + args: [made.wallet], + }) + ).toBe(BigInt(1)); + expect( + await f.push.readContract({ + address: tokens[i], + abi: erc20Abi, + functionName: 'allowance', + args: [made.wallet, f.manifest.addresses.gateway], + }) + ).toBe(BigInt(1)); + const expected = i === 0 ? [amounts[0], BigInt(0)] : amounts; + expect(await spent()).toEqual(expected); + const nonce = await f.push.getTransactionCount({ + address: f.agentAddress, + }); + await expect( + agent.universal.sendTransaction(params(i, BigInt(1))) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(TotalSpendCapExceeded)' }, + }); + expect( + await f.push.getTransactionCount({ address: f.agentAddress }) + ).toBe(nonce); + expect(await spent()).toEqual(expected); + hashes.push(tx.hash, receipt.externalTxHash!); + } + f.evidence('v5-independent-token-budgets', { + wallet: made.wallet, + rulesId: made.rulesIds[0], + tokens, + amounts, + spent: await spent(), + hashes, + }); + }, 1_800_000); +}); diff --git a/packages/core/__e2e__/ci/agw-svm-preflight.ts b/packages/core/__e2e__/ci/agw-svm-preflight.ts new file mode 100644 index 000000000..588ef2c52 --- /dev/null +++ b/packages/core/__e2e__/ci/agw-svm-preflight.ts @@ -0,0 +1,30 @@ +/** No dotenv, signers or transactions: inspect both networks before funding a wire test. */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { createPublicClient, fallback, http, parseAbi, type Address } from 'viem'; +import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { loadAgwManifest, verifyAgwManifest } from '../agw/_manifest'; +import { inspectSvmWirePrograms, svmWireConnection, SVM_WIRE_CHAIN } from '../shared/agw-svm-preflight'; + +async function main() { + const manifest = loadAgwManifest(); + const push = createPublicClient({ chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), transport: fallback(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC.map((u) => http(u))) }); + await verifyAgwManifest(push, manifest); + const block = await push.getBlockNumber(); + const token = await push.readContract({ address: '0x00000000000000000000000000000000000000C0', + abi: parseAbi(['function gasTokenPRC20ByChainNamespace(string) view returns (address)']), + functionName: 'gasTokenPRC20ByChainNamespace', args: [SVM_WIRE_CHAIN], blockNumber: block }) as Address; + const source = await push.readContract({ address: token, abi: parseAbi(['function SOURCE_CHAIN_NAMESPACE() view returns (string)']), functionName: 'SOURCE_CHAIN_NAMESPACE', blockNumber: block }); + if (source !== SVM_WIRE_CHAIN) throw new Error('Gas PRC20 does not match Solana devnet'); + const quote = await push.readContract({ address: '0x00000000000000000000000000000000000000C0', + abi: parseAbi(['function getOutboundTxGasAndFees(address,uint256) view returns (address,uint256,uint256,uint256,string,uint256)']), + functionName: 'getOutboundTxGasAndFees', args: [token, BigInt(0)], blockNumber: block }); + const solana = await inspectSvmWirePrograms(svmWireConnection()); + const result = { push: { chainId: await push.getChainId(), block, sourceCommit: manifest.sourceCommit, token, quote }, solana, transactionsSent: 0 }; + const json = JSON.stringify(result, (_k, v) => typeof v === 'bigint' ? v.toString() : v, 2) + '\n'; + const output = process.argv[2]; + if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, json); } + process.stdout.write(json); +} +main().catch((error) => { console.error((error as Error).message); process.exitCode = 1; }); diff --git a/packages/core/__e2e__/ci/preflight.ts b/packages/core/__e2e__/ci/preflight.ts index 405ea41f9..e7bc8c5e3 100644 --- a/packages/core/__e2e__/ci/preflight.ts +++ b/packages/core/__e2e__/ci/preflight.ts @@ -64,6 +64,7 @@ import { deriveAtaPubkey, } from '../../src/lib/orchestrator/internals/svm-rent'; import { scenariosFor, aggregateNeeds, type Asset } from './suite'; +import { loadAgwManifest, verifyAgwManifest } from '../agw/_manifest'; // --------------------------------------------------------------------------- // tunables @@ -285,6 +286,10 @@ function renderTable(rows: Row[]): void { (async () => { const scenarios = scenariosFor(group); const raw = aggregateNeeds(scenarios); + if (group === 'agw-svm-replay') { + console.log('Recorded SVM replay is read-only: no keys, signing or funding needed.'); + return; + } /** Summed need for an asset, with headroom and reserve, as a decimal string. */ const target = (a: Asset): number => { @@ -326,6 +331,26 @@ function renderTable(rows: Row[]): void { chain: pushChainDef, transport: fallback(pushRpcs.map((u) => http(u))), }) as PublicClient; + + // -- AGW gate ------------------------------------------------------------ + // Every AGW group needs a verified compatible AGW deployment. + // Check it read-only BEFORE any balance work so nothing is ever funded for + // a run that cannot exercise a real deployment. + if (scenarios.some((s) => s.group.startsWith('agw'))) { + try { + await verifyAgwManifest(push, loadAgwManifest()); + console.log('AGW deployment manifest verified on-chain.'); + } catch (err) { + console.error(`\n${(err as Error).message}`); + console.error('No transactions were sent.'); + process.exit(1); + } + } + if (scenarios.some((s) => s.group === 'agw-svm-wire' || s.file === '__e2e__/agw/svm-public.spec.ts')) { + const { inspectSvmWirePrograms, svmWireConnection } = await import('../shared/agw-svm-preflight'); + await inspectSvmWirePrograms(svmWireConnection(solRpc)); + console.log('Solana wire fixture verified read-only before funding.'); + } const pushWallet = createWalletClient({ account: pushAcc, chain: pushChainDef, @@ -390,18 +415,19 @@ function renderTable(rows: Row[]): void { return BigInt(r?.value.amount ?? '0'); }); - const [dUsdtSep, dPeth, dUsdtEth, dPsol, dUsdtSol] = await Promise.all([ + const [dUsdtSep, dPeth, dUsdtEth, dPsol, dUsdtSol, dUsdcEth] = await Promise.all([ decimalsOf(usdtSepolia, sep), decimalsOf(s.pETH, push), decimalsOf(s.USDT_ETH, push), decimalsOf(s.pSOL, push), decimalsOf(s.USDT_SOL, push), + decimalsOf(s.USDC_ETH, push), ]); const dPc20 = pc20Push ? await decimalsOf(pc20Push, push) : 18; const [ sepEth, sepUsdt, - mPC, mPeth, mUsdtEth, mPsol, mUsdtSol, mPc20, + mPC, mPeth, mUsdtEth, mPsol, mUsdtSol, mUsdcEth, mPc20, uPC, uPeth, uUsdtEth, uPsol, uUsdtSol, uPc20, solBal, solUsdt, ceaSol, ceaUsdt, ] = await Promise.all([ @@ -413,6 +439,7 @@ function renderTable(rows: Row[]): void { erc20(s.USDT_ETH, pushAcc.address, push), erc20(s.pSOL, pushAcc.address, push), erc20(s.USDT_SOL, pushAcc.address, push), + erc20(s.USDC_ETH, pushAcc.address, push), pc20Push ? erc20(pc20Push, pushAcc.address, push) : Promise.resolve(BigInt(0)), retry(() => push.getBalance({ address: uea })), @@ -458,8 +485,9 @@ function renderTable(rows: Row[]): void { { scope: 'Push master (Donut)', asset: 'PC', need: units(target('masterPC'), 18) + deficit('ueaPC'), have: mPC, decimals: 18, tier: 'master' }, { scope: 'Push master (Donut)', asset: 'pETH', need: units(target('masterPETH'), dPeth) + deficit('ueaPETH'), have: mPeth, decimals: dPeth, tier: 'master' }, { scope: 'Push master (Donut)', asset: 'pUSDT.eth', need: deficit('ueaUsdtEth'), have: mUsdtEth, decimals: dUsdtEth, tier: 'master' }, - { scope: 'Push master (Donut)', asset: 'pSOL', need: deficit('ueaPSOL'), have: mPsol, decimals: dPsol, tier: 'master' }, - { scope: 'Push master (Donut)', asset: 'pUSDT.sol', need: deficit('ueaUsdtSol'), have: mUsdtSol, decimals: dUsdtSol, tier: 'master' }, + { scope: 'Push master (Donut)', asset: 'pSOL', need: units(target('masterPSOL'), dPsol) + deficit('ueaPSOL'), have: mPsol, decimals: dPsol, tier: 'master' }, + { scope: 'Push master (Donut)', asset: 'pUSDT.sol', need: units(target('masterUsdtSol'), dUsdtSol) + deficit('ueaUsdtSol'), have: mUsdtSol, decimals: dUsdtSol, tier: 'master' }, + { scope: 'Push master (Donut)', asset: 'pUSDC.eth', need: units(target('masterUsdcEth'), dUsdcEth), have: mUsdcEth, decimals: dUsdcEth, tier: 'master' }, { scope: 'Push master (Donut)', asset: 'PC20', need: deficit('ueaPC20'), have: mPc20, decimals: dPc20, tier: 'master' }, { scope: 'Solana master (Devnet)', asset: 'SOL', need: units(target('solanaSOL'), 9) + deficit('ceaSvmSOL'), have: solBal, decimals: 9, tier: 'master' }, { scope: 'Solana master (Devnet)', asset: 'USDT', need: units(target('solanaUsdt'), 6) + deficit('ceaSvmUsdt'), have: solUsdt, decimals: 6, tier: 'master' }, diff --git a/packages/core/__e2e__/ci/run.ts b/packages/core/__e2e__/ci/run.ts index 33d1ae100..5ff82702a 100644 --- a/packages/core/__e2e__/ci/run.ts +++ b/packages/core/__e2e__/ci/run.ts @@ -6,8 +6,10 @@ * npx ts-node --transpile-only __e2e__/ci/run.ts [options] * * --group One of: all (default), smoke, evm-r1, evm-r2, evm-r3, - * svm-r1, svm-r2r3, pc20, push, cross-chain, known-fail - * (`all` excludes known-fail — ask for it by name) + * svm-r1, svm-r2r3, pc20, push, cross-chain, read, + * known-fail, agw, agw-svm-wire, agw-svm-replay + * (`all` excludes known-fail and AGW groups — ask by name; + * agw uses the checked Donut v5 manifest by default, see __e2e__/agw/_manifest.ts) * --list Print the selection and the jest argv, run nothing * --verify Check every `grep` against the real spec titles and exit * non-zero if any fragment matches 0 or >1 tests in its file @@ -55,7 +57,8 @@ function classify(prefix: string): 'describe' | 'it' | null { if (/\?[\s\S]*\b(it|test)\b[\s\S]*\)$/.test(p)) return 'it'; const m = p.match(/([A-Za-z_$][\w$]*)(?:\.(skip|only|failing|concurrent))?$/); if (!m) return null; - if (/^(describe|d|dWrapper|xdescribe|fdescribe)$/.test(m[1])) return 'describe'; + if (/^(describe|d|dWrapper|xdescribe|fdescribe)$/.test(m[1])) + return 'describe'; if (/^(it|test|xit|fit|itWrapper|itIf)$/.test(m[1])) return 'it'; return null; } @@ -83,7 +86,13 @@ function titlesOf(specPath: string): string[] { const depth = depthAt[at]; while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop(); if (kind === 'describe') stack.push({ title: m[2], depth }); - else titles.push(stack.map((s) => s.title).concat(m[2]).join(' ')); + else + titles.push( + stack + .map((s) => s.title) + .concat(m[2]) + .join(' ') + ); } return titles; } @@ -158,13 +167,17 @@ if (has('verify')) { bad++; } else { for (const h of hits) seen.set(h, (seen.get(h) ?? []).concat(s.id)); - console.log(`✓ ${s.id.padEnd(32)} ${hits.length} test${hits.length > 1 ? 's' : ''}`); + console.log( + `✓ ${s.id.padEnd(32)} ${hits.length} test${hits.length > 1 ? 's' : ''}` + ); } } const dupes = [...seen.entries()].filter(([, ids]) => ids.length > 1); for (const [title, ids] of dupes) { - console.error(`✗ overlapping fragments — "${title}" claimed by ${ids.join(', ')}`); + console.error( + `✗ overlapping fragments — "${title}" claimed by ${ids.join(', ')}` + ); bad++; } @@ -193,7 +206,9 @@ if (has('verify')) { `\n${selected.length} scenarios, ${files.length} spec files, ${seen.size} distinct tests.` ); if (bad) { - console.error(`\n${bad} problem(s). The manifest has drifted from the specs.`); + console.error( + `\n${bad} problem(s). The manifest has drifted from the specs.` + ); process.exit(1); } console.log('Manifest is consistent with the specs.'); @@ -202,7 +217,8 @@ if (has('verify')) { if (has('list')) { const byGroup = new Map(); - for (const s of selected) byGroup.set(s.group, (byGroup.get(s.group) ?? []).concat(s)); + for (const s of selected) + byGroup.set(s.group, (byGroup.get(s.group) ?? []).concat(s)); for (const g of GROUPS) { const rows = byGroup.get(g); if (!rows) continue; @@ -214,8 +230,14 @@ if (has('list')) { console.log(` ${s.id.padEnd(32)} ${cost || '(free)'}`); } } - console.log(`\n${selected.length} scenarios across ${files.length} spec files.`); - console.log(`\nnpx ${jestArgs.map((a) => (/[\s|()]/.test(a) ? JSON.stringify(a) : a)).join(' ')}`); + console.log( + `\n${selected.length} scenarios across ${files.length} spec files.` + ); + console.log( + `\nnpx ${jestArgs + .map((a) => (/[\s|()]/.test(a) ? JSON.stringify(a) : a)) + .join(' ')}` + ); process.exit(0); } @@ -225,7 +247,9 @@ if (has('list')) { fs.mkdirSync(LOG_DIR, { recursive: true }); -console.log(`Running ${selected.length} scenarios (group: ${group}) across ${files.length} spec files.\n`); +console.log( + `Running ${selected.length} scenarios (group: ${group}) across ${files.length} spec files.\n` +); const result = spawnSync('npx', jestArgs, { cwd: CORE_ROOT, @@ -277,7 +301,9 @@ function writeSummary(): void { const skipped = all.length - passed - failed; // Skipped rows are the tests jest loaded but `-t` filtered out — noise here. - const shown = all.filter((a) => a.status !== 'pending' && a.status !== 'skipped'); + const shown = all.filter( + (a) => a.status !== 'pending' && a.status !== 'skipped' + ); const lines = [ `## E2E — group \`${group}\``, diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index 71ff59666..3bc27a740 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -34,6 +34,9 @@ export const GROUPS = [ 'cross-chain', 'read', 'known-fail', + 'agw', + 'agw-svm-wire', + 'agw-svm-replay', ] as const; export type Group = (typeof GROUPS)[number]; @@ -69,6 +72,9 @@ export type Asset = // Push master (PUSH_PRIVATE_KEY) on Push Donut — Push-native origin specs | 'masterPC' | 'masterPETH' + | 'masterPSOL' + | 'masterUsdcEth' + | 'masterUsdtSol' // The EVM master's UEA on Push Donut — Routes 2/3 on both VMs, and PC20 | 'ueaPC' | 'ueaPETH' @@ -149,9 +155,52 @@ const F = { readSvmIdl: '__e2e__/read/svm/account-idl.spec.ts', readMutability: '__e2e__/read/evm/any-mutability.spec.ts', readTrackLookup: '__e2e__/read/lifecycle/track-lookup.spec.ts', + agwCreate: '__e2e__/agw/create.spec.ts', + agwFunding: '__e2e__/agw/funding-allowance.spec.ts', + agwNative: '__e2e__/agw/native-exec.spec.ts', + agwLifecycle: '__e2e__/agw/lifecycle.spec.ts', + agwUniversal: '__e2e__/agw/universal-evm.spec.ts', + agwIdentity: '__e2e__/agw/identity-gas.spec.ts', + agwResponses: '__e2e__/agw/responses-hooks.spec.ts', + agwExtendedManagement: '__e2e__/agw/management-extended.spec.ts', + agwExtendedNative: '__e2e__/agw/native-policy-extended.spec.ts', } as const; export const SCENARIOS: Scenario[] = [ + { + id: 'agw-svm-replay-positive', group: 'agw-svm-replay', file: '__e2e__/agw-svm-wire/replay.spec.ts', + grep: 'agw svm recorded replay 1\\. positive replay', needs: {}, env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, + }, + { + id: 'agw-svm-replay-rejected', group: 'agw-svm-replay', file: '__e2e__/agw-svm-wire/replay.spec.ts', + grep: 'agw svm recorded replay 2\\. later terminal rejection', needs: {}, env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, + }, + { + id: 'agw-svm-replay-policy', group: 'agw-svm-replay', file: '__e2e__/agw-svm-wire/replay.spec.ts', + grep: 'agw svm recorded replay 3\\. pinned live policy', needs: {}, env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, + }, + { + id: 'agw-svm-wire-positive', group: 'agw-svm-wire', + file: '__e2e__/agw-svm-wire/outbound.spec.ts', + grep: 'agw svm wire 1\\. positive outbound', + needs: { masterPC: '45', masterPSOL: '0.00002' }, + env: { AGW_SVM_WIRE_E2E: '1' }, + note: 'Internal prepared-wire backend; public SVM capability remains gated.', + }, + { + id: 'agw-svm-wire-refusals', group: 'agw-svm-wire', + file: '__e2e__/agw-svm-wire/outbound.spec.ts', + grep: 'agw svm wire 2\\. substituted account', + needs: { masterPC: '45', masterPSOL: '0.00002' }, + env: { AGW_SVM_WIRE_E2E: '1' }, + }, + { + id: 'agw-svm-wire-failure', group: 'agw-svm-wire', + file: '__e2e__/agw-svm-wire/outbound.spec.ts', + grep: 'agw svm wire 3\\. destination program failure', + needs: { masterPC: '45', masterPSOL: '0.00002' }, + env: { AGW_SVM_WIRE_E2E: '1' }, + }, // --------------------------------------------------------------------------- // smoke — read-only. No transactions, no funds. Runs first so a broken build or // a dead RPC fails in ~3 min instead of 90. @@ -615,8 +664,11 @@ export const SCENARIOS: Scenario[] = [ note: 'prepareRead → simulateRead → sendTransaction → trackRead → value == Sepolia balance at pin.', }, { - id: 'read-canonical-registry', group: 'read', file: F.readRegistry, - grep: 'read state › canonical registry', needs: { masterPC: '0.2' }, + id: 'read-canonical-registry', + group: 'read', + file: F.readRegistry, + grep: 'read state › canonical registry', + needs: { masterPC: '0.2' }, note: 'Callback-free read plus a second pin: verifies per-request storage, stable-key latestResult and settlement.', }, { @@ -673,16 +725,25 @@ export const SCENARIOS: Scenario[] = [ note: 'Funds a fresh wallet with 0.5 PC, as the docs prompt will.', }, { - id: 'read-docs-evm-balance', group: 'read', file: F.readPlaygrounds, - grep: 'docs-examples › 13-read-state playgrounds universal_read_evm_balance', needs: { masterPC: '0.1' }, + id: 'read-docs-evm-balance', + group: 'read', + file: F.readPlaygrounds, + grep: 'docs-examples › 13-read-state playgrounds universal_read_evm_balance', + needs: { masterPC: '0.1' }, }, { - id: 'read-docs-batch', group: 'read', file: F.readPlaygrounds, - grep: 'docs-examples › 13-read-state playgrounds universal_read_batch', needs: { masterPC: '0.1' }, + id: 'read-docs-batch', + group: 'read', + file: F.readPlaygrounds, + grep: 'docs-examples › 13-read-state playgrounds universal_read_batch', + needs: { masterPC: '0.1' }, }, { - id: 'read-docs-resume', group: 'read', file: F.readPlaygrounds, - grep: 'docs-examples › 13-read-state playgrounds universal_read_resume', needs: { masterPC: '0.1' }, + id: 'read-docs-resume', + group: 'read', + file: F.readPlaygrounds, + grep: 'docs-examples › 13-read-state playgrounds universal_read_resume', + needs: { masterPC: '0.1' }, note: 'Read-only resume (own request ID, predefined request ID, tx hash); seeds a request if the balance example was not selected.', }, { @@ -702,37 +763,55 @@ export const SCENARIOS: Scenario[] = [ note: 'Same batch through the UEA — one atomic tx.', }, { - id: 'read-app-batch-recovery', group: 'read', file: F.readBatch, + id: 'read-app-batch-recovery', + group: 'read', + file: F.readBatch, grep: 'read state › custom app batch recovers the committed read', needs: { masterPC: '0.1' }, note: 'Force sequential wallet execution; recover the first read after the second request fails.', }, { - id: 'read-api-token', group: 'read', file: F.readApi, - grep: 'read state › public API coverage ERC20 shorthand', needs: { masterPC: '0.1' }, + id: 'read-api-token', + group: 'read', + file: F.readApi, + grep: 'read state › public API coverage ERC20 shorthand', + needs: { masterPC: '0.1' }, }, { - id: 'read-api-mixed-outcomes', group: 'read', file: F.readApi, - grep: 'read state › public API coverage mixed consensus outcomes', needs: { masterPC: '0.3' }, + id: 'read-api-mixed-outcomes', + group: 'read', + file: F.readApi, + grep: 'read state › public API coverage mixed consensus outcomes', + needs: { masterPC: '0.3' }, }, { - id: 'read-svm-token-auto', group: 'read', file: F.readSvmIdl, + id: 'read-svm-token-auto', + group: 'read', + file: F.readSvmIdl, grep: 'read state › Solana automatic token and IDL account reads detects the mint program', needs: { masterPC: '0.1' }, }, { - id: 'read-svm-idl', group: 'read', file: F.readSvmIdl, + id: 'read-svm-idl', + group: 'read', + file: F.readSvmIdl, grep: 'read state › Solana automatic token and IDL account reads decodes a real Anchor account', needs: { masterPC: '0.1' }, }, { - id: 'read-evm-any-mutability', group: 'read', file: F.readMutability, - grep: 'read state › EVM nonpayable and payable functions', needs: { masterPC: '0.1' }, + id: 'read-evm-any-mutability', + group: 'read', + file: F.readMutability, + grep: 'read state › EVM nonpayable and payable functions', + needs: { masterPC: '0.1' }, note: 'QuoterV2 (nonpayable) + Multicall3.aggregate (payable) simulated at the pinned block.', }, { - id: 'read-svm-raw', group: 'read', file: F.readSvmRaw, - grep: 'read state › SVM raw account', needs: { masterPC: '0.1' }, + id: 'read-svm-raw', + group: 'read', + file: F.readSvmRaw, + grep: 'read state › SVM raw account', + needs: { masterPC: '0.1' }, }, // --------------------------------------------------------------------------- @@ -753,15 +832,278 @@ export const SCENARIOS: Scenario[] = [ '(balance - reserve, the pre-fix behaviour) where the test expects the full ' + '~20.9 PC requirement. Real regression in the Route 2 dead-zone gas sizer.', }, + + // ------------------------------------------------------------------------- + // agw — agentic wallets. EXCLUDED from `all`: every spec's beforeAll fails + // its prerequisite gate until a verified compatible AGW deployment manifest + // (AGW_DEPLOYMENT_MANIFEST) exists. Preflight + // refuses to fund the group without it. Budgets are per run: the Push master + // (owner) pays setup gas, wallet PC and the bounded fresh-agent funding; + // the EVM master's UEA pays external-agent gas; pETH funds the outbound. + // ------------------------------------------------------------------------- + { + id: 'agw-create-bare', + group: 'agw', + file: F.agwCreate, + grep: 'agw create 1\\. bare wallet', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-create-rules', + group: 'agw', + file: F.agwCreate, + grep: 'agw create 2\\. wallet with a native rule', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-funding-pc', + group: 'agw', + file: F.agwFunding, + grep: 'agw funding 1\\. wallet PC', + needs: { masterPC: '1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-funding-allowance', + group: 'agw', + file: F.agwFunding, + grep: 'agw funding 2\\. owner sets and removes', + needs: { masterPC: '1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-batch', + group: 'agw', + file: '__e2e__/agw/native-batch.spec.ts', + grep: 'agw native batch 1\\. sender-preserving atomic', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-batch-rollback', + group: 'agw', + file: '__e2e__/agw/native-batch.spec.ts', + grep: 'agw native batch 2\\. a later policy failure', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-agent', + group: 'agw', + file: F.agwNative, + grep: 'agw native 1\\. agent door', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-owner', + group: 'agw', + file: F.agwNative, + grep: 'agw native 2\\. owner door', + needs: { masterPC: '0.5' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-over-cap', + group: 'agw', + file: F.agwNative, + grep: 'agw native 3\\. over-cap', + needs: { masterPC: '0.5' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-stranger', + group: 'agw', + file: F.agwNative, + grep: 'agw native 4\\. an unrelated signer', + needs: { masterPC: '0.1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-revoked', + group: 'agw', + file: F.agwNative, + grep: 'agw native 5\\. after revocation', + needs: { masterPC: '0.5' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-native-expired', + group: 'agw', + file: F.agwNative, + grep: 'agw native 6\\. an expired rule', + needs: { masterPC: '1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-lifecycle-add', + group: 'agw', + file: F.agwLifecycle, + grep: 'agw lifecycle 1\\. rules\\.add', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-lifecycle-update', + group: 'agw', + file: F.agwLifecycle, + grep: 'agw lifecycle 2\\. rules\\.update', + needs: { masterPC: '1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-lifecycle-checkpoints', + group: 'agw', + file: F.agwLifecycle, + grep: 'agw lifecycle 3\\. checkpoint history', + needs: { masterPC: '0.1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-lifecycle-revoke-all', + group: 'agw', + file: F.agwLifecycle, + grep: 'agw lifecycle 4\\. revoke', + needs: { masterPC: '0.5' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-universal-outbound', + group: 'agw', + file: F.agwUniversal, + grep: 'agw universal evm 1\\. positive-amount outbound', + needs: { masterPC: '25', masterPETH: '0.00001' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-v5-multi-asset', + group: 'agw', + file: '__e2e__/agw/v5-multiasset.spec.ts', + grep: 'agw v5 multi-asset 1\\. public create', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-v5-call-only', + group: 'agw', + file: '__e2e__/agw/v5-multiasset.spec.ts', + grep: 'agw v5 multi-asset 2\\. empty user assets', + needs: { masterPC: '25' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-universal-dest-revert', + group: 'agw', + file: F.agwUniversal, + grep: 'agw universal evm 2\\. destination revert', + needs: { masterPC: '5' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-identity-evm', + group: 'agw', + file: F.agwIdentity, + grep: 'agw identity 1\\. an EVM key on Sepolia', + needs: { masterPC: '1', ueaPC: '1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-identity-first-use', + group: 'agw', + file: F.agwIdentity, + grep: 'agw identity 2\\. first use', + needs: { masterPC: '1', sepoliaEth: '0.003' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-identity-solana', + group: 'agw', + file: F.agwIdentity, + grep: 'agw identity 3\\. a Solana-origin key', + needs: { masterPC: '1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-responses-hooks', + group: 'agw', + file: F.agwResponses, + grep: 'agw responses 1\\. init and per-call hooks', + needs: { masterPC: '1' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-responses-replay', + group: 'agw', + file: F.agwResponses, + grep: 'agw responses 2\\. live and replayed', + needs: { masterPC: '0.5' }, + env: { AGW_E2E: '1' }, + }, + + ...Array.from({ length: 6 }, (_, i) => ({ + id: `agw-public-svm-${i + 1}`, group: 'agw' as const, + file: '__e2e__/agw/svm-public.spec.ts', grep: `agw public svm ${i + 1}\\.`, + needs: { masterPC: '45', masterPSOL: '0.000020001' }, env: { AGW_E2E: '1' }, + })), + + ...Array.from({ length: 8 }, (_, i) => ({ + id: `agw-labels-${i + 1}`, group: 'agw' as const, + file: '__e2e__/agw/labels.spec.ts', grep: `agw labels ${i + 1}\\.`, + needs: i === 4 ? { masterPC: '4', sepoliaEth: '0.003' } : i === 5 ? { masterPC: '4', solanaSOL: '0.003' } : { masterPC: '1' }, + env: { AGW_E2E: '1' }, + })), + + { + id: 'agw-v5-independent-token-budgets', group: 'agw', + file: '__e2e__/agw/v5-multiasset.spec.ts', grep: 'agw v5 multi-asset 3\\.', + needs: { masterPC: '45', masterPETH: '0.000001000000000001', masterUsdcEth: '0.000011' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-owner-spl-transfer', group: 'agw', + file: '__e2e__/agw/svm-spl-transfer.spec.ts', grep: 'agw svm spl transfer 1\\.', + needs: { masterPC: '23', masterUsdtSol: '0.001' }, + env: { AGW_E2E: '1' }, + }, + + // Extended AGW tests are opt-in and use bounded per-scenario testnet budgets. + ...[ + ['prefunding', '1\\. prefunding', '11'], + ['ambiguous-rules', '2\\. same-agent multiplicity', '11'], + ['batched-lifecycle', '3\\. multi-rule add', '0.5'], + ['invalid-revoke', '4\\. invalid revoke', '0.5'], + ['stale-update', '5\\. intervening agent spend', '11'], + ['index-race', '6\\. index race', '1'], + ].map(([slug, grep, pc]) => ({ + id: `agw-extended-${slug}`, + group: 'agw' as const, + file: F.agwExtendedManagement, + grep: `agw extended management ${grep}`, + needs: { masterPC: pc }, + env: { AGW_E2E: '1' }, + })), + ...Array.from({ length: 11 }, (_, i) => ({ + id: `agw-extended-native-policy-${i + 1}`, + group: 'agw' as const, + file: F.agwExtendedNative, + grep: `agw extended native policy ${i + 1}\\.`, + needs: { masterPC: '0.25', masterPETH: '0.000000000001' }, + env: { AGW_E2E: '1' }, + })), ]; /** * Scenarios for a group. `all` means "everything that should pass" — it deliberately - * excludes `known-fail`, which has to be asked for by name. + * excludes `known-fail` and every AGW group, which must be requested by name. */ export function scenariosFor(group: string | undefined): Scenario[] { if (!group || group === 'all') { - return SCENARIOS.filter((s) => s.group !== 'known-fail'); + // AGW stays opt-in: scenarios create/fund fresh wallets and consume testnet funds. + return SCENARIOS.filter( + (s) => s.group !== 'known-fail' && !s.group.startsWith('agw') + ); } if (!(GROUPS as readonly string[]).includes(group)) { throw new Error( @@ -777,7 +1119,10 @@ export function aggregateNeeds( ): Partial> { const total: Partial> = {}; for (const s of scenarios) { - for (const [asset, amount] of Object.entries(s.needs) as [Asset, string][]) { + for (const [asset, amount] of Object.entries(s.needs) as [ + Asset, + string + ][]) { total[asset] = (total[asset] ?? 0) + Number(amount); } } diff --git a/packages/core/__e2e__/shared/agw-state.ts b/packages/core/__e2e__/shared/agw-state.ts new file mode 100644 index 000000000..2c633e03e --- /dev/null +++ b/packages/core/__e2e__/shared/agw-state.ts @@ -0,0 +1,31 @@ +/** Independent contract-state assertions for AGW tests; never part of the public SDK. */ +import type { Address, Hex, PublicClient } from 'viem'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { configId } from '../../src/lib/agentic/codec/ids'; + +export async function readNativeCounters( + client: PublicClient, + addresses: { sessionEngine: Address; rulesPolicy: Address }, + wallet: Address, + rulesId: Hex +) { + const actions = await client.readContract({ + address: addresses.sessionEngine, + abi: v5.abis.engine, + functionName: 'getEnabledActions', + args: [wallet, rulesId], + }); + if (actions.length !== 1) + throw new Error('Native counter fixture expects one action'); + const cfg = await client.readContract({ + address: addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getNativeConfig', + args: [configId(wallet, rulesId, actions[0]), wallet], + }); + return { + valueSpent: cfg.valueSpent, + amountSpent: cfg.amountSpent, + callsUsed: Number(cfg.callsUsed), + }; +} diff --git a/packages/core/__e2e__/shared/agw-svm-policy-probe.ts b/packages/core/__e2e__/shared/agw-svm-policy-probe.ts new file mode 100644 index 000000000..8b5b0d6c0 --- /dev/null +++ b/packages/core/__e2e__/shared/agw-svm-policy-probe.ts @@ -0,0 +1,34 @@ +/** Bypass SDK validation to verify the deployed URP refuses the same mutations. eth_call only. */ +import { decodeFunctionData, encodeFunctionData, type Address, type Hex, type PublicClient } from 'viem'; +import { UNIVERSAL_GATEWAY_PC } from '../../src/lib/constants/abi'; +import { v5, type Call } from '../../src/lib/agentic/contracts/v5'; +import { parseAgwSvmPayload, encodeAgwSvmPayload } from '../../src/lib/agentic/execution/svm-payload'; +import { decodeAgenticRevert } from '../../src/lib/agentic/revert'; + +export async function probeSvmWirePolicies(push: PublicClient, input: { + from: Address; wallet: Address; rulesId: Hex; call: Call; blockNumber: bigint; +}) { + const decodedCall = decodeFunctionData({ abi: UNIVERSAL_GATEWAY_PC, data: input.call.data }); + if (decodedCall.functionName !== 'sendUniversalTxOutbound') throw new Error('Expected a gateway outbound request'); + const request = decodedCall.args[0]; + const parsed = parseAgwSvmPayload(request.payload); + const observations = []; + for (const kind of ['account-pin', 'instruction-data-pin'] as const) { + const accounts = parsed.accounts.map((account) => ({ ...account })); + const data = new Uint8Array(parsed.instructionData); + if (kind === 'account-pin') accounts[2].pubkey = accounts[1].pubkey; + else { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + view.setBigUint64(8, view.getBigUint64(8, true) + BigInt(1), true); + } + const altered = { ...request, payload: encodeAgwSvmPayload(parsed.program, accounts, data) }; + const packet = v5.encodeExecuteAsAgent(input.rulesId, { ...input.call, + data: encodeFunctionData({ abi: UNIVERSAL_GATEWAY_PC, functionName: 'sendUniversalTxOutbound', args: [altered] }) }); + let decoded; + try { await push.call({ account: input.from, to: input.wallet, data: packet, blockNumber: input.blockNumber }); } + catch (error) { decoded = decodeAgenticRevert(error); } + if (!decoded?.name?.includes('Svm')) throw new Error(`Expected a decoded SVM policy refusal for ${kind}, got ${decoded?.name ?? 'no decoded refusal'}`); + observations.push({ kind, reverted: true, decoded }); + } + return { chainId: 42101, block: input.blockNumber.toString(), observations, transactionsSent: 0 }; +} diff --git a/packages/core/__e2e__/shared/agw-svm-preflight.ts b/packages/core/__e2e__/shared/agw-svm-preflight.ts new file mode 100644 index 000000000..1272c52b1 --- /dev/null +++ b/packages/core/__e2e__/shared/agw-svm-preflight.ts @@ -0,0 +1,73 @@ +/** Read-only checks for the internal AGW Solana wire acceptance suite. */ +import { createHash } from 'node:crypto'; +import { BorshAccountsCoder, type Idl } from '@coral-xyz/anchor'; +import { Connection, PublicKey } from '@solana/web3.js'; +import { CHAIN_INFO } from '../../src/lib/constants/chain'; +import { CHAIN } from '../../src/lib/constants/enums'; +import gatewayIdl from '../../src/lib/constants/abi/universalGatewayV0.json'; +import counterIdl from '../../src/lib/orchestrator/svm-idl/__fixtures__/test_counter.idl.json'; + +export const SVM_WIRE_CHAIN = CHAIN.SOLANA_DEVNET; +export const SVM_WIRE_PROGRAM = counterIdl.address; +export const SVM_WIRE_GATEWAY = CHAIN_INFO[SVM_WIRE_CHAIN].lockerContract!; +export const SVM_WIRE_COUNTER = PublicKey.findProgramAddressSync( + [Buffer.from('counter')], new PublicKey(SVM_WIRE_PROGRAM) +)[0]; +const LOADER = 'BPFLoaderUpgradeab1e11111111111111111111111'; +const COUNTER_DISC = Buffer.from(counterIdl.accounts.find((a) => a.name === 'Counter')!.discriminator); + +export function decodeWireCounter(data: Buffer) { + if (data.length < 48 || !data.subarray(0, 8).equals(COUNTER_DISC)) + throw new Error('Counter data does not match the selected IDL'); + return { value: data.readBigUInt64LE(8), authority: new PublicKey(data.subarray(16, 48)).toBase58() }; +} + +export function svmWireConnection(url = CHAIN_INFO[SVM_WIRE_CHAIN].defaultRPC[0]) { + return new Connection(url, { + commitment: 'confirmed', + disableRetryOnRateLimit: true, + fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(20_000) }), + }); +} + +export async function inspectSvmWirePrograms(connection: Connection) { + const genesis = await connection.getGenesisHash(); + if (`solana:${genesis.slice(0, 32)}` !== SVM_WIRE_CHAIN) + throw new Error('Solana RPC is not the selected devnet cluster'); + const gateway = new PublicKey(SVM_WIRE_GATEWAY), program = new PublicKey(SVM_WIRE_PROGRAM); + if (gatewayIdl.address !== gateway.toBase58()) throw new Error('Gateway registry and IDL disagree'); + const config = PublicKey.findProgramAddressSync([Buffer.from('config')], gateway)[0]; + const keys = [gateway, program, SVM_WIRE_COUNTER, config]; + const info = await connection.getMultipleAccountsInfoAndContext(keys); + const fingerprint = (data: Buffer) => createHash('sha256').update(data).digest('hex'); + const programDataKeys = info.value.slice(0, 2).map((account, i) => { + if (!account?.executable || account.owner.toBase58() !== LOADER || + account.data.length !== 36 || account.data.readUInt32LE(0) !== 2) + throw new Error(`Program ${keys[i].toBase58()} is not an executable upgradeable program`); + return new PublicKey(account.data.subarray(4, 36)); + }); + const counter = info.value[2], configuration = info.value[3]; + if (!counter || counter.executable || !counter.owner.equals(program)) throw new Error('Counter account is missing or has the wrong owner'); + const counterState = decodeWireCounter(counter.data); + if (!configuration || configuration.executable || !configuration.owner.equals(gateway)) throw new Error('Gateway configuration is missing or has the wrong owner'); + const decoded = new BorshAccountsCoder(gatewayIdl as unknown as Idl).decode('Config', configuration.data); + if (decoded.paused !== false) throw new Error('Gateway is paused'); + const implementations = await connection.getMultipleAccountsInfoAndContext(programDataKeys); + const programs = implementations.value.map((account, i) => { + if (!account || account.owner.toBase58() !== LOADER || account.data.readUInt32LE(0) !== 3 || account.data.length <= 45) + throw new Error('ProgramData account is missing or invalid'); + return { + address: keys[i].toBase58(), programData: programDataKeys[i].toBase58(), + lastUpgradeSlot: account.data.readBigUInt64LE(4).toString(), + dataLength: account.data.length, dataSha256: fingerprint(account.data), + }; + }); + return { + checkedAt: new Date().toISOString(), chain: SVM_WIRE_CHAIN, genesis, + slot: info.context.slot, programDataSlot: implementations.context.slot, programs, + gatewayConfig: { address: config.toBase58(), paused: decoded.paused, dataSha256: fingerprint(configuration.data) }, + counter: { address: SVM_WIRE_COUNTER.toBase58(), value: counterState.value.toString(), authority: counterState.authority, dataSha256: fingerprint(counter.data) }, + counterIdlSha256: fingerprint(Buffer.from(JSON.stringify(counterIdl))), + gatewayIdlSha256: fingerprint(Buffer.from(JSON.stringify(gatewayIdl))), + }; +} diff --git a/packages/core/jest.agw-local.config.ts b/packages/core/jest.agw-local.config.ts new file mode 100644 index 000000000..c2e22b0c4 --- /dev/null +++ b/packages/core/jest.agw-local.config.ts @@ -0,0 +1,23 @@ +/** + * Local AGW contract harness (review-only). Runs the SDK against the real + * pinned v5 contracts on anvil. Requires Foundry and AGW_LOCAL_DIR from + * scripts/agw-local/prepare.sh. Never part of the unit or E2E runs. + * + * AGW_LOCAL_DIR=$(packages/core/scripts/agw-local/prepare.sh | tail -1) \ + * node node_modules/jest/bin/jest.js --config packages/core/jest.agw-local.config.ts --runInBand + */ +export default { + displayName: 'core-agw-local', + preset: '../../jest.preset.js', + testEnvironment: 'node', + setupFilesAfterEnv: ['/__agw-local__/setup.ts'], + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.agw-local.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + testTimeout: 120000, + testMatch: ['/__agw-local__/**/*.spec.ts'], +}; diff --git a/packages/core/package.json b/packages/core/package.json index 45bf8bc3b..7572d206c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -51,6 +51,11 @@ }, "scripts": { "build:proto": "scripts/protoc-generate.sh", + "docs:check:agw": "node scripts/check-agw-docs.js", + "agw:verify": "node scripts/agw-verify.js", + "agw:check:package": "node scripts/check-agw-package.js", + "agw:check:api": "node scripts/check-agw-api.js", + "agw:check:runtimes": "node scripts/check-agw-runtimes.js", "e2e:list": "ts-node --transpile-only __e2e__/ci/run.ts --list", "e2e:verify": "ts-node --transpile-only __e2e__/ci/run.ts --verify", "e2e:preflight": "ts-node --transpile-only __e2e__/ci/preflight.ts", @@ -67,6 +72,7 @@ "src/**/*.d.ts", "src/**/*.json", "README.md", + "AGW.md", "CHANGELOG.md" ], "devDependencies": { diff --git a/packages/core/scripts/agw-local/abis/engine.json b/packages/core/scripts/agw-local/abis/engine.json new file mode 100644 index 000000000..bdf678eb5 --- /dev/null +++ b/packages/core/scripts/agw-local/abis/engine.json @@ -0,0 +1,1957 @@ +[ + { + "type": "function", + "name": "areActionsEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "areERC1271PoliciesEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "areUserOpPoliciesEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "disableActionId", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "disableActionPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + }, + { + "name": "policies", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "disableERC1271Policies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policies", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "contexts", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "disableUserOpPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policies", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableActionPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableERC1271Policies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "erc1271Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableSessions", + "inputs": [ + { + "name": "sessions", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "permissionIds", + "type": "bytes32[]", + "internalType": "PermissionId[]" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableUserOpPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getActionPolicies", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getERC1271Policies", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEnabledActions", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEnabledERC7739Content", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "enabledERC7739ContentHashes", + "type": "tuple[]", + "internalType": "struct ERC7739ContextHashes[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNameHashes", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getNonce", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPermissionIDs", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "permissionIds", + "type": "bytes32[]", + "internalType": "PermissionId[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPermissionId", + "inputs": [ + { + "name": "session", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "getSessionDigest", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "mode", + "type": "uint8", + "internalType": "enum SmartSessionMode" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getSessionValidatorAndConfig", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "address" + }, + { + "name": "sessionValidatorData", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getUserOpPolicies", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isActionIdEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isActionPolicyEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isERC1271PolicyEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isERC7739ContentEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "content", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isISessionValidatorSet", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isInitialized", + "inputs": [ + { + "name": "smartAccount", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isModuleType", + "inputs": [ + { + "name": "typeID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "isPermissionEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isUserOpPolicyEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isValidSignatureWithSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "hash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "signature", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "result", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "onInstall", + "inputs": [ + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "onUninstall", + "inputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "removeSession", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeEnableSignature", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPermit4337Paymaster", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "enabled", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "validateUserOp", + "inputs": [ + { + "name": "userOp", + "type": "tuple", + "internalType": "struct PackedUserOperation", + "components": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "initCode", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "callData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "accountGasLimits", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "preVerificationGas", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "gasFees", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "paymasterAndData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "signature", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "userOpHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "vd", + "type": "uint256", + "internalType": "ValidationData" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "ActionIdDisabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "indexed": false, + "internalType": "ActionId" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "NonceIterated", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "newValue", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PermissionIdPermit4337Paymaster", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "enabled", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyDisabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "policyType", + "type": "uint8", + "indexed": false, + "internalType": "enum PolicyType" + }, + { + "name": "policy", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "policyType", + "type": "uint8", + "indexed": false, + "internalType": "enum PolicyType" + }, + { + "name": "policy", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionCreated", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionRemoved", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionValidatorDisabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "sessionValidator", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionValidatorEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "sessionValidator", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AssociatedArray_OutOfBounds", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ChainIdMismatch", + "inputs": [ + { + "name": "providedChainId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "ChainIdMismatch", + "inputs": [ + { + "name": "providedChainId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "ForbiddenValidationData", + "inputs": [] + }, + { + "type": "error", + "name": "HashIndexOutOfBounds", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "HashMismatch", + "inputs": [ + { + "name": "providedHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "computedHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "HashMismatch", + "inputs": [ + { + "name": "providedHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "computedHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidActionId", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidCallTarget", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidData", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidEnableSignature", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "hash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidISessionValidator", + "inputs": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + } + ] + }, + { + "type": "error", + "name": "InvalidMode", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidPermissionId", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "InvalidSelfCall", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSession", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "InvalidSessionKeySignature", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "sessionValidator", + "type": "address", + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "userOpHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidTarget", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidUserOpSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NoExecutionsInBatch", + "inputs": [] + }, + { + "type": "error", + "name": "NoPoliciesSet", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "PartlyEnabledActions", + "inputs": [] + }, + { + "type": "error", + "name": "PartlyEnabledPolicies", + "inputs": [] + }, + { + "type": "error", + "name": "PaymasterValidationNotEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "PolicyViolation", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SignerNotFound", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SignerNotFound", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SmartSessionModuleAlreadyInstalled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UnsupportedCallType", + "inputs": [ + { + "name": "callType", + "type": "bytes1", + "internalType": "CallType" + } + ] + }, + { + "type": "error", + "name": "UnsupportedExecutionType", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedPolicy", + "inputs": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UnsupportedSmartSessionMode", + "inputs": [ + { + "name": "mode", + "type": "uint8", + "internalType": "enum SmartSessionMode" + } + ] + } +] diff --git a/packages/core/scripts/agw-local/abis/factory.json b/packages/core/scripts/agw-local/abis/factory.json new file mode 100644 index 000000000..304355405 --- /dev/null +++ b/packages/core/scripts/agw-local/abis/factory.json @@ -0,0 +1,1105 @@ +[ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "DEFAULT_ADMIN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "OPERATOR_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PAUSER_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UPGRADE_INTERFACE_VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "acceptDefaultAdminTransfer", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "beginDefaultAdminTransfer", + "inputs": [ + { + "name": "newAdmin", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "cancelDefaultAdminTransfer", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "changeDefaultAdminDelay", + "inputs": [ + { + "name": "newDelay", + "type": "uint48", + "internalType": "uint48" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "defaultAdmin", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "defaultAdminDelay", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "defaultAdminDelayIncreaseWait", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "deployWallet", + "inputs": [ + { + "name": "label", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "deployWalletWithSig", + "inputs": [ + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "label", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "domainSeparator", + "inputs": [ + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "indexOf", + "inputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "admin", + "type": "address", + "internalType": "address" + }, + { + "name": "walletImplementation_", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isWallet", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "ownerOf", + "inputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pendingDefaultAdmin", + "inputs": [], + "outputs": [ + { + "name": "newAdmin", + "type": "address", + "internalType": "address" + }, + { + "name": "schedule", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pendingDefaultAdminDelay", + "inputs": [], + "outputs": [ + { + "name": "newDelay", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "schedule", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "predictWallet", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "deployed", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "proxiableUUID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "rollbackDefaultAdminDelay", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "unpause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeToAndCall", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "walletCount", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "walletImplementation", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "DefaultAdminDelayChangeCanceled", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "DefaultAdminDelayChangeScheduled", + "inputs": [ + { + "name": "newDelay", + "type": "uint48", + "indexed": false, + "internalType": "uint48" + }, + { + "name": "effectSchedule", + "type": "uint48", + "indexed": false, + "internalType": "uint48" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "DefaultAdminTransferCanceled", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "DefaultAdminTransferScheduled", + "inputs": [ + { + "name": "newAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "acceptSchedule", + "type": "uint48", + "indexed": false, + "internalType": "uint48" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleAdminChanged", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "previousAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleGranted", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleRevoked", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Upgraded", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "WalletDeployed", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "index", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "wallet", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "label", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AccessControlBadConfirmation", + "inputs": [] + }, + { + "type": "error", + "name": "AccessControlEnforcedDefaultAdminDelay", + "inputs": [ + { + "name": "schedule", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "AccessControlEnforcedDefaultAdminRules", + "inputs": [] + }, + { + "type": "error", + "name": "AccessControlInvalidDefaultAdmin", + "inputs": [ + { + "name": "defaultAdmin", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AccessControlUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "neededRole", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CloneArgumentsTooLong", + "inputs": [] + }, + { + "type": "error", + "name": "Create2EmptyBytecode", + "inputs": [] + }, + { + "type": "error", + "name": "ERC1967InvalidImplementation", + "inputs": [ + { + "name": "implementation", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967NonPayable", + "inputs": [] + }, + { + "type": "error", + "name": "EnforcedPause", + "inputs": [] + }, + { + "type": "error", + "name": "ExecutorMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ExpectedPause", + "inputs": [] + }, + { + "type": "error", + "name": "FailedCall", + "inputs": [] + }, + { + "type": "error", + "name": "FailedDeployment", + "inputs": [] + }, + { + "type": "error", + "name": "ImplementationNotSet", + "inputs": [] + }, + { + "type": "error", + "name": "IndexMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "provided", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "type": "error", + "name": "IndexOutOfRange", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "next", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [ + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "IntentWalletMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "provided", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidOwnerSignature", + "inputs": [] + }, + { + "type": "error", + "name": "NotAWallet", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "SafeCastOverflowedUintDowncast", + "inputs": [ + { + "name": "bits", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SignatureExpired", + "inputs": [ + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "UUPSUnauthorizedCallContext", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnsupportedProxiableUUID", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] diff --git a/packages/core/scripts/agw-local/abis/manifest.json b/packages/core/scripts/agw-local/abis/manifest.json new file mode 100644 index 000000000..7c13c7e09 --- /dev/null +++ b/packages/core/scripts/agw-local/abis/manifest.json @@ -0,0 +1,12 @@ +{ + "sourceRepository": "https://github.com/pushchain/push-agentic-wallets", + "sourceCommit": "2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb", + "deployedAbiEvidence": "Donut v5 source compiled in an isolated tree; deployment wiring checked at block 23991912; URP/engine/validator reused from v4", + "files": { + "wallet.json": "6a145964d6c43eaa6223dc8f3d59a540ac0c2f447f25c3a99007eea25de9c695", + "factory.json": "b65f66a1556d2a9ddf9aabe9f595c685c2aada436772892f5a40fb1eec846397", + "policy.json": "c7e3cd5c7f8e47be217c77ebebde4c090903cea7db63024838e434eb6f1fd77b", + "engine.json": "6e5d9cea4dbed01f06af913c1e093743b86b7432195f4a4bf90fb94b282c51b2", + "validator.json": "a4570ddc373241580de8742cbb3d9f2c837094c65bfd1cdd52243ca0c38196fd" + } +} diff --git a/packages/core/scripts/agw-local/abis/policy.json b/packages/core/scripts/agw-local/abis/policy.json new file mode 100644 index 000000000..88e39505a --- /dev/null +++ b/packages/core/scripts/agw-local/abis/policy.json @@ -0,0 +1,2157 @@ +[ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "SEND_OUTBOUND_SELECTOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SESSION_ENGINE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNIVERSAL_EXECUTOR_MODULE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNIVERSAL_GATEWAY_PC", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "assertSpent", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "expectedValueSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expectedAmountSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expectedCalls", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "assertSpent", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "expectedSpent", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "checkAction", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "creditRevert", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "outboundTxId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getConfig", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct Config", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "expectedCEA", + "type": "address", + "internalType": "address" + }, + { + "name": "maxGasPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "assets", + "type": "tuple[]", + "internalType": "struct AssetCapState[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spent", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "allowedCalls", + "type": "tuple[]", + "internalType": "struct AllowedCall[]", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "beneficiaryOffset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "hasBeneficiary", + "type": "bool", + "internalType": "bool" + }, + { + "name": "maxValue", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getMode", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct ModeSlot", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "mode", + "type": "uint8", + "internalType": "enum RulesType" + }, + { + "name": "vm", + "type": "uint8", + "internalType": "enum VmFamily" + }, + { + "name": "chainHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getNativeConfig", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct NativeConfig", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "maxValuePerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxValueTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "valueSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amount", + "type": "tuple", + "internalType": "struct AmountRule", + "components": [ + { + "name": "enabled", + "type": "bool", + "internalType": "bool" + }, + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxTotal", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "amountSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxCalls", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "callsUsed", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "pins", + "type": "tuple[]", + "internalType": "struct ArgPin[]", + "components": [ + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getSvmConfig", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct SvmConfig", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "expectedCEA", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "gatewayProgram", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "maxGasPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "assets", + "type": "tuple[]", + "internalType": "struct AssetCapState[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spent", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "ceaAccounts", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "programs", + "type": "tuple[]", + "internalType": "struct AllowedProgram[]", + "components": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "discriminator", + "type": "bytes8", + "internalType": "bytes8" + }, + { + "name": "discriminatorLen", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "dataless", + "type": "bool", + "internalType": "bool" + }, + { + "name": "maxAccounts", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "name": "pins", + "type": "tuple[]", + "internalType": "struct SvmAccountPin[]", + "components": [ + { + "name": "ruleIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "dataPins", + "type": "tuple[]", + "internalType": "struct SvmDataPin[]", + "components": [ + { + "name": "ruleIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "fromEnd", + "type": "bool", + "internalType": "bool" + }, + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "offsetB", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "len", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "mode", + "type": "uint8", + "internalType": "enum SvmDataPinMode" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "num", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "den", + "type": "uint64", + "internalType": "uint64" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "universalGatewayPC", + "type": "address", + "internalType": "address" + }, + { + "name": "universalExecutorModule", + "type": "address", + "internalType": "address" + }, + { + "name": "sessionEngine", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initializeWithMultiplexer", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "configId", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isCredited", + "inputs": [ + { + "name": "outboundTxId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pushChainHash", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "iid", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "NativeCallMetered", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OutboundMetered", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicySet", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": false, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RevertCredited", + "inputs": [ + { + "name": "outboundTxId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "amountApplied", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesConfigured", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "mode", + "type": "uint8", + "indexed": false, + "internalType": "enum RulesType" + }, + { + "name": "vm", + "type": "uint8", + "indexed": false, + "internalType": "enum VmFamily" + }, + { + "name": "chainHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AllowListOutOfRange", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AlreadyCredited", + "inputs": [ + { + "name": "outboundTxId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AlreadyInitialized", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + } + ] + }, + { + "type": "error", + "name": "AmbiguousRule", + "inputs": [ + { + "name": "i", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "j", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AmountExceedsCap", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AmountExceedsU64", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ArgPinMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AssetListOutOfRange", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AssetNotAllowed", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AssetSpentMismatch", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "BatchSizeOutOfRange", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "BeneficiaryMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CallLimitReached", + "inputs": [ + { + "name": "callsUsed", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "maxCalls", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "type": "error", + "name": "CallNotAllowed", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "CalldataTooShort", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "CalldataTooShortForAmount", + "inputs": [ + { + "name": "actualLength", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "CalldataTooShortForPin", + "inputs": [ + { + "name": "actualLength", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "CallerIsNotUEModule", + "inputs": [ + { + "name": "caller", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CeaAccountAtUnpinnedIndex", + "inputs": [ + { + "name": "accountIndex", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "account", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "CeaAccountsMissExpectedCEA", + "inputs": [] + }, + { + "type": "error", + "name": "ChainMismatch", + "inputs": [ + { + "name": "declared", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "assetChain", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "DiscriminatorLenOutOfRange", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "len", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "DuplicateAsset", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "DuplicateSvmPin", + "inputs": [ + { + "name": "first", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "second", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "EmptyChain", + "inputs": [] + }, + { + "type": "error", + "name": "ForbiddenInnerTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ForbiddenProgramInAllowList", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ForbiddenTargetProgram", + "inputs": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InnerValueExceedsAllowance", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxValue", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidAsset", + "inputs": [ + { + "name": "asset", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidCeaAccount", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidConfigField", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidExpiry", + "inputs": [ + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidRevertRecipient", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSelector", + "inputs": [ + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "InvalidSvmConfigField", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "MalformedInnerCalldata", + "inputs": [] + }, + { + "type": "error", + "name": "MalformedOutboundRequest", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "MalformedSvmPayload", + "inputs": [ + { + "name": "code", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "NativeAmountExceedsCap", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "NativeTargetIsGateway", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NativeTargetZero", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitialized", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "PCValueExceedsCap", + "inputs": [ + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "PayloadNotMulticall", + "inputs": [] + }, + { + "type": "error", + "name": "PolicyNotInitialized", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ProgramListOutOfRange", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ProgramNotAllowed", + "inputs": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "discriminator", + "type": "bytes8", + "internalType": "bytes8" + } + ] + }, + { + "type": "error", + "name": "RecipientMustBeEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "RecipientNotPubkey", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "RecipientTargetMismatch", + "inputs": [ + { + "name": "recipient", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "target", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "RuleWithoutPin", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "RulesExpired", + "inputs": [ + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "SelectorMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "expected", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "SpentLengthMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SpentMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmAccountCountBelowPin", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmAccountCountMismatch", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmAccountPinMismatch", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "SvmAccountsOutOfRange", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataCeilingExceeded", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "ceiling", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataFloorNotMet", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "floor", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataPinInvalid", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataPinMismatch", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "SvmDataRatioNotMet", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "a", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "b", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "num", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "den", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "SvmDataTooShortForPin", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "ixLen", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmInstructionNotExecute", + "inputs": [ + { + "name": "instructionId", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmIxDataTooLong", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmMaxAccountsOutOfRange", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxAccounts", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmPayloadEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "SvmPinIndexOutOfRange", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmPinRuleOutOfRange", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "rule", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "TargetMismatch", + "inputs": [ + { + "name": "actual", + "type": "address", + "internalType": "address" + }, + { + "name": "expected", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "TooManyCeaAccounts", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TooManyPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TooManySvmDataPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TooManySvmPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalNativeAmountExceeded", + "inputs": [ + { + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalSpendCapExceeded", + "inputs": [ + { + "name": "wouldBeTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalValueExceeded", + "inputs": [ + { + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "UncappedGasSwapRejected", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedEnvelopeVersion", + "inputs": [ + { + "name": "version", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "UnsupportedNamespace", + "inputs": [ + { + "name": "chainHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ValueExceedsCap", + "inputs": [ + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ValueOnlyCalldataNotEmpty", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ValueOnlyWithAmountRule", + "inputs": [] + }, + { + "type": "error", + "name": "ValueOnlyWithPins", + "inputs": [] + }, + { + "type": "error", + "name": "WrongModeForCall", + "inputs": [ + { + "name": "actual", + "type": "uint8", + "internalType": "enum RulesType" + } + ] + }, + { + "type": "error", + "name": "WrongVmForCall", + "inputs": [ + { + "name": "actual", + "type": "uint8", + "internalType": "enum VmFamily" + } + ] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] diff --git a/packages/core/scripts/agw-local/abis/validator.json b/packages/core/scripts/agw-local/abis/validator.json new file mode 100644 index 000000000..868308819 --- /dev/null +++ b/packages/core/scripts/agw-local/abis/validator.json @@ -0,0 +1,141 @@ +[ + { + "type": "function", + "name": "isInitialized", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "isModuleType", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onInstall", + "inputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onUninstall", + "inputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "validateConfig", + "inputs": [ + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "validateSignatureWithData", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "validSig", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "error", + "name": "AlreadyInitialized", + "inputs": [ + { + "name": "smartAccount", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "MalformedConfig", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitialized", + "inputs": [ + { + "name": "smartAccount", + "type": "address", + "internalType": "address" + } + ] + } +] diff --git a/packages/core/scripts/agw-local/abis/wallet.json b/packages/core/scripts/agw-local/abis/wallet.json new file mode 100644 index 000000000..5d5f66125 --- /dev/null +++ b/packages/core/scripts/agw-local/abis/wallet.json @@ -0,0 +1,1606 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "sessionEngine_", + "type": "address", + "internalType": "address" + }, + { + "name": "rulesPolicy_", + "type": "address", + "internalType": "address" + }, + { + "name": "sessionValidator_", + "type": "address", + "internalType": "address" + }, + { + "name": "universalGatewayPC_", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "RULES_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SESSION_ENGINE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SESSION_VALIDATOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNIVERSAL_GATEWAY_PC", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "accountId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "agentOf", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "checkpointCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "domainSeparator", + "inputs": [ + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "execute", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "executionCalldata", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "executeAsAgent", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "executionCalldata", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "executeWithSig", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "executionCalldata", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "factory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getNonce", + "inputs": [ + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + } + ], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantNonce", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantRules", + "inputs": [ + { + "name": "session", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "grantRulesWithSig", + "inputs": [ + { + "name": "session", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initializeAccount", + "inputs": [ + { + "name": "label_", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "installModule", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isModuleInstalled", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isValidSignature", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "label", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastCheckpointBlock", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "onERC1155BatchReceived", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onERC1155Received", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onERC721Received", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "revokeAllRules", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRules", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setLabel", + "inputs": [ + { + "name": "label_", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsExecutionMode", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "supportsModule", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "uninstallModule", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "internalType": "address" + }, + { + "name": "deInitData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "AccountInitialized", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "engine", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Checkpointed", + "inputs": [ + { + "name": "seq", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + }, + { + "name": "kind", + "type": "uint8", + "indexed": false, + "internalType": "enum CheckpointKind" + }, + { + "name": "ref", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "blockNumber", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "LabelSet", + "inputs": [ + { + "name": "label", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ModuleInstalled", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ModuleUninstalled", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnerExecuted", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "executionCalldataHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnerExecutedWithSig", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "executionCalldataHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "indexed": false, + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesActionAuthorized", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "agent", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "callsHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesGranted", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "uint8", + "indexed": false, + "internalType": "enum RulesType" + }, + { + "name": "chainHash", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "chainNamespace", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesRevoked", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "UninstallCallbackFailed", + "inputs": [ + { + "name": "module", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AlreadyInitialized", + "inputs": [] + }, + { + "type": "error", + "name": "CallerIsNotAgent", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "caller", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CallerIsNotFactory", + "inputs": [] + }, + { + "type": "error", + "name": "CallerIsNotOwner", + "inputs": [] + }, + { + "type": "error", + "name": "DuplicateAction", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "EmptyChain", + "inputs": [] + }, + { + "type": "error", + "name": "EngineStillHoldsPermissions", + "inputs": [] + }, + { + "type": "error", + "name": "ExecutorMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ForbiddenActionSelector", + "inputs": [ + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "ForbiddenActionTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ForbiddenDispatchTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InconsistentChain", + "inputs": [ + { + "name": "actionIndex", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "IntentExecMismatch", + "inputs": [ + { + "name": "actualCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "IntentGrantNonceMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "provided", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "IntentSessionMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "IntentWalletMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "provided", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidModuleAddress", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidNonce", + "inputs": [ + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "expected", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "provided", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "InvalidOwnerSignature", + "inputs": [] + }, + { + "type": "error", + "name": "LabelTooLong", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "MalformedBatchCalldata", + "inputs": [] + }, + { + "type": "error", + "name": "MalformedSessionShape", + "inputs": [] + }, + { + "type": "error", + "name": "ModuleAlreadyInstalled", + "inputs": [ + { + "name": "module", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OutsideTimeWindow", + "inputs": [ + { + "name": "validAfter", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "OwnerLaneRequired", + "inputs": [ + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + } + ] + }, + { + "type": "error", + "name": "OwnerSigExpired", + "inputs": [ + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "RulesTypeMismatch", + "inputs": [ + { + "name": "derived", + "type": "uint8", + "internalType": "enum RulesType" + }, + { + "name": "actionIndex", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "TooManyActions", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "UnknownPermission", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "UnsupportedExecutionMode", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedModuleType", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ValidationFailed", + "inputs": [ + { + "name": "authorizer", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ValidatorNotInstalled", + "inputs": [ + { + "name": "validator", + "type": "address", + "internalType": "address" + } + ] + } +] diff --git a/packages/core/scripts/agw-local/contracts/HarnessFixtures.sol b/packages/core/scripts/agw-local/contracts/HarnessFixtures.sol new file mode 100644 index 000000000..9f5093508 --- /dev/null +++ b/packages/core/scripts/agw-local/contracts/HarnessFixtures.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +// REVIEW-ONLY FIXTURES for the SDK's local AGW harness. None of this is production +// code and none of it is a model of a deployed Push contract beyond the narrow +// behaviour named on each contract. Live gateway/core/TSS/destination behaviour is +// NOT exercised by anything that uses these fixtures. + +/// @notice Minimal PRC20 stand-in: transfers, allowances, burn and the +/// SOURCE_CHAIN_NAMESPACE view URP interrogates at universal grant. +contract HarnessPRC20 { + string public SOURCE_CHAIN_NAMESPACE; + string private _sourceOverride; + bool private _hasSourceOverride; + function setSourceChainNamespace(string calldata chain) external { SOURCE_CHAIN_NAMESPACE = chain; } + function setSourceTokenAddress(string calldata source) external { + _sourceOverride = source; _hasSourceOverride = true; + } + function SOURCE_TOKEN_ADDRESS() external view returns (string memory) { + if (_hasSourceOverride) return _sourceOverride; + bytes memory chars = "0123456789abcdef"; + bytes memory out = new bytes(42); out[0] = "0"; out[1] = "x"; + uint160 v = uint160(address(this)); + for (uint256 i; i < 40; ++i) out[41-i] = chars[(v >> (4*i)) & 15]; + return string(out); + } + uint8 public immutable decimals; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + uint256 public totalSupply; + + error LowBalance(); + error LowAllowance(); + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory sourceChainNamespace, uint8 decimals_) { + SOURCE_CHAIN_NAMESPACE = sourceChainNamespace; + decimals = decimals_; + } + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + emit Transfer(address(0), to, amount); + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _move(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 a = allowance[from][msg.sender]; + if (a < amount) revert LowAllowance(); + allowance[from][msg.sender] = a - amount; + _move(from, to, amount); + return true; + } + + function burn(uint256 amount) external returns (bool) { + if (balanceOf[msg.sender] < amount) revert LowBalance(); + balanceOf[msg.sender] -= amount; + totalSupply -= amount; + emit Transfer(msg.sender, address(0), amount); + return true; + } + + function _move(address from, address to, uint256 amount) internal { + if (balanceOf[from] < amount) revert LowBalance(); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} + +/// @notice Fixed-quote stand-in for UniversalCore.getOutboundTxGasAndFees. +contract HarnessUniversalCore { + uint256 public gasFee = 1e15; + uint256 public protocolFee = 1e14; + mapping(string => address) public gasTokenPRC20ByChainNamespace; + function setGasToken(string calldata chain, address token) external { gasTokenPRC20ByChainNamespace[chain] = token; } + + function getOutboundTxGasAndFees(address prc20, uint256 gasLimit) + external + view + returns (address gasToken, uint256 gasFee_, uint256 protocolFee_, uint256 gasPrice, string memory chainNamespace, uint256 gasLimitUsed) + { + (bool ok, bytes memory ret) = prc20.staticcall(abi.encodeWithSignature("SOURCE_CHAIN_NAMESPACE()")); + chainNamespace = ok ? abi.decode(ret, (string)) : ""; + return (prc20, gasFee, protocolFee, 1 gwei, chainNamespace, gasLimit == 0 ? 200_000 : gasLimit); + } +} + +struct HarnessOutboundRequest { + bytes recipient; + address token; + uint256 amount; + uint256 gasLimit; + uint256 gasPrice; + uint256 maxPCForGas; + bytes payload; + address revertRecipient; +} + +/// @notice Stub of UniversalGatewayPC's outbound pull. Models ONLY: non-zero +/// token/revertRecipient, transferFrom(msg.sender) + burn of `amount`, +/// msg.value >= protocolFee, maxPCForGas <= msg.value - protocolFee with the +/// excess refunded, and a recorded request. No fee swap, routing, TSS or +/// destination execution. Mirrors gateway bcbf7df UniversalGatewayPC.sol:141-237,430-435. +contract HarnessGatewayPC { + HarnessUniversalCore public immutable universalCore; + uint256 public outboundCount; + bytes32 public lastRequestHash; + address public lastSender; + + error InvalidInput(); + error InsufficientFee(); + + event HarnessOutbound(address indexed sender, address indexed token, uint256 amount, uint256 value, bytes32 requestHash); + + constructor(HarnessUniversalCore core_) { + universalCore = core_; + } + + function sendUniversalTxOutbound(HarnessOutboundRequest calldata req) external payable { + if (req.token == address(0) || req.revertRecipient == address(0)) revert InvalidInput(); + if (req.amount == 0 && req.payload.length == 0) revert InvalidInput(); + uint256 protocolFee = universalCore.protocolFee(); + if (msg.value < protocolFee) revert InsufficientFee(); + uint256 pcForSwap = msg.value - protocolFee; + if (req.maxPCForGas != 0) { + if (req.maxPCForGas > pcForSwap) revert InsufficientFee(); + uint256 refund = pcForSwap - req.maxPCForGas; + if (refund > 0) { + (bool ok,) = msg.sender.call{ value: refund }(""); + require(ok, "refund"); + } + } + if (req.amount > 0) { + HarnessPRC20(req.token).transferFrom(msg.sender, address(this), req.amount); + HarnessPRC20(req.token).burn(req.amount); + } + outboundCount++; + lastSender = msg.sender; + lastRequestHash = keccak256(abi.encode(req)); + emit HarnessOutbound(msg.sender, req.token, req.amount, msg.value, lastRequestHash); + } + + receive() external payable { } +} + +/// @notice Native-rule target: records calls so authorization effects are observable. +contract HarnessTarget { + uint256 public counter; + address public lastBeneficiary; + uint256 public lastAmount; + uint256 public received; + + function increment() external { + counter++; + } + + function deposit(address beneficiary, uint256 amount) external payable { + lastBeneficiary = beneficiary; + lastAmount = amount; + received += msg.value; + counter++; + } + + receive() external payable { + received += msg.value; + } +} + +/// @notice Test-only ERC-7821-shaped executor for real Anvil EIP-7702 transactions. +/// Models self-call authorization and atomic CALL dispatch, not the deployed OZ artifact. +contract HarnessAtomicExecutor { + struct Execution { address target; uint256 value; bytes callData; } + function execute(bytes32 mode, bytes calldata data) external payable { + require(msg.sender == address(this), "self call only"); + require(mode == bytes32(uint256(1) << 248), "batch/default only"); + Execution[] memory calls = abi.decode(data, (Execution[])); + for (uint256 i; i < calls.length; ++i) { + (bool ok, bytes memory ret) = calls[i].target.call{value: calls[i].value}(calls[i].callData); + if (!ok) assembly { revert(add(ret, 32), mload(ret)) } + } + } + receive() external payable {} +} diff --git a/packages/core/scripts/agw-local/gen-abi.mjs b/packages/core/scripts/agw-local/gen-abi.mjs new file mode 100644 index 000000000..0430e31a6 --- /dev/null +++ b/packages/core/scripts/agw-local/gen-abi.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Regenerates packages/core/src/lib/agentic/contracts/abi/v5/*.ts from the +// pinned ABI snapshots in ./abis/. The JSON files are the source artifact set; +// this script only re-serialises them as +// `as const` TypeScript so viem can infer types. Run from the repo root: +// node packages/core/scripts/agw-local/gen-abi.mjs +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, '../../../..'); +const src = join(here, 'abis'); +const dst = join(repo, 'packages/core/src/lib/agentic/contracts/abi/v5'); +const PIN = '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb'; + +const sourceNames = { + AGW: 'wallet', + AGWFactory: 'factory', + UniversalRulesPolicy: 'policy', + SmartSession: 'engine', + AgentValidator: 'validator', +}; +const files = { + AGW: ['AGW_ABI', 'agw.ts'], + AGWFactory: ['AGW_FACTORY_ABI', 'agw-factory.ts'], + UniversalRulesPolicy: [ + 'UNIVERSAL_RULES_POLICY_ABI', + 'universal-rules-policy.ts', + ], + SmartSession: ['SMART_SESSION_ABI', 'smart-session.ts'], + AgentValidator: ['AGENT_VALIDATOR_ABI', 'agent-validator.ts'], +}; +const manifest = JSON.parse(readFileSync(join(src, 'manifest.json'), 'utf8')); +if (manifest.sourceCommit !== PIN) { + throw new Error('ABI source commit does not match the supported generation'); +} + + +mkdirSync(dst, { recursive: true }); +const exports = []; +for (const [name, [constName, file]] of Object.entries(files)) { + const sourceFile = `${sourceNames[name]}.json`; + const raw = readFileSync(join(src, sourceFile)); + const sha = createHash('sha256').update(raw).digest('hex'); + if (sha !== manifest.files[sourceFile]) { + throw new Error(`ABI source hash mismatch: ${sourceFile}`); + } + const abi = JSON.parse(raw.toString()).filter( + (e) => e.type !== 'constructor' + ); + const body = + `// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit.\n` + + `// Source: packages/core/scripts/agw-local/abis/${sourceFile}\n` + + `// push-agentic-wallets@${PIN}\n` + + `// sha256(${name}.json) = ${sha}\n` + + `export const ${constName} = ${JSON.stringify(abi, null, 2)} as const;\n`; + writeFileSync(join(dst, file), body); + exports.push( + `export { ${constName} } from './${file.replace(/\.ts$/, '')}';` + ); +} +writeFileSync( + join(dst, 'index.ts'), + `// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit.\n` + + `// Review generation v5: Donut v5 deployment; source 2e61e13.\n` + + `export const V5_SOURCE_COMMIT = '${PIN}' as const;\n` + + exports.join('\n') + + '\n' +); +console.log(`wrote ${Object.keys(files).length} ABI modules to ${dst}`); diff --git a/packages/core/scripts/agw-local/prepare.sh b/packages/core/scripts/agw-local/prepare.sh new file mode 100755 index 000000000..9cd1923cb --- /dev/null +++ b/packages/core/scripts/agw-local/prepare.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Prepare an ISOLATED build of the pinned AGW contracts for the SDK's local +# contract harness (review-only; never a deployment of record). +# +# AGW_REPO path to a push-agentic-wallets clone (default: ../../../../push-agentic-wallets +# relative to this script). Only `git archive` / `git ls-tree` are run against it, +# so the clone's working tree, branches and index are never touched. +# AGW_PIN commit to extract (default: the reviewed v5). +# OUT destination directory (default: $TMPDIR/push-agw-local-). +# +# The script extracts the pinned tree, fetches each git submodule at the exact +# commit recorded in that tree, copies the harness-only contracts from +# ./contracts into OUT/sdk-harness, and runs `forge build`. It never broadcasts. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGW_REPO="${AGW_REPO:-$HERE/../../../../../push-agentic-wallets}" +AGW_PIN="${AGW_PIN:-2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb}" +OUT="${OUT:-${TMPDIR:-/tmp}/push-agw-local-${AGW_PIN:0:7}}" + +command -v forge >/dev/null || { echo "forge not found (install Foundry)" >&2; exit 1; } +git -C "$AGW_REPO" cat-file -e "${AGW_PIN}^{commit}" 2>/dev/null || { + echo "AGW pin $AGW_PIN is not present in $AGW_REPO; fetch it first" >&2; exit 1; } + +mkdir -p "$OUT" +if [ ! -f "$OUT/.agw-pin" ] || [ "$(cat "$OUT/.agw-pin")" != "$AGW_PIN" ]; then + echo "extracting $AGW_PIN -> $OUT" + git -C "$AGW_REPO" archive "$AGW_PIN" | tar -x -C "$OUT" + # Submodules are gitlinks in the archive; fetch each at its recorded commit. + git -C "$AGW_REPO" ls-tree "$AGW_PIN" lib/ | awk '$2 == "commit" { print $3, $4 }' | + while read -r commit path; do + url="$(git -C "$AGW_REPO" show "$AGW_PIN:.gitmodules" | + git config -f /dev/stdin --get "submodule.$path.url")" + echo "submodule $path @ $commit ($url)" + rm -rf "${OUT:?}/$path" + git init -q "$OUT/$path" + git -C "$OUT/$path" fetch -q --depth 1 "$url" "$commit" + git -C "$OUT/$path" checkout -q FETCH_HEAD + test "$(git -C "$OUT/$path" rev-parse HEAD)" = "$commit" + done + echo "$AGW_PIN" > "$OUT/.agw-pin" +fi + +mkdir -p "$OUT/sdk-harness" +cp "$HERE"/contracts/*.sol "$OUT/sdk-harness/" + +(cd "$OUT" && forge build --offline src sdk-harness lib/smartsessions/contracts/SmartSession.sol lib/openzeppelin-contracts/contracts/proxy >/dev/null) +echo "$OUT" diff --git a/packages/core/scripts/agw-verify.js b/packages/core/scripts/agw-verify.js new file mode 100644 index 000000000..3ac74f72e --- /dev/null +++ b/packages/core/scripts/agw-verify.js @@ -0,0 +1,74 @@ +/** One unfunded verification entry point. No .env loading, live E2E or publishing. */ +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../../..'); +const dir = path.join(root, '.agw-validation'); +fs.mkdirSync(dir, { recursive: true }); +function run(name, file, args, options = {}) { + process.stdout.write(`AGW validation: ${name}\n`); + const result = spawnSync(file, args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + ...options, + }); + const out = String(result.stdout || '') + String(result.stderr || ''); + fs.writeFileSync(path.join(dir, `${name}.log`), out); + if (result.error || result.status !== 0) { + throw new Error( + `AGW validation failed: ${name}; see .agw-validation/${name}.log` + ); + } + return out; +} +const node = process.execPath; +run('units', node, [ + 'node_modules/jest/bin/jest.js', + '--config', + 'packages/core/jest.config.ts', + '--runInBand', + '--testPathPattern=agentic|outbound-(confirmation-gate|svm-confirmation)|svm-idl|evm-client.batch-gas', +]); +for (const kind of ['lib', 'spec', 'agw-local']) + run(`types-${kind}`, node, [ + 'node_modules/typescript/bin/tsc', + '-p', + `packages/core/tsconfig.${kind}.json`, + '--noEmit', + ]); +run('docs', node, ['packages/core/scripts/check-agw-docs.js']); +const env = { + ...process.env, + OUT: process.env.AGW_LOCAL_DIR || path.join(dir, 'contracts'), +}; +run( + 'prepare-contracts', + 'bash', + ['packages/core/scripts/agw-local/prepare.sh'], + { env } +); +run( + 'local-contracts', + node, + [ + 'node_modules/jest/bin/jest.js', + '--config', + 'packages/core/jest.agw-local.config.ts', + '--runInBand', + ], + { env: { ...env, AGW_LOCAL_DIR: env.OUT } } +); +run( + 'build', + node, + ['node_modules/nx/bin/nx.js', 'run', 'core:build', '--skip-nx-cache'], + { env: { ...process.env, NX_DAEMON: 'false' } } +); +run('package', node, ['packages/core/scripts/check-agw-package.js']); +run('public-api', node, [ + 'packages/core/scripts/check-agw-api.js', + '--output', + path.join(dir, 'api-comparison.json'), +]); +process.stdout.write('AGW unfunded validation completed.\n'); diff --git a/packages/core/scripts/check-agw-api.js b/packages/core/scripts/check-agw-api.js new file mode 100644 index 000000000..41cde0232 --- /dev/null +++ b/packages/core/scripts/check-agw-api.js @@ -0,0 +1,424 @@ +/** Public export/type comparison. Report changes; do not approve pending API choices. */ +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const ts = require('typescript'); +const assert = require('node:assert/strict'); +const root = path.resolve(__dirname, '../../..'); +const baseline = 'b1597b91a25dbf7f691f589b4e1e154add604315'; +const config = ts.readConfigFile( + path.join(root, 'packages/core/tsconfig.lib.json'), + ts.sys.readFile +); +const options = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + path.join(root, 'packages/core') +).options; +const selected = [ + 'AgenticNamespace', + 'AgenticWallet', + 'AgenticError', + 'AgenticRevertError', + 'AGENTIC_ERROR_CODE', + 'NativeRule', + 'ArgPin', + 'AmountLimit', + 'AssetCap', + 'AllowedCall', + 'UniversalRule', + 'RulesRecord', + 'CreateOptions', + 'CreateResult', + 'AgenticTxMetadata', +]; +function snapshot(entry) { + const program = ts.createProgram([entry], { ...options, noEmit: true }); + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length) + throw new Error( + ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (f) => f, + getCurrentDirectory: () => root, + getNewLine: () => '\n', + }) + ); + const checker = program.getTypeChecker(), + file = program.getSourceFile(entry); + const exports = checker.getExportsOfModule(checker.getSymbolAtLocation(file)); + const literals = new Map(), + ambiguous = new Set(); + for (const source of program + .getSourceFiles() + .filter((f) => f.fileName.includes('/packages/core/src/'))) { + for (let symbol of checker.getSymbolsInScope( + source, + ts.SymbolFlags.Value + )) { + const name = symbol.name; + if (symbol.flags & ts.SymbolFlags.Alias) + symbol = checker.getAliasedSymbol(symbol); + const type = checker.getTypeOfSymbolAtLocation(symbol, source); + if (type.flags & ts.TypeFlags.EnumLiteral) continue; + const literal = type.isStringLiteral() + ? JSON.stringify(type.value) + : type.isNumberLiteral() + ? String(type.value) + : undefined; + if (literal === undefined) continue; + if (literals.has(name) && literals.get(name) !== literal) + ambiguous.add(name); + else literals.set(name, literal); + } + } + const printer = ts.createPrinter({ removeComments: true }); + const modules = new Map( + program + .getSourceFiles() + .filter((f) => f.fileName.includes('/packages/core/src/')) + .map((f) => [ + 'sdk/src/' + + f.fileName + .split('/src/') + .pop() + .replace(/\.d\.ts$|\.tsx?$/, ''), + f, + ]) + ); + function reference(match, kind, module, name) { + const source = + modules.get(module) || + program.getSourceFile(module + '.d.ts') || + program.getSourceFile(module + '.ts'); + if (!source) return match; + const moduleSymbol = checker.getSymbolAtLocation(source); + let symbol = [ + ...(moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : []), + ...checker.getSymbolsInScope( + source, + ts.SymbolFlags.Type | ts.SymbolFlags.Value | ts.SymbolFlags.Namespace + ), + ].find((s) => s.name === name); + if (!symbol) return match; + if (symbol.flags & ts.SymbolFlags.Alias) + symbol = checker.getAliasedSymbol(symbol); + const decl = symbol.declarations?.[0]; + if (!decl) return match; + if (!kind && symbol.flags & ts.SymbolFlags.TypeAlias) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + if ( + type.flags & + (ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.TemplateLiteral | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined) + ) + return normal( + checker.typeToString( + type, + file, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias + ) + ); + } + if (kind === 'typeof ') { + const type = checker.getTypeOfSymbolAtLocation(symbol, decl); + const calls = type.getCallSignatures(); + if (calls.length === 1) + return normal( + checker.signatureToString( + calls[0], + file, + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.WriteArrowStyleSignature + ) + ); + } + const location = decl.getSourceFile().fileName; + if (!location.includes('/packages/core/src/')) return match; + return `${kind || ''}import("sdk/src/${location + .split('/src/') + .pop() + .replace(/\.d\.ts$|\.tsx?$/, '')}").${symbol.name}`; + } + function canonicalType(text) { + for (let pass = 0; pass < 4; pass++) + text = text.replace( + /(typeof )?import\("([^"]+)"\)\.([A-Za-z_][\w]*)/g, + reference + ); + text = text.replace( + /typeof import\("[^"]+"\)\.([A-Za-z_][\w]*)/g, + (match, name) => + ambiguous.has(name) ? match : literals.get(name) || match + ); + const parsed = ts.createSourceFile( + 'surface.ts', + `type Surface = ${text};`, + ts.ScriptTarget.Latest, + true + ); + const result = ts.transform(parsed, [ + (context) => { + const visit = (node) => { + node = ts.visitEachChild(node, visit, context); + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { + const types = [...node.types].sort((a, b) => + printer + .printNode(ts.EmitHint.Unspecified, a, parsed) + .localeCompare( + printer.printNode(ts.EmitHint.Unspecified, b, parsed) + ) + ); + return ts.isUnionTypeNode(node) + ? ts.factory.createUnionTypeNode(types) + : ts.factory.createIntersectionTypeNode(types); + } + if ( + ts.isTypeLiteralNode(node) && + node.members.every( + (m) => ts.isPropertySignature(m) || ts.isMethodSignature(m) + ) + ) { + return ts.factory.createTypeLiteralNode( + [...node.members].sort((a, b) => + printer + .printNode(ts.EmitHint.Unspecified, a.name, parsed) + .localeCompare( + printer.printNode(ts.EmitHint.Unspecified, b.name, parsed) + ) + ) + ); + } + return node; + }; + return (source) => ts.visitNode(source, visit); + }, + ]); + try { + return printer.printNode( + ts.EmitHint.Unspecified, + result.transformed[0].statements[0].type, + parsed + ); + } finally { + result.dispose(); + } + } + const normal = (s) => + s.replace( + /import\("([^"]+)"\)/g, + (_, p) => + `import("${ + p.includes('/src/') ? 'sdk/src/' + p.split('/src/').pop() : p + }")` + ); + const text = (t, node) => + canonicalType( + normal( + checker.typeToString( + t, + node, + ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias + ) + ) + ); + function properties(t) { + return Object.fromEntries( + checker + .getPropertiesOfType(t) + .filter((p) => { + const d = p.valueDeclaration || p.declarations?.[0]; + if ( + d && + /\/typescript\/lib\/lib\.[^/]+\.d\.ts$/.test( + d.getSourceFile().fileName + ) + ) + return false; + return ( + !d || + !( + ts.getCombinedModifierFlags(d) & + (ts.ModifierFlags.Private | ts.ModifierFlags.Protected) + ) + ); + }) + .map((p) => [ + p.name.replace(/(__@[^@]+)@\d+$/, '$1'), + { + optional: Boolean(p.flags & ts.SymbolFlags.Optional), + readonly: Boolean( + ts.getCheckFlags(p) & ts.CheckFlags.Readonly || + p.declarations?.some( + (d) => + ts.getCombinedModifierFlags(d) & ts.ModifierFlags.Readonly + ) + ), + type: text(checker.getTypeOfSymbolAtLocation(p, file), file), + }, + ]) + .sort(([a], [b]) => a.localeCompare(b)) + ); + } + const result = { + exports: exports.map((s) => s.name).sort(), + agw: {}, + publicSurface: {}, + }; + for (let s of exports) { + const name = s.name; + if (s.flags & ts.SymbolFlags.Alias) s = checker.getAliasedSymbol(s); + const t = + s.flags & + (ts.SymbolFlags.Interface | + ts.SymbolFlags.TypeAlias | + ts.SymbolFlags.Class) + ? checker.getDeclaredTypeOfSymbol(s) + : checker.getTypeOfSymbolAtLocation(s, file); + result.publicSurface[name] = { + type: text(t, file), + members: properties(t), + }; + } + for (const name of selected) { + let s = exports.find((s) => s.name === name); + if (!s) continue; + if (s.flags & ts.SymbolFlags.Alias) s = checker.getAliasedSymbol(s); + const t = + s.flags & + (ts.SymbolFlags.Interface | + ts.SymbolFlags.TypeAlias | + ts.SymbolFlags.Class) + ? checker.getDeclaredTypeOfSymbol(s) + : checker.getTypeOfSymbolAtLocation(s, file); + result.agw[name] = { type: text(t, file), members: properties(t) }; + } + let push = exports.find((s) => s.name === 'PushChain'); + if (push.flags & ts.SymbolFlags.Alias) push = checker.getAliasedSymbol(push); + const staticType = checker.getTypeOfSymbolAtLocation(push, file); + const instanceType = checker.getDeclaredTypeOfSymbol(push); + result.pushChainStatic = properties(staticType); + delete result.pushChainStatic.prototype; + result.pushChainInstance = properties(instanceType); + const universal = instanceType.getProperty('universal'); + result.transactionMethods = properties( + checker.getTypeOfSymbolAtLocation(universal, file) + ); + const utils = staticType.getProperty('utils'); + const utilsType = checker.getTypeOfSymbolAtLocation(utils, file); + result.utilityMethods = properties(utilsType); + delete result.utilityMethods.prototype; + const init = staticType.getProperty('initialize'); + result.initialize = text(checker.getTypeOfSymbolAtLocation(init, file), file); + const constants = staticType.getProperty('CONSTANTS'); + const cType = checker.getTypeOfSymbolAtLocation(constants, file), + agw = cType.getProperty('AGENTIC'); + const aType = checker.getTypeOfSymbolAtLocation(agw, file); + const donut = aType.getProperty('TESTNET_DONUT'); + result.agwConstants = properties( + checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(donut, file)) + ); + result.constants = properties(cType); + return result; +} +const current = snapshot(path.join(root, 'packages/core/src/index.ts')); +const built = snapshot(path.join(root, 'dist/packages/core/src/index.d.ts')); +assert.deepEqual( + built, + current, + 'compiled public API differs from source public API' +); +try { + execFileSync('git', ['cat-file', '-e', `${baseline}^{commit}`], { + cwd: root, + stdio: 'ignore', + }); +} catch { + execFileSync( + 'git', + [ + 'fetch', + '--depth=1', + 'https://github.com/pushchain/push-chain-sdk.git', + baseline, + ], + { cwd: root, stdio: 'ignore' } + ); +} +const temp = fs.mkdtempSync(path.join(root, 'node_modules/.cache/agw-api-')); +try { + const archive = execFileSync( + 'git', + ['archive', baseline, 'packages/core/src'], + { cwd: root, maxBuffer: 30 * 1024 * 1024 } + ); + execFileSync('tar', ['-x', '-C', temp], { input: archive }); + const old = snapshot(path.join(temp, 'packages/core/src/index.ts')); + const changes = []; + for (const name of selected) + if (JSON.stringify(old.agw[name]) !== JSON.stringify(current.agw[name])) + changes.push(name); + const publicTypeChanges = Object.keys(current.publicSurface).filter( + (name) => + JSON.stringify(old.publicSurface[name]) !== + JSON.stringify(current.publicSurface[name]) + ); + const transactionMethodChanges = Object.keys( + current.transactionMethods + ).filter( + (name) => + JSON.stringify(old.transactionMethods[name]) !== + JSON.stringify(current.transactionMethods[name]) + ); + const instanceMethodChanges = Object.keys(current.pushChainInstance).filter( + (name) => + JSON.stringify(old.pushChainInstance[name]) !== + JSON.stringify(current.pushChainInstance[name]) + ); + const report = { + baseline, + head: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(), + compiledMatchesSource: true, + initializeSignatureChanged: old.initialize !== current.initialize, + addedExports: current.exports.filter((s) => !old.exports.includes(s)), + removedExports: old.exports.filter((s) => !current.exports.includes(s)), + changedAgwTypes: changes, + publicTypeChanges, + transactionMethodChanges, + instanceMethodChanges, + agwConstantsChanged: + JSON.stringify(old.agwConstants) !== JSON.stringify(current.agwConstants), + before: old, + after: current, + scope: + 'All root exports and declared public members, PushChain static/instance members, universal transaction methods, utility groups, readonly/optional markers and constants. Behavior/default/deployment changes need the written session review.', + }; + const at = process.argv.indexOf('--output'); + if (at >= 0) + fs.writeFileSync( + path.resolve(process.argv[at + 1]), + JSON.stringify(report, null, 2) + '\n' + ); + process.stdout.write( + JSON.stringify({ + compiledMatchesSource: true, + addedExports: report.addedExports, + removedExports: report.removedExports, + changedAgwTypes: changes, + publicTypeChanges, + transactionMethodChanges, + instanceMethodChanges, + initializeSignatureChanged: report.initializeSignatureChanged, + agwConstantsChanged: report.agwConstantsChanged, + }) + '\n' + ); +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/packages/core/scripts/check-agw-docs.js b/packages/core/scripts/check-agw-docs.js new file mode 100644 index 000000000..ec10c4b13 --- /dev/null +++ b/packages/core/scripts/check-agw-docs.js @@ -0,0 +1,50 @@ +/** Typecheck public AGW guide blocks; never evaluate examples or load keys. */ +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '../../..'); +const guide = path.join(root, 'packages/core/AGW.md'); +const blocks = [ + ...fs.readFileSync(guide, 'utf8').matchAll(/```ts\n([\s\S]*?)```/g), +].map((m) => m[1]); +if (!blocks.length) throw new Error('No AGW TypeScript examples found'); +const cache = path.join(root, 'node_modules/.cache'); +fs.mkdirSync(cache, { recursive: true }); +const dir = fs.mkdtempSync(path.join(cache, 'agw-docs-')); +try { + const source = path.join(dir, 'examples.ts'); + fs.writeFileSync(source, blocks.join('\n\n')); + const configPath = path.join(root, 'packages/core/tsconfig.lib.json'); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) + throw new Error( + ts.flattenDiagnosticMessageText(config.error.messageText, '\n') + ); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + path.dirname(configPath) + ); + const program = ts.createProgram([source], { + ...parsed.options, + noEmit: true, + }); + const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)]; + if (diagnostics.length) { + process.stderr.write( + ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (file) => file, + getCurrentDirectory: () => root, + getNewLine: () => '\n', + }) + ); + process.exitCode = 1; + } else { + process.stdout.write( + `AGW guide: ${blocks.length} TypeScript blocks typechecked against public exports; no examples executed.\n` + ); + } +} finally { + fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/packages/core/scripts/check-agw-package.js b/packages/core/scripts/check-agw-package.js new file mode 100644 index 000000000..83cc7805c --- /dev/null +++ b/packages/core/scripts/check-agw-package.js @@ -0,0 +1,74 @@ +/** Consume built JS/declarations, not tsconfig's source alias. No wallet operations. */ +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { execFileSync } = require('node:child_process'); +const assert = require('node:assert/strict'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '../../..'); +const built = path.join(root, 'dist/packages/core'); +async function main() { + const entry = path.join(built, 'src/index.js'); + const cjs = require(entry), + esm = await import(pathToFileURL(entry).href); + assert.equal(typeof cjs.PushChain.initialize, 'function'); + assert.equal(esm.PushChain, cjs.PushChain); + assert.equal( + cjs.PushChain.CONSTANTS.AGENTIC.TESTNET_DONUT.ENVELOPE_VERSION, + 1 + ); + assert.equal(typeof cjs.AgenticError, 'function'); + const [pack] = JSON.parse( + execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: built, + encoding: 'utf8', + }) + ); + assert(pack.files.some((f) => f.path === 'AGW.md')); + assert(!pack.files.some((f) => /(^|\/)\.env($|\.)/.test(f.path))); + const guide = fs.readFileSync(path.join(built, 'AGW.md'), 'utf8'); + const source = [...guide.matchAll(/```ts\n([\s\S]*?)```/g)] + .map((m) => m[1]) + .join('\n\n'); + const tmp = fs.mkdtempSync( + path.join(root, 'node_modules/.cache/agw-consumer-') + ); + try { + const file = path.join(tmp, 'consumer.ts'); + fs.writeFileSync(file, source); + const config = ts.readConfigFile( + path.join(root, 'packages/core/tsconfig.lib.json'), + ts.sys.readFile + ); + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + path.join(root, 'packages/core') + ); + const options = { + ...parsed.options, + noEmit: true, + paths: { '@pushchain/core': [path.join(built, 'src/index.d.ts')] }, + }; + const diagnostics = ts.getPreEmitDiagnostics( + ts.createProgram([file], options) + ); + if (diagnostics.length) + throw new Error( + ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (f) => f, + getCurrentDirectory: () => root, + getNewLine: () => '\n', + }) + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + process.stdout.write( + 'AGW package: CJS/ESM imports, compiled declaration examples and npm contents pass. No examples executed/published.\n' + ); +} +main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +}); diff --git a/packages/core/scripts/check-agw-runtimes.js b/packages/core/scripts/check-agw-runtimes.js new file mode 100644 index 000000000..6161bc730 --- /dev/null +++ b/packages/core/scripts/check-agw-runtimes.js @@ -0,0 +1,93 @@ +/** Local runtime matrix only. Never dispatch GitHub CI, fund E2Es or publish. */ +const fs = require('node:fs'), + path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const root = path.resolve(__dirname, '../../..'); +const argument = (name) => { + const at = process.argv.indexOf(name); + return at < 0 ? undefined : process.argv[at + 1]; +}; +const binaries = [ + { major: 20, node: argument('--node20') || process.execPath }, + { major: 24, node: argument('--node24') }, +]; +if (!binaries[1].node) + throw new Error( + 'Provide --node24 ; --node20 defaults to the current executable.' + ); +const output = path.resolve( + argument('--output') || path.join(root, '.agw-runtime-validation') +); +fs.mkdirSync(output, { recursive: true }); +const results = []; +function command(dir, node, args, env, name) { + const result = spawnSync(node, args, { + cwd: root, + env, + encoding: 'utf8', + maxBuffer: 30 * 1024 * 1024, + }); + fs.writeFileSync( + path.join(dir, name + '.log'), + String(result.stdout || '') + String(result.stderr || '') + ); + if (result.error || result.status !== 0) + throw new Error(`Node ${node} failed ${name}; see ${dir}/${name}.log`); + return result.stdout; +} +for (const runtime of binaries) { + const dir = path.join(output, 'node' + runtime.major); + fs.mkdirSync(dir, { recursive: true }); + const env = { + ...process.env, + PATH: path.dirname(runtime.node) + path.delimiter + process.env.PATH, + }; + const version = command( + dir, + runtime.node, + [ + '-p', + 'JSON.stringify({version:process.version,major:Number(process.versions.node.split(".")[0]),platform:process.platform,arch:process.arch})', + ], + env, + 'version' + ); + const actual = JSON.parse(version); + if (actual.major !== runtime.major) + throw new Error(`Expected Node ${runtime.major}, got ${actual.version}`); + process.stdout.write(`AGW runtime matrix: ${actual.version}\n`); + command( + dir, + runtime.node, + [ + 'node_modules/jest/bin/jest.js', + '--config', + 'packages/core/jest.config.ts', + '--runInBand', + ], + env, + 'full-unit' + ); + command( + dir, + runtime.node, + ['packages/core/scripts/agw-verify.js'], + env, + 'verification' + ); + for (const file of fs.readdirSync(path.join(root, '.agw-validation'))) { + const source = path.join(root, '.agw-validation', file); + if (fs.statSync(source).isFile()) + fs.copyFileSync(source, path.join(dir, file)); + } + results.push({ ...actual, passed: true }); +} +fs.writeFileSync( + path.join(output, 'results.json'), + JSON.stringify( + { results, hostedCi: false, published: false, fundedE2Es: false }, + null, + 2 + ) + '\n' +); +process.stdout.write('Both runtime matrix entries passed.\n'); diff --git a/packages/core/src/lib/agentic/__fixtures__/v5-vectors.json b/packages/core/src/lib/agentic/__fixtures__/v5-vectors.json new file mode 100644 index 000000000..3f037d15d --- /dev/null +++ b/packages/core/src/lib/agentic/__fixtures__/v5-vectors.json @@ -0,0 +1,181 @@ +{ + "provenance": { + "agwCommit": "2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb", + "generatedBy": "packages/core/__agw-local__/vectors.spec.ts", + "toolchain": "forge Version: 1.5.1-stable", + "chainId": 9000, + "note": "Addresses are deterministic for a fresh anvil + the harness deploy order." + }, + "rulesId": [ + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "grantNonce": { + "$bigint": "0" + }, + "agentConfig": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "expected": "0xed16789aedd36446aa851df227d231dfdaab75ab95d022e251d179e27fdcee59" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "grantNonce": { + "$bigint": "1" + }, + "agentConfig": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "expected": "0xb5cf1093fb5b4109923a54fc9a67eff369e01392403b645e8e0155a6cb4990fe" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "grantNonce": { + "$bigint": "7" + }, + "agentConfig": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "expected": "0xc05ecd2b8ea27dcaff748d5027fcc6b7963b9bf5452ccc6e3016235a878a2da3" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "grantNonce": { + "$bigint": "9223372036854775808" + }, + "agentConfig": "0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8", + "expected": "0xc568da4c1ecd6f73d2609cba4bccba9108f9b3b43e13797e63aa25db68a6ea79" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "grantNonce": { + "$bigint": "0" + }, + "agentConfig": "0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc", + "expected": "0x0a7234266892a3ade6ba3f70c1a4c0b56aefb198e74a14a5d6d08c5f8587498e" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "grantNonce": { + "$bigint": "1" + }, + "agentConfig": "0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc", + "expected": "0x66c8518bd08fc86347cb20601b22df298d73794e99c6eb9250f52ce78c949674" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "grantNonce": { + "$bigint": "7" + }, + "agentConfig": "0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc", + "expected": "0x11be56b6d0b4b6ba03b6475bdbe4393f68e689d690a5428821664947c0e9df3a" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "grantNonce": { + "$bigint": "9223372036854775808" + }, + "agentConfig": "0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc", + "expected": "0x35257788092b8179db6e880a636bc3114dbe9b1b30445d3551218ccc682478df" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x00000000000000000000000000000000000000ff", + "grantNonce": { + "$bigint": "0" + }, + "agentConfig": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "expected": "0x675a172b2a29803a43c4c478a43b68730a6d74aee6102750b59bc49d29b70b47" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x00000000000000000000000000000000000000ff", + "grantNonce": { + "$bigint": "1" + }, + "agentConfig": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "expected": "0x1ba472d29a9d5f904acd8d6682ab6beb04026365da11ec4f7142231ac3eb592b" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x00000000000000000000000000000000000000ff", + "grantNonce": { + "$bigint": "7" + }, + "agentConfig": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "expected": "0xb5e300538b76a563882467a58f2073ed1677234609d698d22bb441814355f12c" + }, + { + "validator": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "agent": "0x00000000000000000000000000000000000000ff", + "grantNonce": { + "$bigint": "9223372036854775808" + }, + "agentConfig": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "expected": "0x7b190cef2be98a983c96b0c4653480549c1f7ce35232f400dff5c6ce7f551f11" + } + ], + "deriveWallet": [ + { + "factory": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", + "walletImplementation": "0x0165878A594ca255338adfa4d48449f69242Eb8F", + "owner": "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + "index": 0, + "expected": "0xE68bD7D2E9b2F21bA9e4e09cE2D20A3d681Ec6e1" + }, + { + "factory": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", + "walletImplementation": "0x0165878A594ca255338adfa4d48449f69242Eb8F", + "owner": "0x1111111111111111111111111111111111111111", + "index": 0, + "expected": "0x3066c721A477afBF4c3732F39A0048684e4641cF" + } + ], + "native": { + "rule": { + "agent": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "target": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "selector": "deposit(address,uint256)", + "validUntil": 4102444800, + "pins": [ + { + "arg": 0, + "expected": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" + } + ], + "amount": { + "arg": 1, + "maxPerCall": { + "$bigint": "10" + }, + "maxTotal": { + "$bigint": "100" + } + }, + "maxValuePerCall": { + "$bigint": "5" + }, + "maxValueTotal": { + "$bigint": "50" + }, + "maxCalls": 3 + }, + "nativeChain": "eip155:9000", + "wallet": "0x3e8FE21F60352CE4e632537113451A6990b9b7e6", + "rulesId": "0xed16789aedd36446aa851df227d231dfdaab75ab95d022e251d179e27fdcee59", + "actionId": "0x95eb75f6723928adc7fd1b4edeec4a558cc76cef41e06b7a16448bf74364d791", + "configId": "0x939681af8de835b5dde52d6056defa47df21b9325892b5945f113a8112179e69", + "configInitialized": true, + "storedTarget": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "storedSelector": "0x47e7ef24", + "storedPins": [ + { + "offset": 4, + "expected": "0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc" + } + ], + "storedAmountOffset": 36, + "encodedSession": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f051200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002047e7ef24000000000000000000000000000000000000000000000000000000000000000000000000000000008a791620dd6260079bf849dc5567adc3f2fdc318000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cf7ed3acca5a467e9e704c703e8d87f634fb0fc9000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000b6569703135353a3930303000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000f48657000000000000000000000000008a791620dd6260079bf849dc5567adc3f2fdc31847e7ef24000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000040000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc" + } +} diff --git a/packages/core/src/lib/agentic/__tests__/codec.spec.ts b/packages/core/src/lib/agentic/__tests__/codec.spec.ts new file mode 100644 index 000000000..6721ad685 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/codec.spec.ts @@ -0,0 +1,444 @@ +import { decodeFunctionData, getAddress, type Address, type Hex } from 'viem'; +import vectorsJson from '../__fixtures__/v5-vectors.json'; +import { + actionId, + agentConfig, + configId, + deriveWallet, + rulesId, +} from '../codec/ids'; +import { + decodeNativeTerms, + encodeNativeTerms, + nativeRuleToTerms, + nativeTermsToRule, +} from '../codec/native'; +import { + buildSession, + decodeEnvelope, + decodeSession, + encodeEnvelope, + encodeSession, +} from '../codec/session'; +import { parseSelector, VALUE_ONLY_SELECTOR } from '../codec/selectors'; +import { argumentOffset, headSize } from '../codec/abi-layout'; +import { UINT256_MAX } from '../codec/defaults'; +import { v5 } from '../contracts/v5'; +import { AGW_ABI } from '../contracts/abi/v5'; +import { AGENTIC_ERROR_CODE } from '../errors'; +import type { NativeRule } from '../agentic.types'; + +/** Restore {$bigint} markers written by the vector generator. */ +function revive(v: T): T { + return JSON.parse(JSON.stringify(v), (_k, x) => + x && typeof x === 'object' && '$bigint' in x ? BigInt(x.$bigint) : x + ); +} +const vectors = revive(vectorsJson) as unknown as { + rulesId: { + validator: Address; + agent: Address; + grantNonce: bigint; + agentConfig: Hex; + expected: Hex; + }[]; + deriveWallet: { + factory: Address; + walletImplementation: Address; + owner: Address; + index: number; + expected: Address; + }[]; + native: { + rule: NativeRule; + nativeChain: string; + wallet: Address; + rulesId: Hex; + actionId: Hex; + configId: Hex; + configInitialized: boolean; + storedTarget: Address; + storedSelector: Hex; + storedPins: { offset: number; expected: Hex }[]; + storedAmountOffset: number; + encodedSession: Hex; + }; +}; + +const NOW = 1_700_000_000; +const target = getAddress('0x0000000000000000000000000000000000007a76'); +const agent = getAddress('0x00000000000000000000000000000000000000a9'); + +describe('IDs against contract-generated vectors (v5)', () => { + it.each(vectors.rulesId)( + 'rulesId(agent, nonce) = engine.getPermissionId — nonce $grantNonce', + (v) => { + expect(agentConfig(v.agent)).toBe(v.agentConfig); + expect( + rulesId({ + validator: v.validator, + agent: v.agent, + grantNonce: v.grantNonce, + }) + ).toBe(v.expected); + } + ); + + it.each(vectors.deriveWallet)( + 'deriveWallet mirrors factory.predictWallet for $owner', + (v) => { + expect(deriveWallet(v)).toBe(getAddress(v.expected)); + } + ); + + it('actionId/configId: the policy holds an initialized config under exactly this key', () => { + const n = vectors.native; + expect(n.configInitialized).toBe(true); + expect(actionId(getAddress(n.storedTarget), n.storedSelector)).toBe( + n.actionId + ); + expect(configId(n.wallet, n.rulesId, n.actionId)).toBe(n.configId); + }); + + it('rulesId rejects a grant nonce outside uint64', () => { + expect(() => + rulesId({ validator: agent, agent, grantNonce: BigInt(2) ** BigInt(64) }) + ).toThrow(/uint64/); + }); +}); + +describe('native rule codec', () => { + const v = vectors.native; + + it('encodes the session the contracts accepted and stored', () => { + const { terms } = nativeRuleToTerms(v.rule, { nowSeconds: NOW }); + const session = buildSession({ + validator: decodeSession(v.encodedSession).sessionValidator, + agent: v.rule.agent, + rulesPolicy: decodeSession(v.encodedSession).actions[0].actionPolicies[0] + .policy, + actions: [ + { + target: terms.target, + selector: terms.selector, + initData: encodeEnvelope(v.nativeChain, encodeNativeTerms(terms)), + }, + ], + }); + expect(encodeSession(session)).toBe(v.encodedSession); + expect(terms.pins).toEqual(v.storedPins); + expect(terms.amount.offset).toBe(v.storedAmountOffset); + expect(terms.selector).toBe(v.storedSelector); + }); + + it('round-trips terms through the envelope and back to a public rule', () => { + const { terms } = nativeRuleToTerms(v.rule, { nowSeconds: NOW }); + const env = decodeEnvelope( + encodeEnvelope(v.nativeChain, encodeNativeTerms(terms)) + ); + expect(env.chainNamespace).toBe(v.nativeChain); + const back = decodeNativeTerms(env.body); + expect(back).toEqual({ ...terms, validUntil: terms.validUntil }); + const rule = nativeTermsToRule(back, v.rule.agent); + // Stored terms carry offsets, not an ABI: decoded rules report the raw offset form. + expect(rule.pins).toEqual([ + { offset: 4, expected: terms.pins[0].expected.toLowerCase() }, + ]); + expect(rule.amount).toEqual({ + offset: 36, + maxPerCall: BigInt(10), + maxTotal: BigInt(100), + }); + expect(rule).not.toHaveProperty('ref'); + // …and the decoded rule re-encodes to identical terms with only its 4-byte selector. + expect(nativeRuleToTerms(rule, { nowSeconds: NOW }).terms).toEqual(terms); + }); + + it('decoding never invents argument indexes: a static array before a pin stays a raw offset', () => { + const input: NativeRule = { + agent, + target, + selector: 'deposit(uint256[2],address)', + validUntil: NOW + 10, + pins: [{ arg: 1, expected: agent }], + }; + const { terms } = nativeRuleToTerms(input, { nowSeconds: NOW }); + expect(terms.pins[0].offset).toBe(4 + 64); + const decoded = nativeTermsToRule(terms, agent); + expect(decoded.pins).toEqual([ + { offset: 68, expected: terms.pins[0].expected }, + ]); + expect(nativeRuleToTerms(decoded, { nowSeconds: NOW }).terms).toEqual( + terms + ); + }); + + it('a decoded address pin re-encodes in argument form once the signature is restored', () => { + const input: NativeRule = { + agent, + target, + selector: 'deposit(address,uint256)', + validUntil: NOW + 10, + pins: [{ arg: 0, expected: agent }], + }; + const { terms } = nativeRuleToTerms(input, { nowSeconds: NOW }); + const word = terms.pins[0].expected; + expect( + nativeRuleToTerms( + { ...input, pins: [{ arg: 0, expected: word }] }, + { nowSeconds: NOW } + ).terms + ).toEqual(terms); + expect(() => + nativeRuleToTerms( + { ...input, pins: [{ arg: 0, expected: `0x${'ff'.repeat(32)}` }] }, + { nowSeconds: NOW } + ) + ).toThrow(/does not encode/); + }); + + it('offset-form pins must be exact words at a valid offset', () => { + const base: NativeRule = { + agent, + target, + selector: '0xd09de08a', + validUntil: NOW + 10, + }; + expect(() => + nativeRuleToTerms( + { ...base, pins: [{ offset: 3, expected: `0x${'00'.repeat(32)}` }] }, + { nowSeconds: NOW } + ) + ).toThrow(/offset/); + expect(() => + nativeRuleToTerms( + { ...base, pins: [{ offset: 4, expected: '0x01' }] }, + { nowSeconds: NOW } + ) + ).toThrow(/32-byte/); + expect( + nativeRuleToTerms( + { ...base, pins: [{ offset: 36, expected: `0x${'00'.repeat(31)}01` }] }, + { nowSeconds: NOW } + ).terms.pins + ).toEqual([{ offset: 36, expected: `0x${'00'.repeat(31)}01` }]); + }); + + it('applies confirmed zero PC defaults and independent token/call defaults', () => { + const { terms } = nativeRuleToTerms( + { + agent, + target, + selector: 'f(uint256)', + validUntil: NOW + 10, + amount: { arg: 0, maxPerCall: BigInt(1) }, + }, + { nowSeconds: NOW } + ); + expect(terms.maxValuePerCall).toBe( + BigInt(0) + ); + expect(terms.maxValueTotal).toBe(BigInt(0)); + expect(terms.maxCalls).toBe(0); + expect(terms.amount.maxTotal).toBe(UINT256_MAX); + }); + + it('rejects ambiguous arg-plus-offset authoring for both pins and amount', () => { + const rule = { agent, target, selector: 'transfer(address,uint256)' as const, validUntil: NOW + 10 }; + expect(() => nativeRuleToTerms({ ...rule, pins: [{ arg: 0, offset: 4, expected: `0x${'00'.repeat(32)}` } as never] }, { nowSeconds: NOW })).toThrow('mutually exclusive'); + expect(() => nativeRuleToTerms({ ...rule, amount: { arg: 1, offset: 36, maxPerCall: BigInt(1) } as never }, { nowSeconds: NOW })).toThrow('mutually exclusive'); + }); + + it('keeps an explicit zero as zero', () => { + const { terms } = nativeRuleToTerms( + { + agent, + target, + selector: 'f(uint256)', + validUntil: NOW + 10, + amount: { arg: 0, maxPerCall: BigInt(1), maxTotal: BigInt(0) }, + }, + { nowSeconds: NOW } + ); + expect(terms.amount.maxTotal).toBe(BigInt(0)); + }); + + it('value-only rules encode the reserved selector and refuse pins/amount', () => { + const { terms } = nativeRuleToTerms( + { agent, target, selector: 'value-only', validUntil: NOW + 10 }, + { nowSeconds: NOW } + ); + expect(terms.selector).toBe(VALUE_ONLY_SELECTOR); + expect(() => + nativeRuleToTerms( + { + agent, + target, + selector: 'value-only', + validUntil: NOW + 10, + pins: [{ arg: 0, expected: agent }], + }, + { nowSeconds: NOW } + ) + ).toThrow(/value-only/); + }); + + it.each([ + ['past expiry', { validUntil: NOW }], + ['zero target', { target: '0x0000000000000000000000000000000000000000' }], + [ + 'too many pins', + { + selector: 'f(uint256)', + pins: Array.from({ length: 9 }, () => ({ + arg: 0, + expected: BigInt(1), + })), + }, + ], + [ + 'pin needs a signature', + { selector: '0x12345678', pins: [{ arg: 0, expected: BigInt(1) }] }, + ], + [ + 'pin on a dynamic argument', + { selector: 'f(bytes)', pins: [{ arg: 0, expected: '0x01' }] }, + ], + [ + 'pin index out of range', + { selector: 'f(uint256)', pins: [{ arg: 1, expected: BigInt(1) }] }, + ], + [ + 'pin value of the wrong type', + { selector: 'f(address)', pins: [{ arg: 0, expected: BigInt(1) }] }, + ], + [ + 'amount on a non-uint', + { selector: 'f(address)', amount: { arg: 0, maxPerCall: BigInt(1) } }, + ], + [ + 'amount also pinned', + { + selector: 'f(uint256)', + pins: [{ arg: 0, expected: BigInt(1) }], + amount: { arg: 0, maxPerCall: BigInt(1) }, + }, + ], + ['negative cap', { maxValuePerCall: BigInt(-1) }], + ['maxCalls beyond uint32', { maxCalls: 2 ** 32 }], + ])('rejects %s before signing', (_name, patch) => { + const rule = { + agent, + target, + selector: 'increment()', + validUntil: NOW + 10, + ...patch, + } as NativeRule; + expect(() => nativeRuleToTerms(rule, { nowSeconds: NOW })).toThrow( + expect.objectContaining({ code: AGENTIC_ERROR_CODE.INVALID_RULE }) + ); + }); + + it('approval policy is external while caller-supplied pins remain ABI-validated', () => { + const base = { agent, target, validUntil: NOW + 10 }; + expect(() => + nativeRuleToTerms( + { ...base, selector: 'approve(address,uint256)' }, + { nowSeconds: NOW } + ) + ).not.toThrow(); + expect(() => + nativeRuleToTerms( + { + ...base, + selector: 'approve(address,uint256)', + pins: [{ arg: 0, expected: agent }], + }, + { nowSeconds: NOW } + ) + ).not.toThrow(); + expect(() => + nativeRuleToTerms( + { + ...base, + selector: + 'permit(address,address,uint256,uint256,uint8,bytes32,bytes32)', + pins: [{ arg: 0, expected: agent }], + }, + { nowSeconds: NOW } + ) + ).not.toThrow(); + }); +}); + +describe('ABI layout offsets', () => { + it('accounts for dynamic heads and multi-word static tuples before the pinned argument', () => { + const dyn = parseSelector('f(bytes,address)'); + expect(argumentOffset(dyn.inputs, 1, 'pin')).toBe(4 + 32); + const tup = parseSelector('f((uint256,uint256),address)'); + expect(headSize(tup.inputs![0])).toBe(64); + expect(argumentOffset(tup.inputs, 1, 'pin')).toBe(4 + 64); + const arr = parseSelector('f(uint256[3],address)'); + expect(argumentOffset(arr.inputs, 1, 'pin')).toBe(4 + 96); + const dynTuple = parseSelector('f((uint256,bytes),address)'); + expect(argumentOffset(dynTuple.inputs, 1, 'pin')).toBe(4 + 32); + }); + + it('rejects pinning a tuple', () => { + const tup = parseSelector('f((uint256,uint256),address)'); + expect(() => argumentOffset(tup.inputs, 0, 'pin')).toThrow(/single-word/); + }); + + it('parses hex, signature and value-only selectors', () => { + expect(parseSelector('0x617BA037').selector).toBe('0x617ba037'); + expect( + parseSelector('supply(address,uint256,address,uint16)').selector + ).toBe('0x617ba037'); + expect(parseSelector('value-only').valueOnly).toBe(true); + expect(() => parseSelector('not a selector' as never)).toThrow(); + }); +}); + +describe('wallet call encoders decode back to the same calls', () => { + const call = { target, value: BigInt(7), data: '0xd09de08a' as Hex }; + + it('execute single uses packed ERC-7579 calldata', () => { + const data = v5.encodeExecute([call]); + const { functionName, args } = decodeFunctionData({ abi: AGW_ABI, data }); + expect(functionName).toBe('execute'); + expect(args[0]).toBe(`0x${'00'.repeat(32)}`); + expect((args[1] as Hex).length).toBe(2 + 2 * (20 + 32 + 4)); + expect(v5.decodeWalletCall(data)).toEqual({ + kind: 'execute', + mode: args[0], + calls: [call], + }); + }); + + it('execute batch uses abi.encode(Execution[])', () => { + const data = v5.encodeExecute([call, { ...call, value: BigInt(0) }]); + const decoded = v5.decodeWalletCall(data); + expect(decoded).toMatchObject({ + kind: 'execute', + mode: `0x01${'00'.repeat(31)}`, + }); + expect((decoded as { calls: unknown[] }).calls).toEqual([ + call, + { ...call, value: BigInt(0) }, + ]); + }); + + it('executeAsAgent carries the rule ID and one packed call', () => { + const id = `0x${'42'.repeat(32)}` as Hex; + expect(v5.decodeWalletCall(v5.encodeExecuteAsAgent(id, call))).toEqual({ + kind: 'executeAsAgent', + rulesId: id, + mode: `0x${'00'.repeat(32)}`, + calls: [call], + }); + }); + + it('unknown calldata is not mistaken for a wallet call', () => { + expect(v5.decodeWalletCall('0xdeadbeef')).toBeNull(); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/fake-chain.ts b/packages/core/src/lib/agentic/__tests__/fake-chain.ts new file mode 100644 index 000000000..d56212bf9 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/fake-chain.ts @@ -0,0 +1,415 @@ +/** + * In-memory stand-in for the AGW contract views used by unit tests. It models + * only what the SDK reads (factory registry/prediction, wallet wiring, + * engine enumeration, URP mode/native config, checkpoints and logs), so the + * SDK's own logic — selection, consistency, error mapping — is what is tested. + * Real-contract behaviour is covered by the local harness (__agw-local__). + */ +import { + encodeAbiParameters, + encodeEventTopics, + getAddress, + keccak256, + toBytes, + type AbiEvent, + type Address, + type Hex, + type Log, + type TransactionReceipt, +} from 'viem'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { v5 } from '../contracts/v5'; +import type { ChainReader } from '../contracts/reader'; +import { actionId, configId, deriveWallet } from '../codec/ids'; +import { + registerAgenticGeneration, + resetAgenticGenerations, + type AgenticGeneration, +} from '../deployments'; +import type { NativeConfigRead } from '../contracts/v5'; + +export const ADDR = { + factory: getAddress('0x00000000000000000000000000000000000fac70'), + impl: getAddress('0x00000000000000000000000000000000000001a1'), + engine: getAddress('0x00000000000000000000000000000000000e0e0e'), + policy: getAddress('0x0000000000000000000000000000000000000b01'), + validator: getAddress('0x0000000000000000000000000000000000000a1d'), + gateway: getAddress('0x00000000000000000000000000000000000000c1'), + owner: getAddress('0x00000000000000000000000000000000000000a0'), + agent: getAddress('0x00000000000000000000000000000000000000a9'), + other: getAddress('0x00000000000000000000000000000000000000b9'), + target: getAddress('0x0000000000000000000000000000000000007a76'), +}; + +export const PUSH_NS = 'eip155:42101'; +export const SEPOLIA_NS = 'eip155:11155111'; + +export interface FakeRule { + rulesId: Hex; + agent: Address; + mode: number; // 1 native, 0 universal + chain: string; + actionIds: Hex[]; + native?: Partial; + universal?: { + assets: readonly { + token: Address; + maxPerCall?: bigint; + maxTotal?: bigint; + spent?: bigint; + }[]; + expectedCEA: Address; + maxGasPerCall: bigint; + }; +} + +export interface FakeWallet { + address: Address; + owner: Address; + index: bigint; + label: string; + rules: FakeRule[]; + checkpointCount: bigint; + grantNonce: bigint; + accountId?: string; +} + +export class FakeChain implements ChainReader { + policyVersion = '3.1.0'; + sourceTokens = new Map(); + block = BigInt(100); + wallets = new Map(); + walletCounts = new Map(); + balances = new Map(); + checkpointLogs: Log[] = []; + extraLogs: Log[] = []; + failOn = new Set(); + calls: { functionName: string; blockNumber?: bigint }[] = []; + /** Override the factory's prediction (to simulate derivation drift). */ + predictOverride?: (owner: Address, index: bigint) => Address; + receipts = new Map(); + + addWallet(owner: Address, label: string, rules: FakeRule[] = []): FakeWallet { + const index = this.walletCounts.get(owner.toLowerCase()) ?? BigInt(0); + const address = deriveWallet({ + factory: ADDR.factory, + walletImplementation: ADDR.impl, + owner, + index, + }); + const w: FakeWallet = { + address, + owner, + index, + label, + rules, + checkpointCount: BigInt(0), + grantNonce: BigInt(rules.length), + }; + this.wallets.set(address.toLowerCase(), w); + this.walletCounts.set(owner.toLowerCase(), index + BigInt(1)); + this.extraLogs.push( + log( + ADDR.factory, + v5.events.walletDeployed, + { owner, index, wallet: address }, + { label }, + BigInt(10) + ) + ); + return w; + } + + nativeRule( + agent: Address, + rulesId: Hex, + extra: Partial = {} + ): FakeRule { + return { + rulesId, + agent, + mode: 1, + chain: PUSH_NS, + actionIds: [actionId(ADDR.target, '0xd09de08a')], + native: extra, + }; + } + + async readContract( + args: Parameters[0] + ): Promise { + const fn = args.functionName; + this.calls.push({ functionName: fn, blockNumber: args.blockNumber }); + if (this.failOn.has(fn)) throw new Error(`rpc failure: ${fn}`); + const a = (args.args ?? []) as readonly unknown[]; + const at = args.address.toLowerCase(); + const wallet = this.wallets.get(at); + switch (fn) { + case 'version': + return this.policyVersion; + // factory + case 'walletImplementation': + return ADDR.impl; + case 'walletCount': + return this.walletCounts.get(String(a[0]).toLowerCase()) ?? BigInt(0); + case 'predictWallet': { + const [owner, index] = a as [Address, bigint]; + const count = this.walletCounts.get(owner.toLowerCase()) ?? BigInt(0); + if (index > count) throw new Error('IndexOutOfRange'); + const addr = this.predictOverride + ? this.predictOverride(owner, index) + : deriveWallet({ + factory: ADDR.factory, + walletImplementation: ADDR.impl, + owner, + index, + }); + return [addr, index < count] as const; + } + case 'isWallet': + return this.wallets.has(String(a[0]).toLowerCase()); + case 'indexOf': + return this.wallets.get(String(a[0]).toLowerCase())?.index ?? BigInt(0); + // wallet + case 'factory': + if (!wallet) throw new Error('no factory()'); + return ADDR.factory; + case 'label': + if (!wallet) throw new Error('not a wallet'); + return wallet.label || `AGW ${wallet.index + BigInt(1)}`; + case 'owner': + if (!wallet) throw new Error('not a wallet'); + return wallet.owner; + case 'SESSION_ENGINE': + return ADDR.engine; + case 'RULES_POLICY': + return ADDR.policy; + case 'SESSION_VALIDATOR': + return ADDR.validator; + case 'UNIVERSAL_GATEWAY_PC': + return ADDR.gateway; + case 'accountId': + return wallet?.accountId ?? 'push.agw.1.0.0'; + case 'grantNonce': + return wallet?.grantNonce ?? BigInt(0); + case 'checkpointCount': + return wallet?.checkpointCount ?? BigInt(0); + case 'agentOf': { + const r = wallet?.rules.find((x) => x.rulesId === a[0]); + return r?.agent ?? '0x0000000000000000000000000000000000000000'; + } + // engine + case 'getPermissionIDs': + return (this.wallets.get(String(a[0]).toLowerCase())?.rules ?? []).map( + (r) => r.rulesId + ); + case 'isPermissionEnabled': { + const w = this.wallets.get(String(a[1]).toLowerCase()); + return !!w?.rules.some((r) => r.rulesId === a[0]); + } + case 'getEnabledActions': { + const w = this.wallets.get(String(a[0]).toLowerCase()); + return w?.rules.find((r) => r.rulesId === a[1])?.actionIds ?? []; + } + // policy + case 'getMode': { + const found = this.findConfig(a[0] as Hex, a[1] as Address); + if (!found) + return { + initialized: false, + mode: 0, + vm: 0, + chainHash: `0x${'00'.repeat(32)}`, + }; + return { + initialized: true, + mode: found.rule.mode, + vm: 0, + chainHash: keccak256(toBytes(found.rule.chain)), + }; + } + case 'getNativeConfig': { + const found = this.findConfig(a[0] as Hex, a[1] as Address); + const n = found?.rule.native ?? {}; + return { + initialized: !!found, + validUntil: 2_000_000_000, + target: ADDR.target, + selector: '0xd09de08a', + maxValuePerCall: BigInt(0), + maxValueTotal: BigInt(0), + valueSpent: BigInt(0), + amount: { + enabled: false, + offset: 0, + maxPerCall: BigInt(0), + maxTotal: BigInt(0), + }, + amountSpent: BigInt(0), + maxCalls: 0, + callsUsed: 0, + pins: [], + ...n, + }; + } + case 'getConfig': { + const found = this.findConfig(a[0] as Hex, a[1] as Address); + const u = found?.rule.universal; + return { + initialized: !!found, + validUntil: 2_000_000_000, + expectedCEA: u?.expectedCEA ?? ADDR.other, + assets: (u?.assets ?? [{ token: ADDR.target }]).map((a) => ({ + maxPerCall: BigInt(1000), + maxTotal: BigInt(10000), + spent: BigInt(0), + ...a, + })), + maxGasPerCall: u?.maxGasPerCall ?? BigInt(0), + allowedCalls: [ + { + target: ADDR.target, + selector: '0xd09de08a', + beneficiaryOffset: 0, + hasBeneficiary: false, + maxValue: BigInt(0), + }, + ], + }; + } + case 'SOURCE_CHAIN_NAMESPACE': + return SEPOLIA_NS; + case 'SOURCE_TOKEN_ADDRESS': + return ( + this.sourceTokens.get(args.address.toLowerCase()) ?? args.address + ); + case 'gasTokenPRC20ByChainNamespace': + return getAddress('0x0000000000000000000000000000000000007070'); + case 'balanceOf': + case 'allowance': + return this.balances.get(`${fn}:${at}`) ?? BigInt(0); + default: + throw new Error(`FakeChain: unhandled ${fn}`); + } + } + + private findConfig(id: Hex, account: Address) { + const w = this.wallets.get(account.toLowerCase()); + if (!w) return undefined; + for (const rule of w.rules) { + for (const action of rule.actionIds) { + if (configId(w.address, rule.rulesId, action) === id) + return { rule, action }; + } + } + return undefined; + } + + async getBlockNumber(): Promise { + if (this.failOn.has('getBlockNumber')) + throw new Error('rpc failure: getBlockNumber'); + return this.block; + } + + async getCode(args: { address: Address }): Promise { + return this.wallets.has(args.address.toLowerCase()) ? '0x3d' : undefined; + } + + async getBalance(args: { address: Address }): Promise { + return this.balances.get(`pc:${args.address.toLowerCase()}`) ?? BigInt(0); + } + + async getLogs(args: Parameters[0]): Promise { + if (this.failOn.has('getLogs')) throw new Error('rpc failure: getLogs'); + return [...this.checkpointLogs, ...this.extraLogs].filter( + (l) => + l.address.toLowerCase() === args.address.toLowerCase() && + BigInt(l.blockNumber ?? BigInt(0)) >= args.fromBlock && + BigInt(l.blockNumber ?? BigInt(0)) <= args.toBlock + ); + } + + async getTransactionReceipt(args: { + hash: Hex; + }): Promise { + const r = this.receipts.get(args.hash); + if (!r) throw new Error(`no receipt ${args.hash}`); + return r; + } +} + +let logIndex = 0; +export function log( + address: Address, + event: AbiEvent, + indexed: Record, + data: Record, + blockNumber: bigint, + txHash: Hex = `0x${'ab'.repeat(32)}`, + transactionIndex = 0 +): Log { + const topics = encodeEventTopics({ + abi: [event], + eventName: event.name, + args: indexed, + } as never); + const nonIndexed = event.inputs.filter((i) => !('indexed' in i && i.indexed)); + return { + address, + topics: topics as [Hex, ...Hex[]], + data: encodeAbiParameters( + nonIndexed, + nonIndexed.map((i) => data[i.name as string]) as never + ), + blockNumber, + transactionHash: txHash, + transactionIndex, + logIndex: logIndex++, + blockHash: `0x${'cd'.repeat(32)}`, + removed: false, + } as Log; +} + +export function checkpointLog( + wallet: Address, + seq: number, + kind: number, + block: bigint, + txIndex = 0 +): Log { + return log( + wallet, + v5.events.checkpointed, + { seq: BigInt(seq) }, + { kind, ref: `0x${'11'.repeat(32)}`, blockNumber: block }, + block, + `0x${seq.toString(16).padStart(64, '0')}`, + txIndex + ); +} + +export function registerFakeGeneration( + network: PUSH_NETWORK = PUSH_NETWORK.TESTNET_DONUT +): AgenticGeneration { + resetAgenticGenerations(); + return registerAgenticGeneration({ + id: 'v5', + sourceCommit: '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb', + network, + advertised: false, + addresses: { + factory: ADDR.factory, + walletImplementation: ADDR.impl, + sessionEngine: ADDR.engine, + rulesPolicy: ADDR.policy, + sessionValidator: ADDR.validator, + gateway: ADDR.gateway, + }, + startBlock: BigInt(1), + accountId: 'push.agw.1.0.0', + }); +} + +export function ruleId(n: number): Hex { + return `0x${n.toString(16).padStart(64, '0')}`; +} diff --git a/packages/core/src/lib/agentic/__tests__/labels.spec.ts b/packages/core/src/lib/agentic/__tests__/labels.spec.ts new file mode 100644 index 000000000..9688dc4bd --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/labels.spec.ts @@ -0,0 +1,115 @@ +import { decodeFunctionData, encodeErrorResult } from 'viem'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import { v5 } from '../contracts/v5'; +import { currentGeneration, resetAgenticGenerations } from '../deployments'; +import { AGENTIC_ERROR_CODE } from '../errors'; +import { createWallet } from '../management/create'; +import { assertLabel } from '../management/label'; +import { setLabel } from '../management/rules-write'; +import { decodeAgwErrorData } from '../revert'; +import { AgenticWalletHandle } from '../wallet'; +import { ADDR, FakeChain, registerFakeGeneration } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; + +beforeEach(() => { + registerFakeGeneration(); +}); +afterEach(resetAgenticGenerations); +const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + +describe('v5 wallet labels', () => { + it('allows empty, 64 ASCII bytes and sixteen four-byte emoji without altering the label', () => { + for (const label of ['', 'a'.repeat(64), '🙂'.repeat(16), ' label ']) { + expect(() => assertLabel(label)).not.toThrow(); + } + }); + + it('refuses non-strings and overlong UTF-8 labels before a create reads or signs', async () => { + const chain = new FakeChain(); + const rt = mockRuntime(chain, { signer: ADDR.owner }); + for (const label of [null, 123, 'a'.repeat(65), '🙂'.repeat(17)]) { + await expect( + createWallet(rt, label as never, { rules: [] }) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.INVALID_RULE }); + } + expect(rt.executeMock).not.toHaveBeenCalled(); + expect(chain.calls).toEqual([]); + }); + + it('encodes a reset through the owner door and forwards the per-call progress hook', async () => { + const chain = new FakeChain(); + const w = chain.addWallet(ADDR.owner, 'custom'); + const rt = mockRuntime(chain, { signer: ADDR.owner }); + const hook = jest.fn(); + const tx = await new AgenticWalletHandle(rt, w.address).setLabel('', { + progressHook: hook, + }); + const sent = rt.executeMock.mock.calls[0][0]; + expect(sent).toMatchObject({ to: w.address, value: BigInt(0) }); + const outer = v5.decodeWalletCall(sent.data); + expect(outer?.kind).toBe('execute'); + if (outer?.kind !== 'execute') throw new Error('expected owner execute'); + expect(outer.calls).toHaveLength(1); + expect(outer.calls[0].target).toBe(w.address); + expect( + decodeFunctionData({ abi: v5.abis.wallet, data: outer.calls[0].data }) + ).toMatchObject({ functionName: 'setLabel', args: [''] }); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ id: PROGRESS_HOOK.AGENTIC_TX_199_01 }) + ); + expect(tx.hash).toBeTruthy(); + }); + + it('refuses non-owner and read-only renames before signing', async () => { + const chain = new FakeChain(); + const w = chain.addWallet(ADDR.owner, 'x'); + const stranger = mockRuntime(chain, { signer: ADDR.agent }); + await expect( + setLabel(stranger, gen(), w.address, 'y') + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NOT_WALLET_OWNER }); + const readOnly = mockRuntime(chain, { signer: ADDR.owner, readOnly: true }); + await expect( + setLabel(readOnly, gen(), w.address, 'y') + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.READ_ONLY }); + expect(stranger.executeMock).not.toHaveBeenCalled(); + expect(readOnly.executeMock).not.toHaveBeenCalled(); + }); + + it('uses the same owner door for an external owner identity', async () => { + const chain = new FakeChain(); + const w = chain.addWallet(ADDR.owner, 'x'); + const rt = mockRuntime(chain, { + signer: ADDR.owner, + signerIsPushNative: () => false, + }); + await setLabel(rt, gen(), w.address, 'UEA label'); + expect( + v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data)?.kind + ).toBe('execute'); + }); + + it('maps the new contract length error and emits a failed operation hook', async () => { + const chain = new FakeChain(); + const w = chain.addWallet(ADDR.owner, 'x'); + const data = encodeErrorResult({ + abi: v5.abis.wallet, + errorName: 'LabelTooLong', + args: [BigInt(65)], + }); + expect(decodeAgwErrorData(data)).toMatchObject({ + name: 'LabelTooLong', + decoded: '["65"]', + }); + const rt = mockRuntime(chain, { + signer: ADDR.owner, + execute: async () => { + throw new Error(`reverted ${data}`); + }, + }); + await expect(setLabel(rt, gen(), w.address, 'valid')).rejects.toMatchObject( + { decodedError: { name: 'LabelTooLong' } } + ); + expect(rt.events.at(-1)?.id).toBe(PROGRESS_HOOK.AGENTIC_TX_199_02); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/management.spec.ts b/packages/core/src/lib/agentic/__tests__/management.spec.ts new file mode 100644 index 000000000..d9726b6a4 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/management.spec.ts @@ -0,0 +1,685 @@ +import { + decodeFunctionData, + encodeErrorResult, + type Hex, + type Log, + type TransactionReceipt, +} from 'viem'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { PushChainBatchExecutionError } from '../../orchestrator/internals/errors'; +import type { MultiCall } from '../../orchestrator/orchestrator.types'; +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import { currentGeneration, resetAgenticGenerations } from '../deployments'; +import { + AGW_ABI, + AGW_FACTORY_ABI, + UNIVERSAL_RULES_POLICY_ABI, +} from '../contracts/abi/v5'; +import { v5 } from '../contracts/v5'; +import { deriveWallet, rulesId as computeRulesId } from '../codec/ids'; +import { AGENTIC_ERROR_CODE } from '../errors'; +import { createWallet } from '../management/create'; +import { addRules, revokeRules, updateRules } from '../management/rules-write'; +import type { NativeRule } from '../agentic.types'; +import { + ADDR, + FakeChain, + PUSH_NS, + log, + registerFakeGeneration, + ruleId, +} from './fake-chain'; +import { fakeResponse, mockRuntime } from './mock-runtime'; + +const NOW = 1_700_000_000; +const rule = (agent = ADDR.agent): NativeRule => ({ + agent, + target: ADDR.target, + selector: 'increment()', + validUntil: NOW + 100, +}); +const granted = (wallet: `0x${string}`, id: Hex, block = BigInt(101)) => + log( + wallet, + v5.events.rulesGranted, + { rulesId: id, chainHash: `0x${'00'.repeat(32)}` }, + { mode: 1, chainNamespace: PUSH_NS }, + block + ); +const revoked = (wallet: `0x${string}`, id: Hex) => + log(wallet, v5.events.rulesRevoked, { rulesId: id }, {}, BigInt(101)); + +function withLogs(logs: Log[], over = {}) { + const r = fakeResponse(over); + const base = r.wait; + r.wait = async () => ({ ...(await base()), logs }); + return r; +} + +beforeEach(() => registerFakeGeneration()); +afterEach(() => resetAgenticGenerations()); + +describe('agentic.create', () => { + const nextWallet = deriveWallet({ + factory: ADDR.factory, + walletImplementation: ADDR.impl, + owner: ADDR.owner, + index: 0, + }); + const ids = [ruleId(0), ruleId(1)].map((_, i) => + computeRulesId({ + validator: ADDR.validator, + agent: i === 0 ? ADDR.agent : ADDR.other, + grantNonce: BigInt(i), + }) + ); + + it('validates first, then sends deploy + grants in input order and returns receipt IDs', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + log( + ADDR.factory, + v5.events.walletDeployed, + { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, + { label: 'ops' }, + BigInt(101) + ), + granted(nextWallet, ids[0]), + granted(nextWallet, ids[1]), + ])) as never, + }); + const execute = jest.spyOn(rt, 'execute'); + const perCall = jest.fn(); + const res = await createWallet(rt, 'ops', { + rules: [rule(), rule(ADDR.other)], + progressHook: perCall, + }); + expect(res).toMatchObject({ wallet: nextWallet, index: 0, rulesIds: ids }); + const calls = (execute.mock.calls[0][0] as { data: MultiCall[] }).data; + expect(calls.map((c) => c.to)).toEqual([ + ADDR.factory, + nextWallet, + nextWallet, + ]); + expect( + decodeFunctionData({ abi: AGW_ABI, data: calls[1].data }).functionName + ).toBe('grantRules'); + expect(rt.events.map((e) => e.id)).toEqual([ + PROGRESS_HOOK.AGENTIC_TX_101, + PROGRESS_HOOK.AGENTIC_TX_102, + PROGRESS_HOOK.AGENTIC_TX_104, + PROGRESS_HOOK.AGENTIC_TX_199_01, + ]); + expect(rt.events.find((e) => e.id === 'AGENTIC-TX-103')).toBeUndefined(); + expect(perCall).toHaveBeenCalledTimes(4); + }); + + it('a bare wallet is one deployWallet call, no funding', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async (p: { to: string; data: Hex; value: bigint }) => { + expect(p.to).toBe(ADDR.factory); + expect(p.value).toBe(BigInt(0)); + return withLogs([ + log( + ADDR.factory, + v5.events.walletDeployed, + { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, + { label: '' }, + BigInt(101) + ), + ]); + }) as never, + }); + expect((await createWallet(rt, '', { rules: [] })).rulesIds).toEqual([]); + }); + + it('invalid input never reaches the signer', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { signer: ADDR.owner }); + await expect( + createWallet(rt, 'x', { rules: [rule(ADDR.owner)] }) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.AGENT_IS_OWNER }); + await expect( + createWallet(rt, 'x', undefined as never) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.INVALID_RULE }); + await expect( + createWallet( + mockRuntime(fake, { signer: ADDR.owner, readOnly: true }), + 'x', + { rules: [] } + ) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.READ_ONLY, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); + it('refuses a changed URP version before grant signing', async () => { + const fake = new FakeChain(); + fake.policyVersion = '3.2.0'; + const rt = mockRuntime(fake, { signer: ADDR.owner }); + await expect( + createWallet(rt, 'version', { rules: [rule()] }) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); + + it('a different wallet in the receipt is an INDEX_RACE with full details', async () => { + const fake = new FakeChain(); + const elsewhere = deriveWallet({ + factory: ADDR.factory, + walletImplementation: ADDR.impl, + owner: ADDR.owner, + index: 1, + }); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + log( + ADDR.factory, + v5.events.walletDeployed, + { owner: ADDR.owner, index: BigInt(1), wallet: elsewhere }, + { label: '' }, + BigInt(101) + ), + ])) as never, + }); + await expect(createWallet(rt, '', { rules: [] })).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.INDEX_RACE, + }); + }); + + it('IDs that differ from the input order are a RECEIPT_MISMATCH, not a silent reorder', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + log( + ADDR.factory, + v5.events.walletDeployed, + { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, + { label: '' }, + BigInt(101) + ), + granted(nextWallet, ids[1]), + granted(nextWallet, ids[0]), + ])) as never, + }); + await expect( + createWallet(rt, '', { rules: [rule(), rule(ADDR.other)] }) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, + }); + }); + + const receipt = (logs: Log[], status: 'success' | 'reverted' = 'success') => + ({ status, logs } as unknown as TransactionReceipt); + + it('a sequential failure after deployment is CREATE_PARTIAL; what committed comes from this call’s receipts', async () => { + const fake = new FakeChain(); + const h1 = `0x${'01'.repeat(32)}` as Hex; + const h2 = `0x${'02'.repeat(32)}` as Hex; + fake.receipts.set( + h1, + receipt([ + log( + ADDR.factory, + v5.events.walletDeployed, + { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, + { label: 'ops' }, + BigInt(101) + ), + ]) + ); + fake.receipts.set(h2, receipt([granted(nextWallet, ids[0])])); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => { + throw new PushChainBatchExecutionError('grant 2 reverted', [h1, h2]); + }) as never, + }); + const err = await createWallet(rt, 'ops', { + rules: [rule(), rule(ADDR.other)], + }).catch((e) => e); + expect(err.code).toBe(AGENTIC_ERROR_CODE.CREATE_PARTIAL); + expect(err.details).toMatchObject({ + wallet: nextWallet, + walletDeployed: true, + grantedRulesIds: [ids[0]], + confirmedHashes: [h1, h2], + }); + expect(err.hint).toMatch(/rules\.add/); + expect(rt.events.at(-1)?.id).toBe(PROGRESS_HOOK.AGENTIC_TX_199_02); + }); + + it('a pending hash keeps an unconfirmed deployment unknown', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => { + throw new PushChainBatchExecutionError( + 'timed out', + [], + `0x${'03'.repeat(32)}` + ); + }) as never, + }); + const err = await createWallet(rt, 'ops', { rules: [rule()] }).catch( + (e) => e + ); + expect(err.details).toMatchObject({ + walletDeployed: 'unknown', + pendingHash: `0x${'03'.repeat(32)}`, + }); + expect(err.hint).toMatch(/Do not retry/); + }); + + it('failed recovery reads report unknown state, never "not deployed"', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => { + fake.addWallet(ADDR.owner, 'committed'); + throw new PushChainBatchExecutionError('grant failed', [ + `0x${'11'.repeat(32)}`, + ]); + }) as never, + }); + const err = await createWallet(rt, 'review', { rules: [rule()] }).catch( + (e) => e + ); + expect(err.code).toBe('CREATE_PARTIAL'); + expect(err.details.walletDeployed).toBe('unknown'); + expect(err.details.recoveryError).toBeTruthy(); + expect(err.hint).toMatch(/Do not retry/); + }); + + it('a receipt that cannot be read after submission keeps the hash and reports unknown state', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => { + const r = fakeResponse({ hash: `0x${'44'.repeat(32)}` }); + r.wait = async () => { + throw new Error('rpc down'); + }; + return r; + }) as never, + }); + const err = await createWallet(rt, 'x', { rules: [] }).catch((e) => e); + expect(err.code).toBe(AGENTIC_ERROR_CODE.CREATE_PARTIAL); + expect(err.details).toMatchObject({ + walletDeployed: 'unknown', + submitted: { txHash: `0x${'44'.repeat(32)}` }, + }); + }); + + it('the deploy is index-bound: a raced slot reverts and is reported as INDEX_RACE with nothing committed', async () => { + const fake = new FakeChain(); + const raceData = encodeErrorResult({ + abi: AGW_FACTORY_ABI, + errorName: 'IndexMismatch', + args: [BigInt(1), BigInt(0)], + }); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async (p: { data: MultiCall[] }) => { + const deploy = decodeFunctionData({ + abi: AGW_FACTORY_ABI, + data: p.data[0].data, + }); + expect(deploy.functionName).toBe('deployWalletWithSig'); + expect( + (deploy.args[0] as { index: bigint; wallet: string }).index + ).toBe(BigInt(0)); + expect((deploy.args[0] as { wallet: string }).wallet).toBe(nextWallet); + throw new Error( + `Execution reverted with reason: custom error ${raceData.slice( + 0, + 10 + )}: ${raceData.slice(10)}.` + ); + }) as never, + }); + const err = await createWallet(rt, 'x', { rules: [rule()] }).catch( + (e) => e + ); + expect(err.code).toBe(AGENTIC_ERROR_CODE.INDEX_RACE); + expect(err.details).toMatchObject({ committed: false }); + }); + + it('a generic send error is still identified as a race when the fresh slot label proves another create won', async () => { + const fake = new FakeChain(); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => { + const winner = fake.addWallet(ADDR.owner, 'competing-create'); + winner.label = 'this-create'; // mutable view cannot erase the deploy evidence + throw new Error('opaque sender failure'); + }) as never, + }); + const err = await createWallet(rt, 'this-create', { + rules: [rule()], + }).catch((e) => e); + expect(err).toMatchObject({ + code: AGENTIC_ERROR_CODE.INDEX_RACE, + details: { + wallet: nextWallet, + index: 0, + committed: false, + competingLabel: 'competing-create', + }, + }); + }); + + it('a later rename alone does not establish a competing creation', async () => { + const fake = new FakeChain(); + const failure = new Error('opaque sender failure'); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => { + const w = fake.addWallet(ADDR.owner, 'this-create'); + w.label = 'renamed-after-deploy'; + throw failure; + }) as never, + }); + await expect(createWallet(rt, 'this-create', { rules: [rule()] })).rejects.toBe(failure); + }); + + it('a slot that is already deployed is refused before signing', async () => { + const fake = new FakeChain(); + fake.predictOverride = (owner, index) => + deriveWallet({ + factory: ADDR.factory, + walletImplementation: ADDR.impl, + owner, + index, + }); + const w = fake.addWallet(ADDR.owner, 'a'); + fake.walletCounts.set(ADDR.owner.toLowerCase(), BigInt(0)); // stale count pointing at a deployed slot + void w; + const rt = mockRuntime(fake, { signer: ADDR.owner }); + await expect(createWallet(rt, '', { rules: [] })).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.INDEX_RACE, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); +}); + +describe('rules.add / rules.revoke', () => { + const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + + it('add: owner-only, same-agent grants allowed, IDs from the grant nonce', async () => { + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + ]); + w.grantNonce = BigInt(4); + const expected = computeRulesId({ + validator: ADDR.validator, + agent: ADDR.agent, + grantNonce: BigInt(4), + }); + const stranger = mockRuntime(fake, { signer: ADDR.other }); + await expect( + addRules(stranger, gen(), w.address, [rule(ADDR.other)]) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.NOT_WALLET_OWNER, + }); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => withLogs([granted(w.address, expected)])) as never, + }); + expect( + (await addRules(rt, gen(), w.address, [rule(ADDR.agent)])).rulesIds + ).toEqual([expected]); + }); + + it('add: several rules are one atomic owner execute batch (one signature on any signer)', async () => { + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w'); + const ids = [ADDR.agent, ADDR.other].map((agent, i) => + computeRulesId({ + validator: ADDR.validator, + agent, + grantNonce: BigInt(i), + }) + ); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + granted(w.address, ids[0]), + granted(w.address, ids[1]), + ])) as never, + }); + const exec = jest.spyOn(rt, 'execute'); + expect( + (await addRules(rt, gen(), w.address, [rule(), rule(ADDR.other)])) + .rulesIds + ).toEqual(ids); + const sent = exec.mock.calls[0][0] as { to: string; data: Hex }; + expect(Array.isArray(sent.data)).toBe(false); + const outer = v5.decodeWalletCall(sent.data) as { + kind: string; + calls: { target: string; data: Hex }[]; + }; + expect(outer.kind).toBe('execute'); + expect( + outer.calls.map( + (c) => decodeFunctionData({ abi: AGW_ABI, data: c.data }).functionName + ) + ).toEqual(['grantRules', 'grantRules']); + }); + + it('revoke: bare and empty are refused; unknown IDs fail before signing; several IDs are one atomic owner execute', async () => { + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + fake.nativeRule(ADDR.other, ruleId(2)), + ]); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + revoked(w.address, ruleId(1)), + revoked(w.address, ruleId(2)), + ])) as never, + }); + const exec = jest.spyOn(rt, 'execute'); + for (const bad of [undefined, [], {}, { all: false }]) { + await expect( + revokeRules(rt, gen(), w.address, bad as never) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET, + }); + } + await expect( + revokeRules(rt, gen(), w.address, [ruleId(9)]) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.RULE_NOT_FOUND }); + expect(exec).not.toHaveBeenCalled(); + await revokeRules(rt, gen(), w.address, [ruleId(1), ruleId(2)]); + const sent = exec.mock.calls[0][0] as { to: string; data: Hex }; + expect(sent.to).toBe(w.address); + const decoded = v5.decodeWalletCall(sent.data) as { + kind: string; + calls: { target: string; data: Hex }[]; + }; + expect(decoded.kind).toBe('execute'); + expect( + decoded.calls.map( + (c) => decodeFunctionData({ abi: AGW_ABI, data: c.data }).functionName + ) + ).toEqual(['revokeRules', 'revokeRules']); + await revokeRules(rt, gen(), w.address, { all: true }); + expect( + decodeFunctionData({ + abi: AGW_ABI, + data: (exec.mock.calls[1][0] as { data: Hex }).data, + }).functionName + ).toBe('revokeAllRules'); + }); +}); + +describe('rules.update — one owner batch: assert → revoke → grant', () => { + const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + + it('builds exactly one execute(batch) with the observed spend asserted per pair', async () => { + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1), { + valueSpent: BigInt(3), + amountSpent: BigInt(4), + callsUsed: 5, + }), + ]); + w.grantNonce = BigInt(2); + const newId = computeRulesId({ + validator: ADDR.validator, + agent: ADDR.agent, + grantNonce: BigInt(2), + }); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + revoked(w.address, ruleId(1)), + granted(w.address, newId), + ])) as never, + }); + const exec = jest.spyOn(rt, 'execute'); + const res = await updateRules(rt, gen(), w.address, { + rules: [{ rulesId: ruleId(1), rule: rule() }], + }); + expect(res.rules).toEqual([{ rulesId: newId, replaced: ruleId(1) }]); + expect(exec).toHaveBeenCalledTimes(1); + const sent = exec.mock.calls[0][0] as { to: string; data: Hex }; + expect(Array.isArray(sent.data)).toBe(false); // never a sequential multi-tx batch + const outer = v5.decodeWalletCall(sent.data) as { + kind: string; + mode: Hex; + calls: { target: string; data: Hex }[]; + }; + expect(outer.kind).toBe('execute'); + expect(outer.calls.map((c) => c.target)).toEqual([ + ADDR.policy, + w.address, + w.address, + ]); + const assertion = decodeFunctionData({ + abi: UNIVERSAL_RULES_POLICY_ABI, + data: outer.calls[0].data, + }); + expect(assertion.functionName).toBe('assertSpent'); + expect(assertion.args.slice(1)).toEqual([ + w.address, + BigInt(3), + BigInt(4), + 5, + ]); + expect( + outer.calls + .slice(1) + .map( + (c) => decodeFunctionData({ abi: AGW_ABI, data: c.data }).functionName + ) + ).toEqual(['revokeRules', 'grantRules']); + }); + + it('universal replacement asserts every asset in stored order before revoke/grant', async () => { + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w', [ + { + ...fake.nativeRule(ADDR.agent, ruleId(1)), + mode: 0, + chain: 'eip155:11155111', + universal: { + expectedCEA: ADDR.other, + maxGasPerCall: BigInt(10), + assets: [ + { token: ADDR.target, spent: BigInt(4) }, + { token: ADDR.other, spent: BigInt(7) }, + ], + }, + }, + ]); + const next = computeRulesId({ + validator: ADDR.validator, + agent: ADDR.agent, + grantNonce: BigInt(1), + }); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + revoked(w.address, ruleId(1)), + granted(w.address, next), + ])) as never, + }); + const exec = jest.spyOn(rt, 'execute'); + await updateRules(rt, gen(), w.address, { + rules: [{ rulesId: ruleId(1), rule: rule() }], + }); + const data = (exec.mock.calls[0][0] as { data: Hex }).data; + const outer = v5.decodeWalletCall(data) as { calls: { data: Hex }[] }; + const assertion = decodeFunctionData({ + abi: UNIVERSAL_RULES_POLICY_ABI, + data: outer.calls[0].data, + }); + expect(assertion.args.slice(1)).toEqual([ + w.address, + [BigInt(4), BigInt(7)], + ]); + expect( + outer.calls + .slice(1) + .map( + (c) => decodeFunctionData({ abi: AGW_ABI, data: c.data }).functionName + ) + ).toEqual(['revokeRules', 'grantRules']); + }); + + it('replacement may name another live agent, but may not repeat an old ID', async () => { + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + fake.nativeRule(ADDR.other, ruleId(2)), + ]); + const next = computeRulesId({ + validator: ADDR.validator, + agent: ADDR.other, + grantNonce: BigInt(2), + }); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + execute: (async () => + withLogs([ + revoked(w.address, ruleId(1)), + granted(w.address, next), + ])) as never, + }); + expect( + ( + await updateRules(rt, gen(), w.address, { + rules: [{ rulesId: ruleId(1), rule: rule(ADDR.other) }], + }) + ).rules[0].rulesId + ).toBe(next); + await expect( + updateRules(rt, gen(), w.address, { + rules: [ + { rulesId: ruleId(1), rule: rule() }, + { rulesId: ruleId(1), rule: rule() }, + ], + }) + ).rejects.toThrow(/same old rule twice/); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/mock-runtime.ts b/packages/core/src/lib/agentic/__tests__/mock-runtime.ts new file mode 100644 index 000000000..63dec7053 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/mock-runtime.ts @@ -0,0 +1,102 @@ +import type { Address, Hex } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import type { UniversalTxResponse } from '../../orchestrator/orchestrator.types'; +import type { ProgressEvent } from '../../progress-hook/progress-hook.types'; +import type { AgenticRuntime } from '../runtime'; +import { ADDR, FakeChain, PUSH_NS } from './fake-chain'; + +export interface MockRuntime extends AgenticRuntime { + executeMock: jest.Mock; + atomicMock: jest.Mock; + events: ProgressEvent[]; + perCallEvents: ProgressEvent[]; + fake: FakeChain; +} + +/** A response shaped like core's R1 response for a call to `to`. */ +export function fakeResponse(over: Partial = {}): UniversalTxResponse { + const resp = { + hash: `0x${'aa'.repeat(32)}`, + origin: `eip155:42101:${ADDR.agent}`, + blockNumber: BigInt(101), + blockHash: '0x', + transactionIndex: 0, + chainId: '42101', + from: ADDR.agent, + to: ADDR.target, + nonce: 1, + data: '0x', + value: BigInt(0), + gasLimit: BigInt(21000), + accessList: [], + wait: async () => ({ + hash: resp.hash, + blockNumber: BigInt(101), + blockHash: '0x', + transactionIndex: 0, + from: resp.from, + to: resp.to, + contractAddress: null, + gasPrice: BigInt(0), + gasUsed: BigInt(0), + cumulativeGasUsed: BigInt(0), + logs: [], + logsBloom: '0x', + status: 1 as const, + raw: { from: resp.from, to: resp.to }, + }), + progressHook: () => undefined, + type: '2', + typeVerbose: 'eip1559', + atomic: true, + signature: { r: '0x', s: '0x', v: 0 }, + ...over, + } as unknown as UniversalTxResponse; + return resp; +} + +export function mockRuntime( + fake: FakeChain, + over: Partial & { signer?: Address; readOnly?: boolean } = {} +): MockRuntime { + const events: ProgressEvent[] = []; + const perCallEvents: ProgressEvent[] = []; + const executeMock = jest.fn(async (params: { to: string; data?: Hex }) => + fakeResponse({ to: params.to, data: (params.data as string) ?? '0x' }) + ); + const atomicMock = jest.fn(async (params: { to: string }) => fakeResponse({ to: params.to })); + const signer = over.signer ?? ADDR.agent; + const rt: MockRuntime = { + network: PUSH_NETWORK.TESTNET_DONUT, + pushChainNamespace: PUSH_NS, + reader: fake, + isReadOnly: over.readOnly ?? false, + signerPushAccount: () => signer, + signerOrigin: () => ({ chain: CHAIN.PUSH_TESTNET_DONUT, address: signer }), + signerIsPushNative: () => true, + execute: executeMock as unknown as AgenticRuntime['execute'], + executeAtomicBatch: atomicMock as unknown as AgenticRuntime['executeAtomicBatch'], + emit: (event, perCall) => { + events.push(event); + if (perCall) { + perCallEvents.push(event); + perCall(event); + } + }, + signerAccountDeployed: async () => true, + signerBalance: async () => BigInt(10) ** BigInt(20), + getGasPrice: async () => BigInt(1), + quoteOutbound: async () => ({ protocolFee: BigInt(100), nativeValueForGas: BigInt(1000), gasLimitUsed: BigInt(200_000) }), + resolveCEA: async () => ({ cea: '0x000000000000000000000000000000000000CeA1' as Address, isDeployed: true }), + resolvePrc20: () => '0x0000000000000000000000000000000000007070' as Address, + nowSeconds: () => 1_700_000_000, + ...over, + executeMock, + atomicMock, + events, + perCallEvents, + fake, + }; + if (over.execute) rt.execute = over.execute; + return rt; +} diff --git a/packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts b/packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts new file mode 100644 index 000000000..ab65c14f4 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts @@ -0,0 +1,119 @@ +import { encodeAbiParameters, type Hex } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { PUSH_BATCH_EXECUTOR_ADDRESS } from '../../constants/chain'; +import { convertExecutorToOrigin } from '../../universal/account/account'; +import { adaptTrackedResponse } from '../response'; +import { v5, UEA_MULTICALL_PREFIX } from '../contracts/v5'; +import { resetAgenticGenerations } from '../deployments'; +import { ADDR, FakeChain, registerFakeGeneration, ruleId } from './fake-chain'; +import { fakeResponse, mockRuntime } from './mock-runtime'; + +jest.mock('../../universal/account/account', () => ({ + convertExecutorToOrigin: jest.fn(), +})); +afterEach(() => { + resetAgenticGenerations(); + jest.clearAllMocks(); +}); + +function fixture() { + registerFakeGeneration(); + const fake = new FakeChain(); + const wallet = fake.addWallet(ADDR.owner, 'batch'); + const actions = [ + { target: ADDR.target, value: BigInt(0), data: '0xd09de08a' as Hex }, + { target: ADDR.target, value: BigInt(2), data: '0xd09de08a' as Hex }, + ]; + const outer = actions.map((c) => ({ + target: wallet.address, + value: BigInt(0), + data: v5.encodeExecuteAsAgent(ruleId(1), c), + })); + const runtime = mockRuntime(fake); + return { fake, wallet, actions, outer, runtime }; +} + +it('recognizes a verified 7702 self-call and retains every native agent action', async () => { + const { fake, wallet, actions, outer, runtime } = fixture(); + const getCode = fake.getCode.bind(fake); + fake.getCode = async (p) => + p.address === ADDR.agent + ? (`0xef0100${PUSH_BATCH_EXECUTOR_ADDRESS[ + CHAIN.PUSH_TESTNET_DONUT + ]!.slice(2)}` as Hex) + : getCode(p); + const result = await adaptTrackedResponse( + runtime, + fakeResponse({ + from: ADDR.agent, + to: ADDR.agent, + data: v5.encodeExecute(outer), + }) + ); + expect(result).toMatchObject({ + from: wallet.address, + to: actions[0].target, + agentic: { + door: 'agent', + rulesId: ruleId(1), + rawTo: ADDR.agent, + nativeCalls: actions.map((c) => ({ + to: c.target, + value: c.value, + data: c.data, + })), + }, + }); +}); + +it('recognizes a verified UEA multicall carrying single agent-door entries', async () => { + const { wallet, actions, outer, runtime } = fixture(); + (convertExecutorToOrigin as jest.Mock).mockResolvedValue({ + exists: true, + account: { chain: CHAIN.ETHEREUM_SEPOLIA, address: ADDR.owner }, + }); + const data = (UEA_MULTICALL_PREFIX + + encodeAbiParameters( + [ + { + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + [outer.map((c) => ({ to: c.target, value: c.value, data: c.data }))] + ).slice(2)) as Hex; + const result = await adaptTrackedResponse( + runtime, + fakeResponse({ + from: ADDR.agent, + to: wallet.address, + data, + raw: { + from: ADDR.other, + to: ADDR.agent, + nonce: 0, + data, + value: BigInt(0), + }, + }) + ); + expect(result.from).toBe(wallet.address); + expect(result.agentic?.nativeCalls).toEqual( + actions.map((c) => ({ to: c.target, value: c.value, data: c.data })) + ); +}); + +it('does not label an arbitrary forwarding contract as a trusted sender batch', async () => { + const { outer, runtime } = fixture(); + const response = fakeResponse({ + from: ADDR.agent, + to: ADDR.other, + data: v5.encodeExecute(outer), + }); + expect(await adaptTrackedResponse(runtime, response)).toBe(response); + expect(response.agentic).toBeUndefined(); +}); diff --git a/packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts b/packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts new file mode 100644 index 000000000..9064b48f4 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts @@ -0,0 +1,300 @@ +import { encodeFunctionData, getAddress, type Hex } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { ERC20_EVM } from '../../constants/abi'; +import { TransactionRoute } from '../../orchestrator/route-detector'; +import type { UniversalExecuteParams } from '../../orchestrator/orchestrator.types'; +import { transformToUniversalTxReceipt } from '../../orchestrator/internals/tx-transformer'; +import { transformToUniversalTxResponse } from '../../orchestrator/internals/response-builder'; +import { adaptTrackedResponse } from '../response'; +import { composeOutbound, type DestinationCall } from '../execution/outbound'; +import { agenticSend } from '../execution/send'; +import { v5 } from '../contracts/v5'; +import { currentGeneration, resetAgenticGenerations } from '../deployments'; +import type { AgenticExecutionContext } from '../context'; +import { ADDR, FakeChain, registerFakeGeneration, ruleId } from './fake-chain'; +import { mockRuntime, fakeResponse } from './mock-runtime'; + +jest.mock('../../universal/account/account', () => ({ + convertExecutorToOrigin: jest + .fn() + .mockResolvedValue({ account: null, exists: false }), +})); + +afterEach(() => resetAgenticGenerations()); + +const USDC = { + symbol: 'USDC', + decimals: 6, + address: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' as Hex, + mechanism: 'approve' as const, +}; +const TRANSFER = encodeFunctionData({ + abi: ERC20_EVM, + functionName: 'transfer', + args: [ADDR.target, BigInt(10)], +}); +const TOKEN_CALL: DestinationCall = { + to: getAddress(USDC.address), + data: TRANSFER, + value: BigInt(0), +}; +const APP_CALL: DestinationCall = { + to: ADDR.target, + data: '0xd09de08a', + value: BigInt(0), +}; +const PRC20 = ADDR.other; +const PUSH_HASH = `0x${'ab'.repeat(32)}` as Hex; +const EXTERNAL_HASH = `0x${'ef'.repeat(32)}` as Hex; + +function fixture() { + registerFakeGeneration(); + const fake = new FakeChain(); + const wallet = fake.addWallet(ADDR.owner, 'outbound-review'); + const gen = currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + const read = fake.readContract.bind(fake); + fake.readContract = async (args) => + args.functionName === 'SOURCE_CHAIN_NAMESPACE' + ? CHAIN.ETHEREUM_SEPOLIA + : read(args); + fake.balances.set(`pc:${wallet.address.toLowerCase()}`, BigInt(100000)); + fake.balances.set(`balanceOf:${PRC20.toLowerCase()}`, BigInt(1000)); + fake.balances.set(`allowance:${PRC20.toLowerCase()}`, BigInt(1000)); + const runtime = mockRuntime(fake, { + signer: ADDR.owner, + resolvePrc20: () => PRC20, + }); + runtime.executeMock.mockImplementation( + async (params: { to: string; data?: Hex }) => + fakeResponse({ + to: params.to, + data: params.data ?? '0x', + from: ADDR.owner, + origin: `eip155:42101:${ADDR.owner}`, + }) + ); + const context: AgenticExecutionContext = { + wallet: wallet.address, + generation: gen, + door: 'owner', + signerPushAccount: ADDR.owner, + signerOrigin: { chain: CHAIN.PUSH_TESTNET_DONUT, address: ADDR.owner }, + }; + return { fake, wallet, gen, runtime, context }; +} + +const cases: { + name: string; + params: UniversalExecuteParams; + calls: DestinationCall[]; +}[] = [ + { + name: 'explicit ERC20 transfer preserves its token target and calldata', + params: { + to: { address: USDC.address, chain: CHAIN.ETHEREUM_SEPOLIA }, + data: TRANSFER, + }, + calls: [TOKEN_CALL], + }, + { + name: 'funds-only transfer describes the actual generated token call', + params: { + to: { address: ADDR.target, chain: CHAIN.ETHEREUM_SEPOLIA }, + funds: { amount: BigInt(10), token: USDC }, + }, + calls: [TOKEN_CALL], + }, + { + name: 'funds plus app call keeps the generated transfer and app call in order', + params: { + to: { address: ADDR.target, chain: CHAIN.ETHEREUM_SEPOLIA }, + funds: { amount: BigInt(10), token: USDC }, + data: APP_CALL.data, + }, + calls: [TOKEN_CALL, APP_CALL], + }, + { + name: 'explicit call array retains all calls without guessing a primary action', + params: { + to: { address: ADDR.other, chain: CHAIN.ETHEREUM_SEPOLIA }, + data: [TOKEN_CALL, APP_CALL], + }, + calls: [TOKEN_CALL, APP_CALL], + }, + { + name: 'native transfer preserves the actual destination value', + params: { + to: { address: ADDR.target, chain: CHAIN.ETHEREUM_SEPOLIA }, + value: BigInt(7), + }, + calls: [{ to: ADDR.target, value: BigInt(7), data: '0x' }], + }, +]; + +describe('canonical outbound response on send and replay', () => { + it.each(cases)('$name', async ({ params, calls }) => { + const { wallet, runtime, context } = fixture(); + const live = await agenticSend(runtime, context, params); + const submitted = runtime.executeMock.mock.calls[0][0]; + const replay = await adaptTrackedResponse( + runtime, + fakeResponse({ + to: submitted.to, + data: submitted.data, + origin: live.origin, + from: ADDR.owner, + route: TransactionRoute.UOA_TO_PUSH, + }) + ); + const summary = calls[0]; + for (const response of [live, replay]) { + expect(response).toMatchObject({ + from: wallet.address, + origin: live.origin, + ...summary, + route: TransactionRoute.UOA_TO_CEA, + chain: CHAIN.ETHEREUM_SEPOLIA, + }); + expect(response.agentic?.destinationCalls).toEqual(calls); + expect(response.agentic).toMatchObject({ + rawTo: wallet.address, + rawData: submitted.data, + }); + expect(await response.wait()).toMatchObject({ + from: wallet.address, + to: summary.to, + }); + } + }); +}); + +describe.each(['owner', 'agent'] as const)( + 'outbound replay wait on the %s door', + (door) => { + it.each(['success', 'reverted'] as const)( + 'handles an early Push-only route with a %s Push receipt', + async (status) => { + const { wallet, gen, runtime } = fixture(); + const out = await composeOutbound( + runtime, + gen, + wallet.address, + { + to: { address: ADDR.target, chain: CHAIN.ETHEREUM_SEPOLIA }, + data: APP_CALL.data, + }, + { door } + ); + const input = + door === 'owner' + ? v5.encodeExecute([out.gatewayCall]) + : v5.encodeExecuteAsAgent(ruleId(1), out.gatewayCall); + const signer = door === 'owner' ? ADDR.owner : ADDR.agent; + const tx = { + hash: PUSH_HASH, + from: signer, + to: wallet.address, + input, + value: BigInt(0), + nonce: 1, + gas: BigInt(160000), + gasPrice: BigInt(1), + blockNumber: BigInt(100), + blockHash: `0x${'cd'.repeat(32)}`, + transactionIndex: 0, + type: 'eip1559', + accessList: [], + r: '0x1', + s: '0x2', + v: BigInt(0), + wait: jest.fn().mockResolvedValue({ + transactionHash: PUSH_HASH, + blockNumber: BigInt(100), + blockHash: `0x${'cd'.repeat(32)}`, + transactionIndex: 0, + from: signer, + to: wallet.address, + contractAddress: null, + gasUsed: BigInt(80000), + cumulativeGasUsed: BigInt(80000), + logs: [], + logsBloom: '0x', + status, + }), + }; + const waitForOutboundTx = jest.fn().mockResolvedValue({ + externalTxHash: EXTERNAL_HASH, + destinationChain: CHAIN.ETHEREUM_SEPOLIA, + recipient: ADDR.target, + amount: '0', + assetAddr: PRC20, + explorerUrl: 'https://example.test/tx', + }); + const initHook = jest.fn(); + const perCallHook = jest.fn(); + const response = await transformToUniversalTxResponse( + { + universalSigner: { + account: { chain: CHAIN.PUSH_TESTNET_DONUT, address: signer }, + }, + progressHook: initHook, + } as unknown as Parameters[0], + tx as unknown as Parameters[1], + [], + { + trackTransaction: jest.fn(), + waitForOutboundTx, + transformToUniversalTxReceipt, + printLog: jest.fn(), + outboundConstants: { + initialWaitMs: 0, + pollingIntervalMs: 1, + maxTimeoutMs: 100, + }, + inboundConstants: { + initialWaitMs: 0, + pollingIntervalMs: 1, + maxTimeoutMs: 100, + }, + } + ); + response.route = TransactionRoute.UOA_TO_PUSH; + response.chain = CHAIN.PUSH_TESTNET_DONUT; + await adaptTrackedResponse(runtime, response); + response.progressHook(perCallHook); + const receipt = await response.wait(); + expect(response).toMatchObject({ + from: wallet.address, + route: TransactionRoute.UOA_TO_CEA, + chain: CHAIN.ETHEREUM_SEPOLIA, + }); + expect(response.agentic).toMatchObject({ + door, + destinationCalls: [APP_CALL], + }); + expect(receipt.from).toBe(wallet.address); + if (status === 'success') { + expect(waitForOutboundTx).toHaveBeenCalledTimes(1); + expect(waitForOutboundTx.mock.calls[0][0]).toBe(PUSH_HASH); + expect(receipt).toMatchObject({ + status: 1, + externalStatus: 'success', + externalChain: CHAIN.ETHEREUM_SEPOLIA, + externalTxHash: EXTERNAL_HASH, + }); + } else { + expect(waitForOutboundTx).not.toHaveBeenCalled(); + expect(receipt.status).toBe(0); + expect(receipt.externalStatus).toBeUndefined(); + } + const terminal = + status === 'success' ? 'SEND-TX-299-01' : 'SEND-TX-299-02'; + for (const hook of [initHook, perCallHook]) { + expect( + hook.mock.calls.filter(([event]) => event.id === terminal) + ).toHaveLength(1); + } + } + ); + } +); diff --git a/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts b/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts new file mode 100644 index 000000000..15dc12175 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts @@ -0,0 +1,548 @@ +import { getAddress } from 'viem'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { resolveAgenticContext } from '../context'; +import { resetAgenticGenerations, currentGeneration } from '../deployments'; +import { AGENTIC_ERROR_CODE } from '../errors'; +import { readCheckpoints, validateSequence } from '../reads/checkpoints'; +import { getRule, listRules, selectRuleForSend } from '../reads/rules'; +import { Snapshot, scanLogs } from '../reads/snapshot'; +import { deriveForOwner, listForOwner, walletInfo } from '../reads/wallets'; +import { + ADDR, + FakeChain, + PUSH_NS, + SEPOLIA_NS, + checkpointLog, + log, + registerFakeGeneration, + ruleId, +} from './fake-chain'; +import { mockRuntime } from './mock-runtime'; +import { v5 } from '../contracts/v5'; +import { readOriginToken } from '../contracts/prc20-metadata'; + +describe('generation registry', () => { + afterEach(() => resetAgenticGenerations()); + + it('ships Donut v5 only; other networks are unsupported', () => { + resetAgenticGenerations(); + expect(currentGeneration(PUSH_NETWORK.TESTNET_DONUT)).toMatchObject({ + id: 'v5', + advertised: true, + }); + for (const n of Object.values(PUSH_NETWORK).filter( + (n) => n !== PUSH_NETWORK.TESTNET_DONUT + )) { + expect(() => currentGeneration(n)).toThrow( + expect.objectContaining({ + code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + }) + ); + } + }); + + it('initialize against any wallet fails before signing when no generation is registered', async () => { + resetAgenticGenerations(); + const fake = new FakeChain(); + await expect( + resolveAgenticContext( + mockRuntime(fake, { network: PUSH_NETWORK.MAINNET }), + ADDR.target + ) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + }); + }); +}); + +describe('Donut log-range limit', () => { + it('covers the full range in contiguous pages of at most 1,000 blocks', async () => { + const fake = new FakeChain(); + const getLogs = jest.spyOn(fake, 'getLogs'); + await scanLogs( + fake, + { address: ADDR.factory, event: v5.events.walletDeployed }, + BigInt(10), + BigInt(2010) + ); + expect(getLogs.mock.calls.map(([a]) => [a.fromBlock, a.toBlock])).toEqual([ + [BigInt(10), BigInt(1009)], + [BigInt(1010), BigInt(2009)], + [BigInt(2010), BigInt(2010)], + ]); + }); +}); + +describe('native PRC20 metadata', () => { + it('accepts a zero address word only for the registered gas token', async () => { + const fake = new FakeChain(), + gas = getAddress('0x0000000000000000000000000000000000007070'); + const original = fake.readContract.bind(fake); + jest.spyOn(fake, 'readContract').mockImplementation(async (args) => { + if (args.functionName === 'SOURCE_TOKEN_ADDRESS') { + const abi = args.abi as readonly { + outputs?: readonly { type: string }[]; + }[]; + if (abi[0].outputs?.[0].type === 'address') + return '0x0000000000000000000000000000000000000000'; + throw new Error('native precompile returns a static word'); + } + return original(args); + }); + const snap = await Snapshot.at(fake); + expect(await readOriginToken(snap, gas, SEPOLIA_NS)).toBe( + '0x0000000000000000000000000000000000000000' + ); + await expect( + readOriginToken(snap, ADDR.target, SEPOLIA_NS) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.RULE_READ_FAILED }); + }); +}); + +describe('initialize with agenticWallet — door resolution', () => { + let fake: FakeChain; + beforeEach(() => { + registerFakeGeneration(); + fake = new FakeChain(); + }); + afterEach(() => resetAgenticGenerations()); + + it('owner → owner door; universal account context keeps signer identity separate', async () => { + const w = fake.addWallet(ADDR.owner, 'w'); + const ctx = await resolveAgenticContext( + mockRuntime(fake, { signer: ADDR.owner }), + w.address + ); + expect(ctx).toMatchObject({ + wallet: w.address, + door: 'owner', + signerPushAccount: ADDR.owner, + }); + }); + + it('agent named in an enabled rule → agent door, even when that rule is expired', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1), { validUntil: 1 }), + ]); + const ctx = await resolveAgenticContext(mockRuntime(fake), w.address); + expect(ctx.door).toBe('agent'); + }); + + it('anyone else → NOT_OWNER_OR_AGENT', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + ]); + await expect( + resolveAgenticContext( + mockRuntime(fake, { signer: ADDR.other }), + w.address + ) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.NOT_OWNER_OR_AGENT, + }); + }); + + it('no code → WALLET_NOT_DEPLOYED; a contract that is not an AGW → NOT_AGENTIC_WALLET', async () => { + await expect( + resolveAgenticContext(mockRuntime(fake), ADDR.target) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.WALLET_NOT_DEPLOYED, + }); + await expect( + resolveAgenticContext(mockRuntime(fake), 'not-an-address') + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.NOT_AGENTIC_WALLET, + }); + }); + + it('wiring that does not match the registered generation is refused', async () => { + const w = fake.addWallet(ADDR.owner, 'w'); + w.accountId = 'push.agentwallet.1.0.0'; + await expect( + resolveAgenticContext( + mockRuntime(fake, { signer: ADDR.owner }), + w.address + ) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + }); + }); +}); + +describe('per-send rule selection', () => { + let fake: FakeChain; + beforeEach(() => { + registerFakeGeneration(); + fake = new FakeChain(); + }); + afterEach(() => resetAgenticGenerations()); + const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + + it('exactly one enabled (agent, chain) match is selected, at one block', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + fake.nativeRule(ADDR.other, ruleId(2)), + { ...fake.nativeRule(ADDR.agent, ruleId(3)), mode: 0, chain: SEPOLIA_NS }, + ]); + const snap = await Snapshot.at(fake); + const r = await selectRuleForSend( + snap, + gen(), + w.address, + ADDR.agent, + PUSH_NS + ); + expect(r.rulesId).toBe(ruleId(1)); + const u = await selectRuleForSend( + snap, + gen(), + w.address, + ADDR.agent, + SEPOLIA_NS + ); + expect(u.rulesId).toBe(ruleId(3)); + expect(new Set(fake.calls.map((c) => c.blockNumber))).toEqual( + new Set([fake.block]) + ); + }); + + it('zero matches → NO_RULES_FOR_CHAIN', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.other, ruleId(2)), + ]); + await expect( + selectRuleForSend( + await Snapshot.at(fake), + gen(), + w.address, + ADDR.agent, + PUSH_NS + ) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN }); + }); + + it('direct-grant duplicates → AMBIGUOUS_RULE listing every candidate, never an arbitrary pick', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + fake.nativeRule(ADDR.agent, ruleId(2)), + ]); + await expect( + selectRuleForSend( + await Snapshot.at(fake), + gen(), + w.address, + ADDR.agent, + PUSH_NS + ) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.AMBIGUOUS_RULE, + details: { rulesIds: [ruleId(1), ruleId(2)] }, + }); + }); + + it('a revoked rule is not a candidate; lookups are not cached between sends', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + ]); + expect( + ( + await selectRuleForSend( + await Snapshot.at(fake), + gen(), + w.address, + ADDR.agent, + PUSH_NS + ) + ).rulesId + ).toBe(ruleId(1)); + w.rules = []; + await expect( + selectRuleForSend( + await Snapshot.at(fake), + gen(), + w.address, + ADDR.agent, + PUSH_NS + ) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN }); + }); + + it('an RPC failure is an error, never an empty rule set', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + ]); + fake.failOn.add('getPermissionIDs'); + await expect( + selectRuleForSend( + await Snapshot.at(fake), + gen(), + w.address, + ADDR.agent, + PUSH_NS + ) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.RULE_READ_FAILED }); + }); +}); + +describe('rules.list / rules.get', () => { + let fake: FakeChain; + beforeEach(() => { + registerFakeGeneration(); + fake = new FakeChain(); + }); + afterEach(() => resetAgenticGenerations()); + const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + + it('decodes active native records without exposing internal spend', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1), { + callsUsed: 3, + valueSpent: BigInt(5), + maxCalls: 9, + }), + ]); + const rules = await listRules( + await Snapshot.at(fake), + gen(), + w.address, + PUSH_NS + ); + expect(rules).toEqual([ + expect.objectContaining({ + rulesId: ruleId(1), + enabled: true, + chainNamespace: PUSH_NS, + agent: ADDR.agent, + rule: expect.objectContaining({ maxCalls: 9, selector: '0xd09de08a' }), + }), + ]); + }); + + it('public records contain no spent field', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + ]); + const record = await getRule( + await Snapshot.at(fake), + gen(), + w.address, + ruleId(1), + PUSH_NS + ); + expect(record).not.toHaveProperty('spent'); + }); + + it('lists native and universal rules without truncating', async () => { + const w = fake.addWallet(ADDR.owner, 'w', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + { ...fake.nativeRule(ADDR.other, ruleId(2)), mode: 0, chain: SEPOLIA_NS }, + ]); + const records = await listRules( + await Snapshot.at(fake), + gen(), + w.address, + PUSH_NS + ); + expect(records.map((r) => r.chainNamespace)).toEqual([PUSH_NS, SEPOLIA_NS]); + }); + it('an empty source-token address is native only for the chain registry gas token', async () => { + const token = getAddress('0x0000000000000000000000000000000000007070'); + fake.sourceTokens.set(token.toLowerCase(), ''); + const w = fake.addWallet(ADDR.owner, 'native-gas', [ + { + ...fake.nativeRule(ADDR.agent, ruleId(1)), + mode: 0, + chain: SEPOLIA_NS, + universal: { + expectedCEA: ADDR.other, + maxGasPerCall: BigInt(1), + assets: [{ token }], + }, + }, + ]); + const record = await getRule( + await Snapshot.at(fake), + gen(), + w.address, + ruleId(1), + PUSH_NS + ); + expect(record.rule).toMatchObject({ + assets: [{ token: '0x0000000000000000000000000000000000000000' }], + }); + fake.sourceTokens.set(ADDR.target.toLowerCase(), ''); + w.rules[0].universal!.assets = [{ token: ADDR.target }]; + await expect( + getRule(await Snapshot.at(fake), gen(), w.address, ruleId(1), PUSH_NS) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.RULE_READ_FAILED }); + }); + + it('unknown and revoked IDs both return RULE_NOT_FOUND without history reconstruction', async () => { + const w = fake.addWallet(ADDR.owner, 'w'); + const snap = await Snapshot.at(fake); + await expect( + getRule(snap, gen(), w.address, ruleId(9), PUSH_NS) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.RULE_NOT_FOUND, + }); + fake.extraLogs.push( + log( + w.address, + v5.events.rulesRevoked, + { rulesId: ruleId(9) }, + {}, + BigInt(50) + ) + ); + await expect( + getRule(snap, gen(), w.address, ruleId(9), PUSH_NS) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.RULE_NOT_FOUND, + }); + }); +}); + +describe('wallet reads', () => { + let fake: FakeChain; + beforeEach(() => { + registerFakeGeneration(); + fake = new FakeChain(); + }); + afterEach(() => resetAgenticGenerations()); + const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + + it('derive defaults to the next unused index; beyond it is refused', async () => { + fake.addWallet(ADDR.owner, 'a'); + expect(await deriveForOwner(fake, gen(), ADDR.owner)).toMatchObject({ + index: 1, + deployed: false, + }); + expect(await deriveForOwner(fake, gen(), ADDR.owner, 0)).toMatchObject({ + index: 0, + deployed: true, + }); + await expect(deriveForOwner(fake, gen(), ADDR.owner, 2)).rejects.toThrow( + /beyond/ + ); + }); + + it('derivation drift between the factory and the SDK mirror is detected (obligation 13)', async () => { + fake.predictOverride = () => + getAddress('0x0000000000000000000000000000000000000bad'); + await expect(deriveForOwner(fake, gen(), ADDR.owner)).rejects.toMatchObject( + { + code: AGENTIC_ERROR_CODE.INCONSISTENT_READ, + } + ); + }); + + it('list returns deployed slots with labels and rule counts plus the next undeployed slot', async () => { + fake.addWallet(ADDR.owner, 'first', [ + fake.nativeRule(ADDR.agent, ruleId(1)), + ]); + fake.addWallet(ADDR.owner, ''); + const wallets = await listForOwner(fake, gen(), ADDR.owner); + expect( + wallets.map((w) => [w.index, w.label, w.deployed, w.rulesCount]) + ).toEqual([ + [0, 'first', true, 1], + [1, 'AGW 2', true, 0], + [2, '', false, 0], + ]); + }); + + it('an empty owner lists only slot 0, undeployed', async () => { + expect(await listForOwner(fake, gen(), ADDR.other)).toEqual([ + expect.objectContaining({ + index: 0, + deployed: false, + rulesCount: 0, + label: '', + }), + ]); + }); + + it('reads current labels without depending on deployment logs', async () => { + const w = fake.addWallet(ADDR.owner, 'original'); + w.label = 'renamed'; + fake.extraLogs = []; + expect(await walletInfo(fake, gen(), w.address)).toMatchObject({ label: 'renamed' }); + expect((await listForOwner(fake, gen(), ADDR.owner))[0].label).toBe('renamed'); + expect(fake.calls.filter((c) => c.functionName === 'label')).toHaveLength(2); + w.label = ''; + expect(await walletInfo(fake, gen(), w.address)).toMatchObject({ label: 'AGW 1' }); + }); + + it('info of the signer’s next slot describes it as undeployed; other undeployed addresses are refused', async () => { + const next = await deriveForOwner(fake, gen(), ADDR.owner); + expect( + await walletInfo(fake, gen(), next.address, ADDR.owner) + ).toMatchObject({ deployed: false, index: 0, owner: ADDR.owner }); + await expect( + walletInfo(fake, gen(), next.address, ADDR.other) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.WALLET_NOT_DEPLOYED, + }); + }); +}); + +describe('checkpoints', () => { + let fake: FakeChain; + beforeEach(() => { + registerFakeGeneration(); + fake = new FakeChain(); + }); + afterEach(() => resetAgenticGenerations()); + const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + + it('orders by block, tx and log index and counts five ticks for a replacement in one block', async () => { + const w = fake.addWallet(ADDR.owner, 'w'); + // A grant (seq 1), then a replacement batch (seq 2..6) in one tx; logs arrive reversed. + fake.checkpointLogs = [1, 2, 3, 4, 5, 6] + .map((seq) => + checkpointLog( + w.address, + seq, + [1, 0, 0, 2, 0, 1][seq - 1], + seq === 1 ? BigInt(20) : BigInt(60) + ) + ) + .reverse(); + w.checkpointCount = BigInt(6); + const { checkpoints } = await readCheckpoints(fake, gen(), w.address); + expect(checkpoints.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]); + expect(checkpoints.slice(1).map((c) => c.kind)).toEqual([ + 'OWNER_ACTION', + 'OWNER_ACTION', + 'RULES_REVOKED', + 'OWNER_ACTION', + 'RULES_GRANTED', + ]); + const since = await readCheckpoints(fake, gen(), w.address, BigInt(60)); + expect(since.checkpoints).toHaveLength(5); + expect(since.checkpoints.every((c) => c.blockNumber === BigInt(60))).toBe( + true + ); + }); + + it('a count/event mismatch is reported (after one retry) rather than returned', async () => { + const w = fake.addWallet(ADDR.owner, 'w'); + fake.checkpointLogs = [checkpointLog(w.address, 1, 1, BigInt(20))]; + w.checkpointCount = BigInt(2); + await expect(readCheckpoints(fake, gen(), w.address)).rejects.toMatchObject( + { + code: AGENTIC_ERROR_CODE.INCONSISTENT_READ, + } + ); + }); + + it('validateSequence checks contiguity and the count', () => { + expect(validateSequence([1, 2, 3], BigInt(3), true)).toBeNull(); + expect(validateSequence([1, 3], BigInt(3), true)).toMatch(/gap/); + expect(validateSequence([2, 3], BigInt(3), false)).toBeNull(); + expect(validateSequence([2, 3], BigInt(4), false)).toMatch( + /does not match/ + ); + expect(validateSequence([], BigInt(0), true)).toBeNull(); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts new file mode 100644 index 000000000..3b508a491 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts @@ -0,0 +1,254 @@ +import { + encodeErrorResult, + encodeFunctionData, + getAddress, + type Hex, +} from 'viem'; +import * as core from '../../index'; +import { CONSTANTS } from '../../constants'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { UNIVERSAL_GATEWAY_PC } from '../../constants/abi'; +import { PushChainExecutionError } from '../../orchestrator/internals/errors'; +import { Utils } from '../../utils'; +import { AGW_ABI } from '../contracts/abi/v5'; +import { v5 } from '../contracts/v5'; +import { resetAgenticGenerations } from '../deployments'; +import { AgenticRevertError } from '../errors'; +import { adaptAgenticResponse, adaptTrackedResponse } from '../response'; +import { decodeAgenticRevert, decodeAgwErrorData } from '../revert'; +import { ADDR, FakeChain, registerFakeGeneration, ruleId } from './fake-chain'; +import { fakeResponse } from './mock-runtime'; + +afterEach(() => resetAgenticGenerations()); + +describe('response identity', () => { + it('live adaptation keeps origin/hash/raw and records the wrapped call', () => { + const resp = fakeResponse({ + to: ADDR.target, + data: '0xabcd', + from: ADDR.agent, + }); + const origin = resp.origin; + adaptAgenticResponse(resp, { + wallet: ADDR.owner, + door: 'agent', + rulesId: ruleId(1), + logical: { to: ADDR.other, data: '0x01', value: BigInt(2) }, + }); + expect(resp).toMatchObject({ + from: ADDR.owner, + to: ADDR.other, + data: '0x01', + value: BigInt(2), + origin, + }); + expect(resp.raw).toMatchObject({ + from: ADDR.agent, + to: ADDR.target, + data: '0xabcd', + }); + expect(resp.agentic).toMatchObject({ + rawTo: ADDR.target, + rawData: '0xabcd', + door: 'agent', + }); + }); + + it('replay: a tracked execute on a registered wallet is re-presented as the wallet’s call', async () => { + registerFakeGeneration(); + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w'); + const data = v5.encodeExecuteAsAgent(ruleId(4), { + target: ADDR.target, + value: BigInt(0), + data: '0xd09de08a', + }); + const tracked = await adaptTrackedResponse( + { reader: fake, network: PUSH_NETWORK.TESTNET_DONUT }, + fakeResponse({ to: w.address, data, from: ADDR.agent }) + ); + expect(tracked).toMatchObject({ + from: w.address, + to: ADDR.target, + data: '0xd09de08a', + }); + expect(tracked.agentic).toMatchObject({ + door: 'agent', + rulesId: ruleId(4), + }); + }); + + it('replay: an outbound through the gateway restores the destination call and route', async () => { + registerFakeGeneration(); + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w'); + const payload = ('0x2cc2842d' + + encodeFunctionData({ + abi: [ + { + type: 'function', + name: 'x', + inputs: [ + { + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + ], + args: [[{ to: ADDR.target, value: BigInt(0), data: '0xd09de08a' }]], + }).slice(10)) as Hex; + const gatewayCall = encodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + functionName: 'sendUniversalTxOutbound', + args: [ + { + recipient: '0x', + token: ADDR.other, + amount: BigInt(1), + gasLimit: BigInt(1), + gasPrice: BigInt(0), + maxPCForGas: BigInt(1), + payload, + revertRecipient: w.address, + }, + ], + }); + const data = v5.encodeExecuteAsAgent(ruleId(4), { + target: ADDR.gateway, + value: BigInt(5), + data: gatewayCall, + }); + const tracked = await adaptTrackedResponse( + { reader: fake, network: PUSH_NETWORK.TESTNET_DONUT }, + fakeResponse({ to: w.address, data }) + ); + expect(tracked).toMatchObject({ + from: w.address, + to: ADDR.target, + data: '0xd09de08a', + route: 'UOA_TO_CEA', + }); + // A Push-only route inferred from an incomplete Cosmos record is replaced. + const early = await adaptTrackedResponse( + { reader: fake, network: PUSH_NETWORK.TESTNET_DONUT }, + fakeResponse({ to: w.address, data, route: 'UOA_TO_PUSH' }) + ); + expect(early.route).toBe('UOA_TO_CEA'); + }); + + it('replay: anything not provably an AGW call is left untouched', async () => { + const fake = new FakeChain(); + const plain = fakeResponse({ to: ADDR.target, data: '0xd09de08a' }); + expect( + await adaptTrackedResponse( + { reader: fake, network: PUSH_NETWORK.TESTNET_DONUT }, + plain + ) + ).toBe(plain); + registerFakeGeneration(); + const spoof = fakeResponse({ + to: ADDR.target, + data: v5.encodeExecute([ + { target: ADDR.other, value: BigInt(0), data: '0x' }, + ]), + }); + const out = await adaptTrackedResponse( + { reader: fake, network: PUSH_NETWORK.TESTNET_DONUT }, + spoof + ); + expect(out.from).toBe(spoof.from); + expect(out.agentic).toBeUndefined(); + }); +}); + +describe('revert decoding', () => { + it('decodes AGW errors with arguments', () => { + const data = encodeErrorResult({ + abi: AGW_ABI, + errorName: 'CallerIsNotAgent', + args: [ruleId(1), ADDR.other], + }); + expect(decodeAgwErrorData(data)).toMatchObject({ + name: 'CallerIsNotAgent', + }); + }); + + it('names a truncated PolicyCheckReverted by its inner selector', () => { + expect( + decodeAgwErrorData(`0xf4270752${'8bb88ba1'.padEnd(64, '0')}`) + ).toMatchObject({ + name: 'PolicyCheckReverted(CallLimitReached)', + selector: '0xf4270752', + }); + }); + + it('finds revert data through error causes and viem-style messages', () => { + const inner = new Error('outer', { + cause: { + data: encodeErrorResult({ + abi: AGW_ABI, + errorName: 'UnsupportedExecutionMode', + }), + }, + }); + expect(decodeAgenticRevert(inner)?.name).toBe('UnsupportedExecutionMode'); + expect(decodeAgenticRevert(new Error('nothing here'))).toBeUndefined(); + }); + it('decodes viem simulation errors whose full revert lives in raw', () => { + const raw = `0xf4270752${'8bb88ba1'.padEnd(64, '0')}` as Hex; + expect( + decodeAgenticRevert({ + message: 'Unknown signature 0xf4270752', + cause: { raw }, + })?.name + ).toBe('PolicyCheckReverted(CallLimitReached)'); + }); +}); + +describe('public surface', () => { + it('exports the AGW errors, and the base execution error for instanceof checks', () => { + expect(core.AgenticError).toBeDefined(); + expect(new core.AgenticRevertError('x')).toBeInstanceOf( + core.PushChainExecutionError + ); + expect(core.PushChainExecutionError).toBe(PushChainExecutionError); + expect(core.AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN).toBe( + 'NO_RULES_FOR_CHAIN' + ); + expect(new AgenticRevertError('x', { cause: 'c' }).cause).toBe('c'); + }); + + it('CONSTANTS.AGENTIC carries the checked Donut v5 proxies', () => { + expect(CONSTANTS.AGENTIC.TESTNET_DONUT).toMatchObject({ + FACTORY: '0x8137F96A50EBF41d904e3678c84c391a0D1BCcc5', + RULES_POLICY: '0x603E7f0aF6e1aAFf46DDfb28b1e99364f8BC59af', + ENVELOPE_VERSION: 1, + }); + expect(Object.isFrozen(CONSTANTS.AGENTIC)).toBe(true); + }); + + it('PushChain.utils.agentic is the pure helper set', () => { + expect(Object.keys(Utils.agentic).sort()).toEqual( + ['actionId', 'configId', 'decodeRules'].sort() + ); + // @ts-expect-error generation context helpers are not public + expect(Utils.agentic.rulesId).toBeUndefined(); + // @ts-expect-error the marketplace compiler is outside AGW + expect(Utils.agentic.compileCard).toBeUndefined(); + for (const name of [ + 'rulesId', + 'deriveWallet', + 'encodeRules', + 'compileCard', + ]) { + expect(Utils.agentic).not.toHaveProperty(name); + } + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts b/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts new file mode 100644 index 000000000..fe3013760 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts @@ -0,0 +1,348 @@ +import { getAddress, type Address } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../constants/tokens'; +import { getPRC20Address } from '../../universal/prc20-address'; +import { encodeUniversalTerms } from '../codec/universal-terms'; +import { buildSession, encodeEnvelope, encodeSession } from '../codec/session'; +import { prepareRules } from '../codec/rules'; +import { validateUniversalRuleShape } from '../codec/universal'; +import { V5_CAPABILITIES } from '../deployments'; +import { AGENTIC_ERROR_CODE } from '../errors'; +import { internalAgenticUtils } from '../utils'; +import type { NativeRule, UniversalRule } from '../agentic.types'; +import { ADDR, PUSH_NS, SEPOLIA_NS } from './fake-chain'; + +const NOW = 1_700_000_000; +const ctx = { + owner: ADDR.owner, + pushChainNamespace: PUSH_NS, + validator: ADDR.validator, + rulesPolicy: ADDR.policy, + capabilities: V5_CAPABILITIES, + nowSeconds: NOW, + forbiddenTargets: [ + ADDR.factory, + ADDR.engine, + ADDR.policy, + ADDR.validator, + ADDR.gateway, + ], +}; +const native = (over: Partial = {}): NativeRule => ({ + agent: ADDR.agent, + target: ADDR.target, + selector: 'increment()', + validUntil: NOW + 100, + ...over, +}); +const universal = (over: Partial = {}): UniversalRule => ({ + agent: ADDR.agent, + chainNamespace: SEPOLIA_NS, + assets: [ + { + token: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + maxPerCall: BigInt(100), + }, + ], + maxGasPerCall: BigInt(10) ** BigInt(18), + validUntil: NOW + 100, + allowedCalls: [ + { + target: ADDR.target, + selector: 'supply(address,uint256,address,uint16)', + beneficiary: 2, + }, + ], + ...over, +}); + +describe('prepareRules — spec 1.b checks before any signature', () => { + it('AGENT_IS_OWNER', () => { + expect(() => prepareRules([native({ agent: ADDR.owner })], ctx)).toThrow( + expect.objectContaining({ code: AGENTIC_ERROR_CODE.AGENT_IS_OWNER }) + ); + }); + + it('allows multiple rules for the same agent and chain', () => { + const prepared = prepareRules( + [native(), native({ target: ADDR.other })], + ctx + ); + expect(prepared).toHaveLength(2); + expect(prepared.map((p) => p.agent)).toEqual([ADDR.agent, ADDR.agent]); + expect(prepared.map((p) => p.nativeTerms?.target)).toEqual([ + ADDR.target, + ADDR.other, + ]); + }); + + it('native and universal rules coexist for the same agent', () => { + const u = universal(); + const prepared = prepareRules([native(), u], { + ...ctx, + gateway: ADDR.gateway, + universal: new Map([ + [ + u, + { + expectedCEA: ADDR.other, + assets: [ + { + token: ADDR.target, + maxPerCall: BigInt(100), + maxTotal: BigInt(1000), + }, + ], + }, + ], + ]), + }); + expect(prepared.map((p) => p.chainNamespace)).toEqual([ + PUSH_NS, + SEPOLIA_NS, + ]); + }); + + it('removed ref inputs fail explicitly instead of being silently dropped', () => { + expect(() => prepareRules([{ ...native(), ref: `0x${'99'.repeat(32)}` } as never], ctx)) + .toThrow(expect.objectContaining({ code: AGENTIC_ERROR_CODE.INVALID_RULE })); + }); + + it('refuses infrastructure targets the contracts would reject', () => { + for (const t of [ + ADDR.gateway, + ADDR.policy, + '0x0000000000000000000000000000000000000001' as Address, + ]) { + expect(() => prepareRules([native({ target: t })], ctx)).toThrow( + /infrastructure/ + ); + } + }); + + it('a universal rule naming the Push chain is rejected', () => { + expect(() => + prepareRules( + [universal({ chainNamespace: PUSH_NS as `eip155:${string}` })], + ctx + ) + ).toThrow(/omit chainNamespace/); + }); + + it('valid native rules produce one Session each, agent bound in the validator config', () => { + const [p] = prepareRules([native()], ctx); + expect(p.chainNamespace).toBe(PUSH_NS); + expect(p.session.sessionValidator).toBe(ADDR.validator); + expect(p.session.actions).toHaveLength(1); + expect(p.session.actions[0].actionPolicies[0].policy).toBe(ADDR.policy); + expect(p.session.permitERC4337Paymaster).toBe(false); + }); +}); + +describe('universal rules — validated, then capability-gated (A01, A05)', () => { + it('structural validation runs before the capability gate', () => { + expect(() => + validateUniversalRuleShape(universal({ allowedCalls: [] }), NOW) + ).toThrow(/1\.\.32/); + expect(() => + validateUniversalRuleShape(universal({ maxGasPerCall: BigInt(0) }), NOW) + ).toThrow(/maxGasPerCall/); + expect(() => + validateUniversalRuleShape( + universal({ chainNamespace: 'eip155:abc' as `eip155:${string}` }), + NOW + ) + ).toThrow(/CAIP-2/); + expect(() => + validateUniversalRuleShape( + universal({ + assets: Array.from({ length: 9 }, (_, i) => ({ + token: getAddress(`0x${(i + 1).toString(16).padStart(40, '0')}`), + maxPerCall: BigInt(1), + })), + }), + NOW + ) + ).toThrow(/at most 8/); + expect(() => + validateUniversalRuleShape( + universal({ + assets: [ + { token: ADDR.target, maxPerCall: BigInt(1) }, + { token: ADDR.target, maxPerCall: BigInt(2) }, + ], + }), + NOW + ) + ).toThrow(/duplicates/); + }); + + it('approval policy belongs to UI/marketplace; structurally valid approval calls are accepted', () => { + for (const selector of [ + 'approve(address,uint256)', + 'increaseAllowance(address,uint256)', + ] as const) { + expect(() => + validateUniversalRuleShape( + universal({ allowedCalls: [{ target: ADDR.target, selector }] }), + NOW + ) + ).not.toThrow(); + } + }); + + it('a beneficiary must be an address argument of a known signature', () => { + expect(() => + validateUniversalRuleShape( + universal({ + allowedCalls: [ + { + target: ADDR.target, + selector: 'supply(address,uint256,address,uint16)', + beneficiary: 1, + }, + ], + }), + NOW + ) + ).toThrow(/address argument/); + expect(() => + validateUniversalRuleShape( + universal({ + allowedCalls: [ + { target: ADDR.target, selector: '0x617ba037', beneficiary: 2 }, + ], + }), + NOW + ) + ).toThrow(/signature/); + }); + + it('requires resolved wallet/token context and named SVM IDL inputs', () => { + expect(() => prepareRules([universal()], ctx)).toThrow( + expect.objectContaining({ + code: AGENTIC_ERROR_CODE.INVALID_RULE, + }) + ); + expect(() => + prepareRules( + [ + universal({ + chainNamespace: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1' as never, + }), + ], + ctx + ) + ).toThrow( + expect.objectContaining({ + code: AGENTIC_ERROR_CODE.INVALID_RULE, + }) + ); + }); +}); + +describe('internal generation codecs (pure)', () => { + const u = internalAgenticUtils; + + it('encodeRules → decodeRules round-trips a native rule', () => { + const rule = native({ + selector: 'deposit(address,uint256)', + pins: [{ arg: 0, expected: ADDR.other }], + maxCalls: 4, + }); + const [bytes] = u.encodeRules([rule], { + pushChainNamespace: PUSH_NS, + validator: ADDR.validator, + rulesPolicy: ADDR.policy, + nowSeconds: NOW, + }); + const back = u.decodeRules(bytes, { pushChainNamespace: PUSH_NS }); + expect(back).toMatchObject({ + agent: ADDR.agent, + target: ADDR.target, + maxCalls: 4, + validUntil: NOW + 100, + }); + expect((back as NativeRule).pins).toEqual([ + { + offset: 4, + expected: `0x${'0'.repeat(24)}${ADDR.other.slice(2).toLowerCase()}`, + }, + ]); + }); + + it('decodeRules rejects a foreign-chain envelope carrying native terms', () => { + const [bytes] = u.encodeRules([native()], { + pushChainNamespace: SEPOLIA_NS, + validator: ADDR.validator, + rulesPolicy: ADDR.policy, + nowSeconds: NOW, + }); + expect(() => u.decodeRules(bytes, { pushChainNamespace: PUSH_NS })).toThrow( + expect.objectContaining({ + code: AGENTIC_ERROR_CODE.RULE_READ_FAILED, + }) + ); + }); + + it('pure decoding resolves known v5 assets and preserves the exact beneficiary offset', () => { + const inputToken = MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.USDC; + const prc20 = getPRC20Address(inputToken, { + network: PUSH_NETWORK.TESTNET_DONUT, + }).address; + const bytes = encodeSession( + buildSession({ + validator: ADDR.validator, + agent: ADDR.agent, + rulesPolicy: ADDR.policy, + actions: [ + { + target: ADDR.gateway, + selector: '0x77b86bec', + initData: encodeEnvelope( + CHAIN.ETHEREUM_SEPOLIA, + encodeUniversalTerms({ + validUntil: NOW + 100, + expectedCEA: ADDR.other, + assets: [ + { + token: prc20, + maxPerCall: BigInt(10), + maxTotal: BigInt(100), + }, + ], + maxGasPerCall: BigInt(20), + allowedCalls: [ + { + target: ADDR.target, + selector: '0x617ba037', + beneficiaryOffset: 68, + hasBeneficiary: true, + maxValue: BigInt(0), + }, + ], + }) + ), + }, + ], + }) + ); + expect(u.decodeRules(bytes)).toMatchObject({ + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + assets: [ + { + token: getAddress(inputToken.address), + maxPerCall: BigInt(10), + maxTotal: BigInt(100), + }, + ], + allowedCalls: [{ beneficiaryOffset: 68 }], + }); + }); + + it('actionId accepts signatures and hex alike', () => { + expect(u.actionId(ADDR.target, 'increment()')).toBe( + u.actionId(ADDR.target, '0xd09de08a') + ); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/send.spec.ts b/packages/core/src/lib/agentic/__tests__/send.spec.ts new file mode 100644 index 000000000..5291fac1d --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/send.spec.ts @@ -0,0 +1,680 @@ +import { + decodeAbiParameters, + decodeFunctionData, + encodeFunctionData, + getAddress, + maxUint256, + type Address, + type Hex, +} from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { UNIVERSAL_GATEWAY_PC, ERC20_EVM } from '../../constants/abi'; +import { PushChainExecutionError } from '../../orchestrator/internals/errors'; +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import type { AgenticExecutionContext } from '../context'; +import { currentGeneration, resetAgenticGenerations } from '../deployments'; +import { v5 } from '../contracts/v5'; +import { actionId } from '../codec/ids'; +import { AGENTIC_ERROR_CODE, AgenticRevertError } from '../errors'; +import { agenticSend } from '../execution/send'; +import { + ADDR, + FakeChain, + SEPOLIA_NS, + registerFakeGeneration, + ruleId, +} from './fake-chain'; +import { mockRuntime } from './mock-runtime'; + +const data = '0xd09de08a' as Hex; + +function setup( + door: 'owner' | 'agent', + rules = (f: FakeChain) => [f.nativeRule(ADDR.agent, ruleId(1))] +) { + registerFakeGeneration(); + const fake = new FakeChain(); + const w = fake.addWallet(ADDR.owner, 'w', rules(fake)); + const gen = currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + const signer = door === 'owner' ? ADDR.owner : ADDR.agent; + const ctx: AgenticExecutionContext = { + wallet: w.address, + door, + signerPushAccount: signer, + signerOrigin: { chain: CHAIN.PUSH_TESTNET_DONUT, address: signer }, + generation: gen, + }; + return { fake, w, gen, ctx, rt: mockRuntime(fake, { signer }) }; +} + +afterEach(() => resetAgenticGenerations()); + +describe('guards fail before the signer is ever invoked', () => { + it.each([ + [ + 'from (Routes 3/4)', + { to: ADDR.target, data, from: { chain: CHAIN.ETHEREUM_SEPOLIA } }, + AGENTIC_ERROR_CODE.FROM_NOT_ALLOWED, + ], + [ + 'payGasWith', + { to: ADDR.target, data, payGasWith: {} }, + AGENTIC_ERROR_CODE.NOT_ALLOWED_IN_AGENTIC_MODE, + ], + [ + 'migration', + { + to: { address: ADDR.target, chain: CHAIN.ETHEREUM_SEPOLIA }, + migration: true, + }, + AGENTIC_ERROR_CODE.NOT_ALLOWED_IN_AGENTIC_MODE, + ], + [ + 'funds on a Push send', + { to: ADDR.target, funds: { amount: BigInt(1) } }, + AGENTIC_ERROR_CODE.NOT_ALLOWED_IN_AGENTIC_MODE, + ], + ])('%s', async (_name, params, code) => { + const { rt, ctx } = setup('agent'); + await expect(agenticSend(rt, ctx, params as never)).rejects.toMatchObject({ + code, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); + + it('read-only clients never sign', async () => { + const { fake, ctx } = setup('owner'); + const rt = mockRuntime(fake, { signer: ADDR.owner, readOnly: true }); + await expect( + agenticSend(rt, ctx, { to: ADDR.target, data }) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.READ_ONLY, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + 'agent native arrays require an atomic sender-preserving transport (Push EOA=%s)', + async (native) => { + const { rt, ctx } = setup('agent'); + rt.signerIsPushNative = () => native; + const calls = [ + { to: ADDR.target, value: BigInt(0), data }, + { to: ADDR.target, value: BigInt(1), data }, + ]; + const tx = await agenticSend(rt, ctx, { to: ADDR.target, data: calls }); + expect(rt.executeMock).not.toHaveBeenCalled(); + expect(rt.atomicMock).toHaveBeenCalledTimes(1); + const batch = rt.atomicMock.mock.calls[0][0].data as { + to: Address; + value: bigint; + data: Hex; + }[]; + expect(batch.map((c) => c.to)).toEqual([ctx.wallet, ctx.wallet]); + expect(batch.map((c) => v5.decodeWalletCall(c.data))).toEqual( + calls.map((c) => ({ + kind: 'executeAsAgent', + rulesId: ruleId(1), + mode: `0x${'00'.repeat(32)}`, + calls: [{ target: c.to, value: c.value, data: c.data }], + })) + ); + expect(tx.agentic?.nativeCalls).toEqual(calls); + } + ); + + it('empty native arrays fail before any transport is called', async () => { + const { rt, ctx } = setup('agent'); + await expect( + agenticSend(rt, ctx, { to: ADDR.target, data: [] }) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.INVALID_RULE }); + expect(rt.executeMock).not.toHaveBeenCalled(); + expect(rt.atomicMock).not.toHaveBeenCalled(); + }); + + it('NO_RULES_FOR_CHAIN and AMBIGUOUS_RULE are pre-signature', async () => { + const none = setup('agent', () => []); + await expect( + agenticSend(none.rt, none.ctx, { to: ADDR.target, data }) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN, + }); + const dup = setup('agent', (f) => [ + f.nativeRule(ADDR.agent, ruleId(1)), + f.nativeRule(ADDR.agent, ruleId(2)), + ]); + await expect( + agenticSend(dup.rt, dup.ctx, { to: ADDR.target, data }) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.AMBIGUOUS_RULE, + }); + expect(none.rt.executeMock).not.toHaveBeenCalled(); + expect(dup.rt.executeMock).not.toHaveBeenCalled(); + }); + + it('an external agent whose deployed UEA cannot pay Push gas fails loudly; an undeployed UEA takes the first-use path', async () => { + const { fake, ctx } = setup('agent'); + const poor = mockRuntime(fake, { + signer: ADDR.agent, + signerIsPushNative: () => false, + signerAccountDeployed: async () => true, + signerBalance: async () => BigInt(5), + getGasPrice: async () => BigInt(1), + }); + await expect( + agenticSend(poor, ctx, { to: ADDR.target, data }) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.AGENT_GAS_INSUFFICIENT, + }); + expect(poor.executeMock).not.toHaveBeenCalled(); + const firstUse = mockRuntime(fake, { + signer: ADDR.agent, + signerIsPushNative: () => false, + signerAccountDeployed: async () => false, + signerBalance: async () => BigInt(0), + }); + await agenticSend(firstUse, ctx, { to: ADDR.target, data }); + expect(firstUse.executeMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('explicit Push destinations', () => { + it('a ChainTarget on the connected Push chain is a native send, same as a bare address', async () => { + const { rt, ctx } = setup('agent'); + const tx = await agenticSend(rt, ctx, { + to: { address: ADDR.target, chain: CHAIN.PUSH_TESTNET_DONUT }, + data, + }); + expect( + v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) + ).toMatchObject({ kind: 'executeAsAgent', rulesId: ruleId(1) }); + expect(tx.route).not.toBe('UOA_TO_CEA'); + expect(tx.to).toBe(ADDR.target); + }); + + it('the owner door treats it the same way', async () => { + const { rt, ctx } = setup('owner'); + await agenticSend(rt, ctx, { + to: { address: ADDR.target, chain: CHAIN.PUSH_TESTNET }, + data, + }); + expect( + v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) + ).toMatchObject({ kind: 'execute', calls: [{ target: ADDR.target }] }); + }); + + it('another Push network is refused, not routed outbound', async () => { + const { rt, ctx } = setup('owner'); + await expect( + agenticSend(rt, ctx, { + to: { address: ADDR.target, chain: CHAIN.PUSH_MAINNET }, + data, + }) + ).rejects.toThrow(/other than the connected network/); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); +}); + +describe('native wrapping and logical identity', () => { + it('agent door: executeAsAgent(selected rule, exact call); response shows the wallet as from', async () => { + const { rt, ctx, w } = setup('agent'); + const perCall = jest.fn(); + const tx = await agenticSend( + rt, + ctx, + { to: ADDR.target, data, value: BigInt(3) }, + { progressHook: perCall } + ); + const sent = rt.executeMock.mock.calls[0][0]; + expect(sent.to).toBe(w.address); + expect(sent.value).toBe(BigInt(0)); + expect(v5.decodeWalletCall(sent.data)).toEqual({ + kind: 'executeAsAgent', + rulesId: ruleId(1), + mode: `0x${'00'.repeat(32)}`, + calls: [{ target: ADDR.target, value: BigInt(3), data }], + }); + expect(tx).toMatchObject({ + from: w.address, + to: ADDR.target, + data, + value: BigInt(3), + }); + expect(tx.origin).toContain(ADDR.agent); + expect(tx.agentic).toMatchObject({ + wallet: w.address, + door: 'agent', + rulesId: ruleId(1), + rawTo: w.address, + }); + const ids = rt.events.map((e) => e.id); + expect(ids).toEqual([ + PROGRESS_HOOK.AGENTIC_TX_107, + PROGRESS_HOOK.AGENTIC_TX_199_01, + ]); + expect(perCall).toHaveBeenCalledTimes(2); + expect(rt.executeMock.mock.calls[0][1]).toMatchObject({ + progressHook: perCall, + }); + }); + + it('owner door: arrays become one atomic execute(batch)', async () => { + const { rt, ctx } = setup('owner'); + await agenticSend(rt, ctx, { + to: ADDR.target, + data: [ + { to: ADDR.target, value: BigInt(0), data }, + { to: ADDR.other, value: BigInt(1), data: '0x' }, + ], + }); + const decoded = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data); + expect(decoded).toMatchObject({ + kind: 'execute', + mode: `0x01${'00'.repeat(31)}`, + }); + expect((decoded as { calls: unknown[] }).calls).toHaveLength(2); + }); + + it('wallet waits report the wallet on the receipt too', async () => { + const { rt, ctx, w } = setup('agent'); + const tx = await agenticSend(rt, ctx, { to: ADDR.target, data }); + expect((await tx.wait()).from).toBe(w.address); + }); + + it('an on-chain policy revert becomes AgenticRevertError with the decoded gate and 199-02', async () => { + const { fake, ctx } = setup('agent'); + const rt = mockRuntime(fake, { + signer: ADDR.agent, + execute: async () => { + throw new PushChainExecutionError( + 'Execution reverted with reason: custom error 0xf4270752: 8bb88ba100000000000000000000000000000000000000000000000000000000.' + ); + }, + }); + const err = await agenticSend(rt, ctx, { to: ADDR.target, data }).catch( + (e) => e + ); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(err).toBeInstanceOf(PushChainExecutionError); + expect(err.decodedError.name).toBe('PolicyCheckReverted(CallLimitReached)'); + const failed = rt.events.find( + (e) => e.id === PROGRESS_HOOK.AGENTIC_TX_199_02 + ); + expect(failed?.response).toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(CallLimitReached)' }, + }); + }); +}); + +describe('EVM outbound composition from the wallet', () => { + const token = getAddress('0x0000000000000000000000000000000000007070'); + const universalSetup = () => { + const s = setup('agent', (f) => [ + { + ...f.nativeRule(ADDR.agent, ruleId(5)), + mode: 0, + chain: SEPOLIA_NS, + actionIds: [], + universal: { + assets: [{ token }], + expectedCEA: getAddress('0x000000000000000000000000000000000000cea1'), + maxGasPerCall: BigInt(5000), + }, + }, + ]); + // universal action id is (gateway, sendUniversalTxOutbound) + s.w.rules[0].actionIds = [actionId(ADDR.gateway, '0x77b86bec')]; + s.fake.balances.set( + `pc:${s.w.address.toLowerCase()}`, + BigInt(10) ** BigInt(18) + ); + s.fake.balances.set(`balanceOf:${token.toLowerCase()}`, BigInt(1000)); + s.fake.balances.set(`allowance:${token.toLowerCase()}`, BigInt(500)); + return s; + }; + const send = (amount?: bigint) => ({ + to: { address: ADDR.target, chain: CHAIN.ETHEREUM_SEPOLIA }, + data, + ...(amount + ? { + funds: { + amount, + token: { + symbol: 'USDC', + decimals: 6, + address: token, + mechanism: 'approve' as const, + }, + }, + } + : {}), + }); + + it('builds the policy-compatible request: empty recipient, wallet refund, nonzero gas cap, multicall payload, CEA of the wallet', async () => { + const s = universalSetup(); + const resolveCEA = jest.fn(async () => ({ + cea: getAddress('0x000000000000000000000000000000000000cea1'), + isDeployed: true, + })); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolveCEA, + resolvePrc20: () => token, + }); + const tx = await agenticSend(rt, s.ctx, send(BigInt(100))); + expect(resolveCEA).toHaveBeenCalledWith( + s.w.address, + CHAIN.ETHEREUM_SEPOLIA + ); + const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + kind: string; + rulesId: Hex; + calls: { target: Address; value: bigint; data: Hex }[]; + }; + expect(outer.kind).toBe('executeAsAgent'); + expect(outer.rulesId).toBe(ruleId(5)); + expect(outer.calls[0].target).toBe(ADDR.gateway); + expect(outer.calls[0].value).toBe(BigInt(1100)); + const { args } = decodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + data: outer.calls[0].data, + }); + expect(args[0]).toMatchObject({ + recipient: '0x', + token, + amount: BigInt(100), + maxPCForGas: BigInt(1000), + revertRecipient: s.w.address, + }); + expect((args[0] as { payload: Hex }).payload.startsWith('0x2cc2842d')).toBe( + true + ); + expect(tx).toMatchObject({ + from: s.w.address, + to: token, + data: encodeFunctionData({ + abi: ERC20_EVM, + functionName: 'transfer', + args: [ADDR.target, BigInt(100)], + }), + route: 'UOA_TO_CEA', + chain: CHAIN.ETHEREUM_SEPOLIA, + }); + expect(tx.agentic?.destinationCalls).toHaveLength(2); + expect(tx.agentic?.destinationAccount).toBe( + getAddress('0x000000000000000000000000000000000000cea1') + ); + }); + + it('never injects an approval into the agent path; missing allowance fails before signing', async () => { + const s = universalSetup(); + s.fake.balances.set(`allowance:${token.toLowerCase()}`, BigInt(10)); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + }); + await expect( + agenticSend(rt, s.ctx, send(BigInt(100))) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); + + it('wallet funding is checked locally; rule PC limits are enforced by the contract', async () => { + const s = universalSetup(); + s.fake.balances.set(`pc:${s.w.address.toLowerCase()}`, BigInt(10)); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + }); + await expect(agenticSend(rt, s.ctx, send())).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, + }); + const s2 = universalSetup(); + const pricey = mockRuntime(s2.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + quoteOutbound: async () => ({ + protocolFee: BigInt(100), + nativeValueForGas: BigInt(10_000), + gasLimitUsed: BigInt(1), + }), + }); + const response = await agenticSend(pricey, s2.ctx, send()); + expect(pricey.executeMock).toHaveBeenCalledTimes(1); + expect(response.agentic?.rulesId).toBe(ruleId(5)); + }); + + it('a committed destination account that no longer matches derivation is drift, not a send', async () => { + const s = universalSetup(); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + resolveCEA: async () => ({ + cea: getAddress('0x000000000000000000000000000000000000dead'), + isDeployed: true, + }), + }); + await expect(agenticSend(rt, s.ctx, send())).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.INCONSISTENT_READ, + }); + }); + + it('a zero gas quote is refused (URP rejects an uncapped gas swap)', async () => { + const s = universalSetup(); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + quoteOutbound: async () => ({ + protocolFee: BigInt(1), + nativeValueForGas: BigInt(0), + gasLimitUsed: BigInt(1), + }), + }); + await expect(agenticSend(rt, s.ctx, send())).rejects.toThrow( + /uncapped gas swap/ + ); + }); + + it('SVM destinations reject EVM-width program addresses', async () => { + const s = universalSetup(); + const rt = mockRuntime(s.fake, { signer: ADDR.agent }); + await expect( + agenticSend( + rt, + { ...s.ctx, door: 'owner' }, + { to: { address: ADDR.target, chain: CHAIN.SOLANA_DEVNET }, data } + ) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.INVALID_RULE, + }); + }); + + it('owner door never writes an allowance: it sends only the outbound and consumes the existing allowance', async () => { + const s = universalSetup(); + s.ctx.door = 'owner'; + s.ctx.signerPushAccount = ADDR.owner; + const rt = mockRuntime(s.fake, { + signer: ADDR.owner, + resolvePrc20: () => token, + }); + await agenticSend(rt, s.ctx, send(BigInt(100))); + const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + kind: string; + calls: { target: Address; data: Hex }[]; + }; + expect(outer.kind).toBe('execute'); + expect(outer.calls.map((c) => c.target)).toEqual([ADDR.gateway]); + }); + + it('owner door with too small an allowance fails before signing (no implicit approval)', async () => { + const s = universalSetup(); + s.ctx.door = 'owner'; + s.ctx.signerPushAccount = ADDR.owner; + s.fake.balances.set(`allowance:${token.toLowerCase()}`, BigInt(10)); + const rt = mockRuntime(s.fake, { + signer: ADDR.owner, + resolvePrc20: () => token, + }); + await expect( + agenticSend(rt, s.ctx, send(BigInt(100))) + ).rejects.toMatchObject({ + code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); + + it('an existing unlimited allowance is consumed as-is', async () => { + const s = universalSetup(); + s.ctx.door = 'owner'; + s.ctx.signerPushAccount = ADDR.owner; + s.fake.balances.set(`allowance:${token.toLowerCase()}`, maxUint256); + const rt = mockRuntime(s.fake, { + signer: ADDR.owner, + resolvePrc20: () => token, + }); + await expect( + agenticSend(rt, s.ctx, send(BigInt(100))) + ).resolves.toBeDefined(); + }); + + it('a transfer-only ERC-20 outbound instructs a transfer to the requested recipient', async () => { + const s = universalSetup(); + s.ctx.door = 'owner'; + s.ctx.signerPushAccount = ADDR.owner; + const rt = mockRuntime(s.fake, { + signer: ADDR.owner, + resolvePrc20: () => token, + }); + const recipient = getAddress('0x00000000000000000000000000000000000ab0b0'); + const usdc = { + symbol: 'USDC', + decimals: 6, + address: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + mechanism: 'approve' as const, + }; + const tx = await agenticSend(rt, s.ctx, { + to: { address: recipient, chain: CHAIN.ETHEREUM_SEPOLIA }, + funds: { amount: BigInt(100), token: usdc }, + }); + const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + calls: { data: Hex }[]; + }; + const { args } = decodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + data: outer.calls[0].data, + }); + const payload = (args[0] as { payload: Hex }).payload; + const [calls] = decodeAbiParameters( + [ + { + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + `0x${payload.slice(10)}` + ); + expect(calls).toHaveLength(1); + expect(getAddress(calls[0].to)).toBe(getAddress(usdc.address)); + expect( + decodeFunctionData({ abi: ERC20_EVM, data: calls[0].data }).args + ).toEqual([recipient, BigInt(100)]); + expect(tx.to).toBe(getAddress(usdc.address)); + expect(tx.data).toBe(calls[0].data); + expect(tx.agentic?.destinationCalls).toEqual( + calls.map((c) => ({ ...c, to: getAddress(c.to) })) + ); + }); + + it('a value-only outbound burns the native PRC20 for that value and forwards it to the recipient', async () => { + const s = universalSetup(); + s.ctx.door = 'owner'; + s.ctx.signerPushAccount = ADDR.owner; + const native = getAddress('0x0000000000000000000000000000000000000e7e'); + s.fake.balances.set(`balanceOf:${native.toLowerCase()}`, BigInt(1000)); + s.fake.balances.set(`allowance:${native.toLowerCase()}`, BigInt(1000)); + const rt = mockRuntime(s.fake, { + signer: ADDR.owner, + resolvePrc20: () => native, + }); + await agenticSend(rt, s.ctx, { + to: { address: ADDR.other, chain: CHAIN.ETHEREUM_SEPOLIA }, + value: BigInt(7), + }); + const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + calls: { data: Hex }[]; + }; + const req = decodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + data: outer.calls[0].data, + }).args[0] as { amount: bigint; payload: Hex }; + expect(req.amount).toBe(BigInt(7)); + expect(req.payload.toLowerCase()).toContain( + ADDR.other.slice(2).toLowerCase() + ); + }); + + it('an outbound with no destination call (parking in the CEA) is refused before signing', async () => { + const s = universalSetup(); + s.ctx.door = 'owner'; + s.ctx.signerPushAccount = ADDR.owner; + const cea = getAddress('0x000000000000000000000000000000000000cea1'); + const rt = mockRuntime(s.fake, { + signer: ADDR.owner, + resolvePrc20: () => token, + resolveCEA: async () => ({ cea, isDeployed: true }), + }); + await expect( + agenticSend(rt, s.ctx, { + to: { address: cea, chain: CHAIN.ETHEREUM_SEPOLIA }, + value: BigInt(1), + }) + ).rejects.toThrow(/no destination call/); + expect(rt.executeMock).not.toHaveBeenCalled(); + }); + + it('an agent value-only destination call is refused before signing (the policy admits only allow-listed calls)', async () => { + const s = universalSetup(); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + }); + await expect( + agenticSend(rt, s.ctx, { + to: { address: ADDR.other, chain: CHAIN.ETHEREUM_SEPOLIA }, + funds: { + amount: BigInt(1), + token: { + symbol: 'X', + decimals: 6, + address: token, + mechanism: 'native', + }, + }, + }) + ).rejects.toThrow(/no function selector/); + }); + + it('more than 10 destination calls are refused', async () => { + const s = universalSetup(); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + }); + await expect( + agenticSend(rt, s.ctx, { + to: { address: ADDR.target, chain: CHAIN.ETHEREUM_SEPOLIA }, + data: Array.from({ length: 11 }, () => ({ + to: ADDR.target, + value: BigInt(0), + data, + })), + }) + ).rejects.toThrow(/at most 10/); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/svm-backend.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-backend.spec.ts new file mode 100644 index 000000000..28e76662e --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/svm-backend.spec.ts @@ -0,0 +1,323 @@ +import { PublicKey } from '@solana/web3.js'; +import { bytesToHex, type Hex } from 'viem'; +import { CHAIN } from '../../constants/enums'; +import { createSvmMetadataProvider } from '../management/svm-metadata'; +import { resolveSvmContext } from '../management/svm-context'; +import { Snapshot } from '../reads/snapshot'; +import { + registerIdl, + clearRegistry, +} from '../../orchestrator/svm-idl/registry'; +import { prepareAgwSvmInstruction } from '../execution/svm-instruction'; +import { + deriveAgwSvmValueAccounts, + deriveAgwSvmAta, +} from '../codec/svm-accounts'; +import { ADDR, FakeChain } from './fake-chain'; +import type { SvmTermsWire } from '../codec/svm-terms'; +const key = (b: string) => `0x${b.repeat(32)}` as Hex; +const SPL = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const TOKEN2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; +const MINT = new PublicKey(Buffer.from(key('44').slice(2), 'hex')); +const PROGRAM = key('33'), + GATEWAY = key('22'); +const genesis = CHAIN.SOLANA_DEVNET.slice(7) + 'abcdefghijk'; +function rpc(owner = SPL, length = 82) { + const data = Buffer.alloc(length); + data[45] = 1; + if (length >= 166) data[165] = 1; + return { + getGenesisHash: jest.fn().mockResolvedValue(genesis), + getAccountInfo: jest.fn().mockResolvedValue({ + owner: new PublicKey(owner), + executable: false, + data, + lamports: 1, + rentEpoch: 0, + }), + }; +} +afterEach(clearRegistry); +describe('SVM metadata and resolved instruction backend', () => { + it.each(['dataless', 'non-anchor', 'wrong-account'])( + 'validates explicit wire accounts for %s instructions', + (kind) => { + const derived = deriveAgwSvmValueAccounts(ADDR.owner, GATEWAY, []); + const dataless = kind === 'dataless'; + const terms: SvmTermsWire = { + ...derived, + validUntil: 9999999999, + assets: [ + { token: ADDR.target, maxPerCall: BigInt(1), maxTotal: BigInt(2) }, + ], + maxGasPerCall: BigInt(10), + programs: [ + { + program: PROGRAM, + discriminator: '0x0100000000000000', + discriminatorLen: dataless ? 0 : 1, + dataless, + maxAccounts: 1, + }, + ], + pins: [ + { ruleIndex: 0, accountIndex: 0, expected: derived.expectedCEA }, + ], + dataPins: [], + }; + const build = () => + prepareAgwSvmInstruction( + { + wallet: ADDR.owner, + chain: CHAIN.SOLANA_DEVNET, + program: PROGRAM, + instructionData: dataless ? new Uint8Array() : new Uint8Array([1]), + accounts: [ + { + pubkey: + kind === 'wrong-account' ? key('66') : derived.expectedCEA, + isWritable: true, + }, + ], + }, + terms + ); + if (kind === 'wrong-account') + expect(build).toThrow('SvmAccountPinMismatch'); + else expect(build().recipient).toBe(PROGRAM); + } + ); + it.each([false, true])( + 'resolves output mint Token-2022 ownership, rejecting a wrong supplied program: %s', + async (wrong) => { + const fake = new FakeChain(); + fake.readContract = async ({ functionName }) => + functionName === 'gasTokenPRC20ByChainNamespace' + ? ADDR.other + : functionName === 'SOURCE_CHAIN_NAMESPACE' + ? CHAIN.SOLANA_DEVNET + : ''; + const mint = key('55'); + const metadata = { + gateway: async () => ({ + chainNamespace: CHAIN.SOLANA_DEVNET, + program: GATEWAY, + }), + mint: async () => ({ mint, tokenProgram: TOKEN2022 }), + }; + const promise = resolveSvmContext( + new Snapshot(fake, BigInt(1)), + ADDR.owner, + CHAIN.SOLANA_DEVNET, + [{ token: ADDR.other, maxPerCall: BigInt(1), maxTotal: BigInt(2) }], + metadata, + [{ mint, ...(wrong ? { tokenProgram: SPL } : {}) }] + ); + if (wrong) + await expect(promise).rejects.toThrow( + 'output mint/token-program mismatch' + ); + else { + const ctx = await promise; + expect(ctx.ceaAccounts).toEqual([ + ctx.expectedCEA, + deriveAgwSvmAta(ctx.expectedCEA, { mint, tokenProgram: TOKEN2022 }), + ]); + } + } + ); + it.each([ + [SPL, 82], + [TOKEN2022, 82], + [TOKEN2022, 166], + ])('reads initialized mint owner %s length %s', async (owner, length) => { + const r = rpc(owner, length); + const provider = createSvmMetadataProvider( + { + [CHAIN.SOLANA_DEVNET]: { + gatewayProgram: GATEWAY, + rpcUrls: ['http://fixture'], + }, + }, + () => r + ); + expect( + await provider.mint(CHAIN.SOLANA_DEVNET, bytesToHex(MINT.toBytes())) + ).toEqual({ mint: MINT.toBase58(), tokenProgram: owner }); + expect(await provider.gateway(CHAIN.SOLANA_DEVNET)).toEqual({ + chainNamespace: CHAIN.SOLANA_DEVNET, + program: GATEWAY, + }); + }); + it('rejects wrong cluster RPCs and falls back to the configured correct cluster', async () => { + const wrong = rpc(), + correct = rpc(); + wrong.getGenesisHash.mockResolvedValue('wrong-genesis'); + const provider = createSvmMetadataProvider( + { + [CHAIN.SOLANA_DEVNET]: { + gatewayProgram: GATEWAY, + rpcUrls: ['wrong', 'right'], + }, + }, + (url) => (url === 'wrong' ? wrong : correct) + ); + await provider.mint(CHAIN.SOLANA_DEVNET, bytesToHex(MINT.toBytes())); + expect(wrong.getAccountInfo).not.toHaveBeenCalled(); + expect(correct.getAccountInfo).toHaveBeenCalledTimes(1); + }); + it.each([ + 'missing', + 'executable', + 'token-account', + 'uninitialized', + 'wrong-owner', + ])('rejects %s as a mint', async (kind) => { + const r = rpc(), + info = await r.getAccountInfo(); + if (kind === 'missing') r.getAccountInfo.mockResolvedValue(null); + else { + if (kind === 'executable') info.executable = true; + if (kind === 'token-account') info.data = Buffer.alloc(165); + if (kind === 'uninitialized') info.data[45] = 0; + if (kind === 'wrong-owner') info.owner = PublicKey.default; + r.getAccountInfo.mockResolvedValue(info); + } + const p = createSvmMetadataProvider( + { + [CHAIN.SOLANA_DEVNET]: { + gatewayProgram: GATEWAY, + rpcUrls: ['fixture'], + }, + }, + () => r + ); + await expect( + p.mint(CHAIN.SOLANA_DEVNET, bytesToHex(MINT.toBytes())) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + }); + it('requires a configured gateway/RPC, rather than choosing a devnet default', async () => { + await expect( + createSvmMetadataProvider({}).gateway(CHAIN.SOLANA_DEVNET) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + }); + it('pins all PRC20 reads and rejects a source-chain mismatch', async () => { + const fake = new FakeChain(); + fake.readContract = jest.fn(async ({ functionName }) => + functionName === 'gasTokenPRC20ByChainNamespace' + ? ADDR.other + : CHAIN.ETHEREUM_SEPOLIA + ); + const snap = new Snapshot(fake, BigInt(123)); + const metadata = { + gateway: async () => ({ + chainNamespace: CHAIN.SOLANA_DEVNET, + program: GATEWAY, + }), + mint: jest.fn(), + }; + await expect( + resolveSvmContext( + snap, + ADDR.owner, + CHAIN.SOLANA_DEVNET, + [{ token: ADDR.target, maxPerCall: BigInt(1), maxTotal: BigInt(2) }], + metadata + ) + ).rejects.toMatchObject({ code: 'ASSET_CHAIN_MISMATCH' }); + expect( + (fake.readContract as jest.Mock).mock.calls.every( + ([arg]) => arg.blockNumber === BigInt(123) + ) + ).toBe(true); + }); + it('rejects registry and mint substitution', async () => { + const fake = new FakeChain(); + fake.readContract = async ({ functionName }) => + functionName === 'gasTokenPRC20ByChainNamespace' + ? ADDR.other + : functionName === 'SOURCE_CHAIN_NAMESPACE' + ? CHAIN.SOLANA_DEVNET + : MINT.toBase58(); + const snap = new Snapshot(fake, BigInt(1)), + caps = [ + { token: ADDR.target, maxPerCall: BigInt(1), maxTotal: BigInt(2) }, + ]; + await expect( + resolveSvmContext(snap, ADDR.owner, CHAIN.SOLANA_DEVNET, caps, { + gateway: async () => ({ + chainNamespace: CHAIN.SOLANA_TESTNET, + program: GATEWAY, + }), + mint: jest.fn(), + }) + ).rejects.toThrow('cluster mismatch'); + await expect( + resolveSvmContext(snap, ADDR.owner, CHAIN.SOLANA_DEVNET, caps, { + gateway: async () => ({ + chainNamespace: CHAIN.SOLANA_DEVNET, + program: GATEWAY, + }), + mint: async () => ({ mint: key('55'), tokenProgram: SPL }), + }) + ).rejects.toThrow('different mint'); + }); + it('derives the wallet authority for IDL accounts, not the signer/default gateway authority', () => { + const derived = deriveAgwSvmValueAccounts(ADDR.owner, GATEWAY, []); + const program = new PublicKey( + Buffer.from(PROGRAM.slice(2), 'hex') + ).toBase58(); + registerIdl({ + address: program, + metadata: { name: 'fixture', version: '1', spec: '0.1.0' }, + instructions: [ + { + name: 'go', + discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + accounts: [{ name: 'cea_authority', writable: true }], + args: [], + }, + ], + }); + const t: SvmTermsWire = { + ...derived, + validUntil: 9999999999, + assets: [ + { token: ADDR.target, maxPerCall: BigInt(1), maxTotal: BigInt(2) }, + ], + maxGasPerCall: BigInt(10), + programs: [ + { + program: PROGRAM, + discriminator: '0x0102030405060708', + discriminatorLen: 8, + dataless: false, + maxAccounts: 1, + }, + ], + pins: [{ ruleIndex: 0, accountIndex: 0, expected: derived.expectedCEA }], + dataPins: [], + }; + const out = prepareAgwSvmInstruction( + { + wallet: ADDR.owner, + chain: CHAIN.SOLANA_DEVNET, + program, + instructionData: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + }, + t + ); + expect(out.accounts[0].pubkey).toBe(derived.expectedCEA); + expect(() => + prepareAgwSvmInstruction( + { + wallet: ADDR.owner, + chain: CHAIN.SOLANA_DEVNET, + program, + instructionData: new Uint8Array([9, 2, 3, 4, 5, 6, 7, 8]), + }, + t + ) + ).toThrow(/discriminator/); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/svm-idl.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-idl.spec.ts new file mode 100644 index 000000000..e752336d1 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/svm-idl.spec.ts @@ -0,0 +1,177 @@ +import type { Idl } from '@coral-xyz/anchor'; +import { PublicKey } from '@solana/web3.js'; +import { encodeAnchorIxData } from '../../orchestrator/svm-idl/ix-encoder'; +import type { SolanaRule } from '../agentic.types'; +import { compileSvmIdlRule, type SvmResolvedContext } from '../codec/svm-idl'; +import { SvmDataPinMode } from '../codec/svm-terms'; +import { + encodeAgwSvmPayload, + validateAgwSvmPayload, +} from '../execution/svm-payload'; +import { ADDR } from './fake-chain'; +import type { Hex } from 'viem'; +const key = (c: string) => `0x${c.repeat(64)}` as Hex; +const program = key('3'), + cea = key('4'), + mint = key('5'), + ata = key('6'); +const idl: Idl = { + address: new PublicKey(Buffer.from(program.slice(2), 'hex')).toBase58(), + metadata: { name: 'test', version: '1', spec: '0.1.0' }, + instructions: [ + { + name: 'deposit', + discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + accounts: [ + { name: 'cea', writable: true }, + { name: 'token', writable: true }, + ], + args: [ + { name: 'flag', type: 'u8' }, + { name: 'amount', type: 'u64' }, + { name: 'minimum', type: 'u64' }, + ], + }, + ], +}; +const ctx: SvmResolvedContext = { + expectedCEA: cea, + gatewayProgram: key('2'), + ceaAccounts: [cea, ata], + assets: [ + { token: ADDR.target, maxPerCall: BigInt(10), maxTotal: BigInt(100) }, + ], + tokenAccounts: new Map([[mint, ata]]), +}; +const input = (): SolanaRule => ({ + agent: ADDR.agent, + chainNamespace: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + validUntil: 100, + assets: [], + maxGasPerCall: BigInt(10), + allowedInstructions: [ + { + program, + instruction: { idl, name: 'deposit' }, + accounts: [ + { name: 'cea', expected: { kind: 'walletCEA' } }, + { name: 'token', expected: { kind: 'walletATA', token: mint } }, + ], + fields: [ + { name: 'flag', equals: BigInt(1) }, + { name: 'amount', max: BigInt(10) }, + { + numerator: 'amount', + denominator: 'minimum', + minRatio: { num: BigInt(2), den: BigInt(1) }, + }, + ], + }, + ], +}); +describe('named SVM IDL rule compilation', () => { + it('enforces named constraints against independently Borsh-encoded data', () => { + const terms = compileSvmIdlRule(input(), ctx, 0); + expect(terms.pins.map((p) => p.expected)).toEqual([cea, ata]); + expect(terms.dataPins.map((p) => [p.offset, p.len, p.mode])).toEqual([ + [8, 1, SvmDataPinMode.EQ], + [9, 8, SvmDataPinMode.LTE_LE], + [9, 8, SvmDataPinMode.RATIO_GTE_LE], + ]); + const payload = (amount: bigint, minimum: bigint) => + encodeAgwSvmPayload( + program, + [ + { pubkey: cea, isWritable: true }, + { pubkey: ata, isWritable: true }, + ], + encodeAnchorIxData(idl, 'deposit', [1, amount, minimum]) + ); + expect( + validateAgwSvmPayload(terms, program, payload(BigInt(10), BigInt(5))) + ).toBe(0); + expect(() => + validateAgwSvmPayload(terms, program, payload(BigInt(11), BigInt(5))) + ).toThrow('SvmDataCeilingExceeded'); + expect(() => + validateAgwSvmPayload(terms, program, payload(BigInt(9), BigInt(5))) + ).toThrow('SvmDataRatioNotMet'); + }); + it.each([ + [ + 'unknown account', + (r: SolanaRule) => { + r.allowedInstructions[0].accounts[0].name = 'wrong'; + }, + ], + [ + 'unlisted ATA', + (r: SolanaRule) => { + r.allowedInstructions[0].accounts[1].expected = { + kind: 'walletATA', + token: key('7'), + }; + }, + ], + [ + 'unknown field', + (r: SolanaRule) => { + r.allowedInstructions[0].fields = [{ name: 'wrong', max: BigInt(2) }]; + }, + ], + [ + 'overflow', + (r: SolanaRule) => { + r.allowedInstructions[0].fields = [{ name: 'flag', max: BigInt(256) }]; + }, + ], + [ + 'duplicate instruction', + (r: SolanaRule) => { + r.allowedInstructions.push(r.allowedInstructions[0]); + }, + ], + [ + 'mismatched program', + (r: SolanaRule) => { + r.allowedInstructions[0].program = key('8'); + }, + ], + [ + 'conflicting comparison', + (r: SolanaRule) => { + r.allowedInstructions[0].fields = [ + { name: 'flag', min: BigInt(0), max: BigInt(1) } as never, + ]; + }, + ], + ])('rejects %s', (_name, mutate) => { + const r = input(); + mutate(r); + expect(() => compileSvmIdlRule(r, ctx, 0)).toThrow(); + }); + it.each(['bytes', 'string', { vec: 'u8' }, { defined: { name: 'Unknown' } }])( + 'refuses unsupported layouts %j', + (type) => { + const r = input(); + r.allowedInstructions[0].instruction.idl = { + ...idl, + instructions: [ + { + ...idl.instructions[0], + args: [ + { name: 'prefix', type: type as never }, + ...idl.instructions[0].args, + ], + }, + ], + }; + expect(() => compileSvmIdlRule(r, ctx, 0)).toThrow('layout'); + } + ); + it('does not mutate the caller IDL', () => { + const before = JSON.stringify(idl); + compileSvmIdlRule(input(), ctx, 0); + expect(JSON.stringify(idl)).toBe(before); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts new file mode 100644 index 000000000..0d7317fce --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts @@ -0,0 +1,502 @@ +import { PublicKey } from '@solana/web3.js'; +import { bytesToHex, hexToBytes, numberToHex, type Hex } from 'viem'; +import { + decodeSvmTerms, + encodeSvmTerms, + validateSvmTerms, + SvmDataPinMode, + type SvmTermsWire, +} from '../codec/svm-terms'; +import { + deriveAgwSvmAta, + deriveAgwSvmCea, + deriveAgwSvmValueAccounts, + svmKey, + resolveAgwSvmRuleContext, +} from '../codec/svm-accounts'; +import { + encodeAgwSvmPayload, + parseAgwSvmPayload, + validateAgwSvmPayload, +} from '../execution/svm-payload'; +import { composeAgwSvmAction } from '../execution/svm-outbound'; +import { decodeFunctionData } from 'viem'; +import { UNIVERSAL_GATEWAY_PC } from '../../constants/abi'; +import { encodeSvmExecutePayload } from '../../orchestrator/payload-builders'; +import { ADDR } from './fake-chain'; + +const key = (byte: string) => `0x${byte.repeat(32)}` as Hex; +const CEA = key('11'), + GATEWAY = key('22'), + PROGRAM = key('33'), + ATA = key('44'); +const NOW = 1_700_000_000; +const base = (): SvmTermsWire => ({ + validUntil: NOW + 60, + expectedCEA: CEA, + gatewayProgram: GATEWAY, + assets: [{ token: ADDR.target, maxPerCall: BigInt(5), maxTotal: BigInt(10) }], + maxGasPerCall: BigInt(20), + ceaAccounts: [CEA, ATA], + programs: [ + { + program: PROGRAM, + discriminator: '0x0100000000000000', + discriminatorLen: 1, + dataless: false, + maxAccounts: 2, + }, + ], + pins: [ + { ruleIndex: 0, accountIndex: 0, expected: CEA }, + { ruleIndex: 0, accountIndex: 1, expected: ATA }, + ], + dataPins: [], +}); +const accounts = [ + { pubkey: CEA, isWritable: true }, + { pubkey: ATA, isWritable: true }, +]; + +describe('v5 internal SVM wire codec', () => { + it('round-trips ordered per-token caps and all four data-pin modes', () => { + const t = base(); + t.dataPins = [ + { + ruleIndex: 0, + fromEnd: false, + offset: 0, + offsetB: 0, + len: 1, + mode: 0, + expected: key('00'), + num: BigInt(0), + den: BigInt(0), + }, + { + ruleIndex: 0, + fromEnd: true, + offset: 8, + offsetB: 0, + len: 8, + mode: 1, + expected: numberToHex(5, { size: 32 }), + num: BigInt(0), + den: BigInt(0), + }, + { + ruleIndex: 0, + fromEnd: false, + offset: 1, + offsetB: 0, + len: 2, + mode: 2, + expected: numberToHex(65535, { size: 32 }), + num: BigInt(0), + den: BigInt(0), + }, + { + ruleIndex: 0, + fromEnd: true, + offset: 16, + offsetB: 8, + len: 8, + mode: 3, + expected: key('00'), + num: BigInt(9), + den: BigInt(10), + }, + ]; + t.assets = [ + ...t.assets, + { token: ADDR.other, maxPerCall: BigInt(0), maxTotal: BigInt(0) }, + ]; + expect(decodeSvmTerms(encodeSvmTerms(t, NOW))).toEqual(t); + }); + it.each([ + [ + 'expired', + (t: SvmTermsWire) => { + t.validUntil = NOW; + }, + ], + [ + 'zero CEA', + (t: SvmTermsWire) => { + t.expectedCEA = key('00'); + }, + ], + [ + 'empty assets', + (t: SvmTermsWire) => { + t.assets = []; + }, + ], + [ + 'duplicate tokens', + (t: SvmTermsWire) => { + t.assets = [t.assets[0], t.assets[0]]; + }, + ], + [ + 'system program', + (t: SvmTermsWire) => { + t.programs = [{ ...t.programs[0], program: key('00') }]; + }, + ], + [ + 'ambiguous prefixes', + (t: SvmTermsWire) => { + t.programs = [ + ...t.programs, + { + ...t.programs[0], + discriminator: '0x0102000000000000', + discriminatorLen: 2, + }, + ]; + }, + ], + [ + 'missing pin', + (t: SvmTermsWire) => { + t.pins = []; + }, + ], + [ + 'duplicate pin', + (t: SvmTermsWire) => { + t.pins = [t.pins[0], t.pins[0]]; + }, + ], + [ + 'CEA absent', + (t: SvmTermsWire) => { + t.ceaAccounts = [ATA]; + }, + ], + [ + 'fixed count outside bound', + (t: SvmTermsWire) => { + t.programs = [{ ...t.programs[0], maxAccounts: 65 }]; + }, + ], + ])('rejects %s before encoding', (_name, change) => { + const t = base(); + change(t); + expect(() => encodeSvmTerms(t, NOW)).toThrow( + expect.objectContaining({ code: 'INVALID_RULE' }) + ); + }); + it('refuses misleading integer/raw alignment and zero ratio denominators', () => { + const t = base(); + const p = { + ruleIndex: 0, + fromEnd: false, + offset: 1, + offsetB: 3, + len: 2, + mode: SvmDataPinMode.EQ, + expected: numberToHex(5, { size: 32 }), + num: BigInt(0), + den: BigInt(0), + }; + t.dataPins = [p]; + expect(() => validateSvmTerms(t, NOW)).toThrow(/left-aligned/); + t.dataPins = [ + { + ...p, + mode: SvmDataPinMode.GTE_LE, + expected: numberToHex(65536, { size: 32 }), + }, + ]; + expect(() => validateSvmTerms(t, NOW)).toThrow(/width/); + t.dataPins = [ + { + ...p, + mode: SvmDataPinMode.RATIO_GTE_LE, + num: BigInt(1), + den: BigInt(0), + }, + ]; + expect(() => validateSvmTerms(t, NOW)).toThrow(/ratio/); + }); +}); + +describe('wallet-scoped SVM value accounts', () => { + it('derives the exact PDA from wallet20 and the selected gateway', () => { + const result = deriveAgwSvmCea(ADDR.owner, GATEWAY); + const [expected, bump] = PublicKey.findProgramAddressSync( + [Buffer.from('push_identity'), Buffer.from(hexToBytes(ADDR.owner))], + new PublicKey(hexToBytes(GATEWAY)) + ); + expect(result).toEqual({ address: bytesToHex(expected.toBytes()), bump }); + expect(deriveAgwSvmCea(ADDR.agent, GATEWAY).address).not.toBe( + result.address + ); + expect(deriveAgwSvmCea(ADDR.owner, PROGRAM).address).not.toBe( + result.address + ); + expect(PublicKey.isOnCurve(hexToBytes(result.address))).toBe(false); + }); + it('resolved token caps cannot omit their corresponding protected ATAs', () => { + const cap = { + token: ADDR.target, + maxPerCall: BigInt(1), + maxTotal: BigInt(2), + }; + const result = resolveAgwSvmRuleContext( + ADDR.owner, + GATEWAY, + [ + { kind: 'native', cap }, + { kind: 'spl', cap: { ...cap, token: ADDR.other }, mint: PROGRAM }, + ], + [{ mint: ATA }] + ); + expect(result.ceaAccounts).toHaveLength(3); + expect(result.assets.map((a) => a.token)).toEqual([ + ADDR.target, + ADDR.other, + ]); + expect(() => deriveAgwSvmCea(ADDR.owner, key('00'))).toThrow( + /gateway program/ + ); + }); + it('includes input/output ATAs, deduplicates, and separates Token-2022 accounts', () => { + const t = { mint: PROGRAM }, + output = { mint: ATA }; + const derived = deriveAgwSvmValueAccounts( + ADDR.owner, + GATEWAY, + [t, t], + [output] + ); + expect(derived.ceaAccounts).toEqual([ + derived.expectedCEA, + deriveAgwSvmAta(derived.expectedCEA, t), + deriveAgwSvmAta(derived.expectedCEA, output), + ]); + const legacy = deriveAgwSvmAta(derived.expectedCEA, t), + token2022 = deriveAgwSvmAta(derived.expectedCEA, { + ...t, + tokenProgram: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + }); + expect(token2022).not.toBe(legacy); + expect(svmKey(new PublicKey(hexToBytes(PROGRAM)).toBase58())).toBe(PROGRAM); + expect(() => + deriveAgwSvmAta(derived.expectedCEA, { mint: key('00') }) + ).toThrow(/native SOL/); + }); +}); + +describe('S12–S18 payload checks', () => { + it('matches the existing node encoder and parses every byte including writable flags', () => { + const payload = encodeAgwSvmPayload( + PROGRAM, + accounts, + new Uint8Array([1, 2, 3]) + ); + expect(payload).toBe( + encodeSvmExecutePayload({ + targetProgram: PROGRAM, + accounts, + ixData: new Uint8Array([1, 2, 3]), + instructionId: 2, + }) + ); + expect(parseAgwSvmPayload(payload)).toMatchObject({ + program: PROGRAM, + instructionData: new Uint8Array([1, 2, 3]), + accounts: [ + { pubkey: CEA, writableFlag: 1 }, + { pubkey: ATA, writableFlag: 1 }, + ], + }); + expect(validateAgwSvmPayload(base(), PROGRAM, payload)).toBe(0); + const raw = hexToBytes(payload); + raw[36] = 7; + expect(parseAgwSvmPayload(bytesToHex(raw)).accounts[0].writableFlag).toBe( + 7 + ); + expect(validateAgwSvmPayload(base(), PROGRAM, bytesToHex(raw))).toBe(0); // URP deliberately ignores flags. + }); + it.each([ + ['trailing data', (p: Hex) => `${p}00` as Hex, 'MalformedSvmPayload'], + ['truncated', (p: Hex) => p.slice(0, -2) as Hex, 'MalformedSvmPayload'], + [ + 'oversized count', + (p: Hex) => `0x00000041${p.slice(10)}` as Hex, + 'SvmAccountsOutOfRange', + ], + ])( + 'rejects %s with the corresponding contract error', + (_name, mutate, error) => { + const p = encodeAgwSvmPayload(PROGRAM, accounts, new Uint8Array([1])); + expect(() => parseAgwSvmPayload(mutate(p))).toThrow( + expect.objectContaining({ details: { contractError: error } }) + ); + } + ); + it('pins prevent key substitution and protected-account aliasing', () => { + const t = base(); + expect(() => + validateAgwSvmPayload( + t, + PROGRAM, + encodeAgwSvmPayload( + PROGRAM, + [{ ...accounts[0], pubkey: GATEWAY }, accounts[1]], + new Uint8Array([1]) + ) + ) + ).toThrow(/SvmAccountPinMismatch/); + t.programs = [{ ...t.programs[0], maxAccounts: 0 }]; + expect(() => + validateAgwSvmPayload( + t, + PROGRAM, + encodeAgwSvmPayload( + PROGRAM, + [...accounts, accounts[0]], + new Uint8Array([1]) + ) + ) + ).toThrow(/CeaAccountAtUnpinnedIndex/); + expect(() => + validateAgwSvmPayload( + t, + GATEWAY, + encodeAgwSvmPayload(PROGRAM, accounts, new Uint8Array([1])) + ) + ).toThrow(/RecipientTargetMismatch/); + }); + it('enforces little-endian floors/ceilings, from-end offsets, ratios and exact byte pins', () => { + const t = base(); + t.dataPins = [ + { + ruleIndex: 0, + fromEnd: true, + offset: 4, + offsetB: 2, + len: 2, + mode: 3, + expected: key('00'), + num: BigInt(9), + den: BigInt(10), + }, + ]; + const make = (out: number, input: number) => + encodeAgwSvmPayload( + PROGRAM, + accounts, + new Uint8Array([1, out & 255, out >> 8, input & 255, input >> 8]) + ); + expect(validateAgwSvmPayload(t, PROGRAM, make(90, 100))).toBe(0); + expect(() => validateAgwSvmPayload(t, PROGRAM, make(89, 100))).toThrow( + /SvmDataRatioNotMet/ + ); + t.dataPins = [ + { + ...t.dataPins[0], + fromEnd: false, + offset: 1, + mode: 1, + expected: numberToHex(90, { size: 32 }), + }, + ]; + expect(() => validateAgwSvmPayload(t, PROGRAM, make(89, 100))).toThrow( + /SvmDataFloorNotMet/ + ); + t.dataPins = [{ ...t.dataPins[0], mode: 2 }]; + expect(() => validateAgwSvmPayload(t, PROGRAM, make(91, 100))).toThrow( + /SvmDataCeilingExceeded/ + ); + t.dataPins = [ + { ...t.dataPins[0], mode: 0, expected: `0x5a00${'00'.repeat(30)}` }, + ]; + expect(validateAgwSvmPayload(t, PROGRAM, make(90, 100))).toBe(0); + expect(() => + validateAgwSvmPayload( + t, + PROGRAM, + encodeAgwSvmPayload(PROGRAM, accounts, new Uint8Array([1])) + ) + ).toThrow(/SvmDataTooShortForPin/); + }); +}); + +describe('internal SVM outbound action', () => { + const config = () => ({ + ...base(), + initialized: true, + maxGasPerCall: BigInt(100), + assets: [ + { + token: ADDR.target, + maxPerCall: BigInt(5), + maxTotal: BigInt(10), + spent: BigInt(2), + }, + ], + }); + const input = () => ({ + wallet: ADDR.owner, + gateway: ADDR.gateway, + token: ADDR.target, + amount: BigInt(3), + recipient: PROGRAM, + payload: encodeAgwSvmPayload(PROGRAM, accounts, new Uint8Array([1])), + gasLimit: BigInt(200000), + protocolFee: BigInt(2), + maxPCForGas: BigInt(3), + }); + it('uses the program recipient, wallet refund and quoted PC while preserving the exact payload', () => { + const i = input(), + result = composeAgwSvmAction(i, config(), NOW); + const request = decodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + data: result.data, + }).args![0]; + expect(result.target).toBe(ADDR.gateway); + expect(result.value).toBe(BigInt(5)); + expect(request).toMatchObject({ + recipient: PROGRAM, + revertRecipient: ADDR.owner, + amount: BigInt(3), + payload: i.payload, + gasPrice: BigInt(0), + }); + }); + it('rejects caps, zero gas and unknown assets before signing', () => { + expect(() => + composeAgwSvmAction({ ...input(), amount: BigInt(6) }, config(), NOW) + ).toThrow(/AmountExceedsCap/); + expect(() => + composeAgwSvmAction({ ...input(), token: ADDR.other }, config(), NOW) + ).toThrow(/AssetNotAllowed/); + expect(() => + composeAgwSvmAction({ ...input(), maxPCForGas: BigInt(0) }, config(), NOW) + ).toThrow(/UncappedGasSwapRejected/); + expect(() => + composeAgwSvmAction( + { ...input(), maxPCForGas: BigInt(100) }, + config(), + NOW + ) + ).toThrow(/PCValueExceedsCap/); + const c = config(); + c.assets[0].spent = BigInt(9); + expect(() => composeAgwSvmAction(input(), c, NOW)).toThrow( + /TotalSpendCapExceeded/ + ); + c.assets[0].maxPerCall = BigInt(2) ** BigInt(256) - BigInt(1); + expect(() => + composeAgwSvmAction( + { ...input(), amount: BigInt(2) ** BigInt(64) }, + c, + NOW + ) + ).toThrow(/AmountExceedsU64/); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts new file mode 100644 index 000000000..766135cdf --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts @@ -0,0 +1,251 @@ +import { + decodeFunctionResult, + encodeAbiParameters, + type Abi, + type Hex, +} from 'viem'; +import { preparePublicSvmRule } from '../management/svm-public'; +import { resolveSvmAssets } from '../management/svm-context'; +import { readSvmSourceToken } from '../contracts/prc20-metadata'; +import { PublicKey } from '@solana/web3.js'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../constants/tokens'; +import { getPRC20Address } from '../../universal/prc20-address'; +import { CHAIN } from '../../constants/enums'; +import { Snapshot } from '../reads/snapshot'; +import { quoteSvmRequest } from '../execution/svm-quote'; +import { ADDR, FakeChain } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; +const mint = '0x' + '33'.repeat(32); +function fixture(chain = CHAIN.SOLANA_DEVNET) { + const fake = new FakeChain(); + fake.readContract = async ({ functionName }) => + functionName === 'gasTokenPRC20ByChainNamespace' + ? ADDR.other + : functionName === 'SOURCE_CHAIN_NAMESPACE' + ? chain + : mint; + const quoteOutbound = jest.fn().mockResolvedValue({ + protocolFee: BigInt(1), + nativeValueForGas: BigInt(10), + gasLimitUsed: BigInt(100), + }); + return { + snap: new Snapshot(fake, BigInt(1)), + rt: mockRuntime(fake, { quoteOutbound }), + quoteOutbound, + }; +} +describe('AGW Solana finalization quote context', () => { + it('uses the wallet identity and actual source mint for SPL rent budgeting', async () => { + const f = fixture(); + await quoteSvmRequest( + f.rt, + f.snap, + ADDR.owner, + ADDR.target, + CHAIN.SOLANA_DEVNET, + BigInt(0), + BigInt(5) + ); + expect(f.quoteOutbound).toHaveBeenCalledWith( + ADDR.target, + BigInt(0), + CHAIN.SOLANA_DEVNET, + { + wallet: ADDR.owner, + splMintBase58: new PublicKey( + Buffer.from(mint.slice(2), 'hex') + ).toBase58(), + burnAmount: BigInt(5), + } + ); + }); + it('native transfers carry no SPL mint or ATA requirement', async () => { + const f = fixture(); + const q = await quoteSvmRequest( + f.rt, + f.snap, + ADDR.owner, + ADDR.other, + CHAIN.SOLANA_DEVNET, + BigInt(0), + BigInt(5) + ); + expect(q.isNative).toBe(true); + expect(f.quoteOutbound.mock.calls[0][3].splMintBase58).toBeUndefined(); + }); + it('zero-burn instruction quotes do not add a transfer ATA requirement', async () => { + const f = fixture(); + await quoteSvmRequest( + f.rt, + f.snap, + ADDR.owner, + ADDR.target, + CHAIN.SOLANA_DEVNET, + BigInt(0), + BigInt(0) + ); + expect(f.quoteOutbound.mock.calls[0][3].splMintBase58).toBeUndefined(); + }); + it('an asset from another chain fails route validation without a quote', async () => { + const f = fixture(CHAIN.ETHEREUM_SEPOLIA); + await expect( + quoteSvmRequest( + f.rt, + f.snap, + ADDR.owner, + ADDR.target, + CHAIN.SOLANA_DEVNET, + BigInt(0), + BigInt(5) + ) + ).rejects.toMatchObject({ code: 'ASSET_CHAIN_MISMATCH' }); + expect(f.quoteOutbound).not.toHaveBeenCalled(); + }); + it('decodes the real precompile word when a Solidity string ABI cannot decode it', async () => { + const f = fixture(); + const read = f.snap.reader.readContract; + const outputTypes: string[] = []; + f.snap.reader.readContract = async (args) => { + if (args.functionName !== 'SOURCE_TOKEN_ADDRESS') return read(args); + const fn = (args.abi as Abi).find( + (entry) => entry.type === 'function' && entry.name === args.functionName + ); + if (fn?.type !== 'function') throw new Error('missing metadata ABI'); + outputTypes.push(fn.outputs[0].type); + return decodeFunctionResult({ + abi: args.abi as Abi, + functionName: args.functionName, + data: encodeAbiParameters([{ type: 'bytes32' }], [mint as Hex]), + }); + }; + await quoteSvmRequest( + f.rt, + f.snap, + ADDR.owner, + ADDR.target, + CHAIN.SOLANA_DEVNET, + BigInt(0), + BigInt(5) + ); + expect(outputTypes).toEqual(['string', 'bytes32']); + const expected = new PublicKey( + Buffer.from(mint.slice(2), 'hex') + ).toBase58(); + expect(f.quoteOutbound.mock.calls[0][3].splMintBase58).toBe(expected); + const metadata = { + gateway: jest.fn(), + mint: jest.fn().mockResolvedValue({ + mint: expected, + tokenProgram: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }), + }; + const assets = await resolveSvmAssets( + f.snap, + CHAIN.SOLANA_DEVNET, + [{ token: ADDR.target, maxPerCall: BigInt(5), maxTotal: BigInt(5) }], + metadata + ); + expect(assets[0]).toMatchObject({ kind: 'spl', mint: expected }); + expect(metadata.mint).toHaveBeenCalledWith(CHAIN.SOLANA_DEVNET, mint); + }); + + it('does not fabricate a mint when both metadata representations fail', async () => { + const f = fixture(); + f.snap.reader.readContract = async () => { + throw new Error('RPC unavailable'); + }; + await expect( + readSvmSourceToken(f.snap, ADDR.target, CHAIN.SOLANA_DEVNET) + ).rejects.toMatchObject({ + code: 'RULE_READ_FAILED', + cause: { message: 'RPC unavailable' }, + }); + expect(f.quoteOutbound).not.toHaveBeenCalled(); + }); + it('uses the current registered SPL mint when Donut metadata returns a zero word', async () => { + const f = fixture(); + const asset = MOVEABLE_TOKEN_CONSTANTS.SOLANA_DEVNET.USDT; + const token = getPRC20Address({ + chain: CHAIN.SOLANA_DEVNET, + address: asset.address, + }).address; + const read = f.snap.reader.readContract; + f.snap.reader.readContract = async (args) => { + if (args.functionName !== 'SOURCE_TOKEN_ADDRESS') return read(args); + return decodeFunctionResult({ + abi: args.abi as Abi, + functionName: args.functionName, + data: encodeAbiParameters( + [{ type: 'bytes32' }], + [`0x${'00'.repeat(32)}`] + ), + }); + }; + await quoteSvmRequest( + f.rt, + f.snap, + ADDR.owner, + token, + CHAIN.SOLANA_DEVNET, + BigInt(0), + BigInt(1000) + ); + expect(f.quoteOutbound.mock.calls[0][3].splMintBase58).toBe(asset.address); + await expect( + readSvmSourceToken(f.snap, ADDR.target, CHAIN.SOLANA_DEVNET) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + }); + it('canonicalizes a decoded SPL mint before resolving its PRC20 mapping', async () => { + const f = fixture(); + const expected = new PublicKey( + Buffer.from(mint.slice(2), 'hex') + ).toBase58(); + const program = `0x${'44'.repeat(32)}` as Hex; + const rt = { + ...f.rt, + resolvePrc20: jest.fn().mockReturnValue(ADDR.target), + svmMetadata: { + gateway: async () => ({ + chainNamespace: CHAIN.SOLANA_DEVNET, + program: `0x${'22'.repeat(32)}`, + }), + mint: async () => ({ + mint: expected, + tokenProgram: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }), + }, + }; + await preparePublicSvmRule(rt, f.snap, ADDR.owner, { + agent: ADDR.agent, + chainNamespace: CHAIN.SOLANA_DEVNET, + assets: [{ token: mint, maxPerCall: BigInt(5), maxTotal: BigInt(5) }], + validUntil: f.rt.nowSeconds() + 3600, + maxGasPerCall: BigInt(10), + allowedInstructions: [ + { + program, + accounts: [{ name: 'authority', expected: { kind: 'walletCEA' } }], + instruction: { + name: 'ping', + idl: { + address: new PublicKey( + Buffer.from(program.slice(2), 'hex') + ).toBase58(), + metadata: { name: 'fixture', version: '1', spec: '0.1.0' }, + instructions: [ + { + name: 'ping', + discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + accounts: [{ name: 'authority', signer: true }], + args: [], + }, + ], + }, + }, + }, + ], + }); + expect(rt.resolvePrc20).toHaveBeenCalledWith(expected, CHAIN.SOLANA_DEVNET); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/svm-wire-fixture.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-wire-fixture.spec.ts new file mode 100644 index 000000000..cb2a2d6be --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/svm-wire-fixture.spec.ts @@ -0,0 +1,59 @@ +import { Connection, PublicKey, type AccountInfo } from '@solana/web3.js'; +import { decodeWireCounter, inspectSvmWirePrograms, SVM_WIRE_COUNTER, SVM_WIRE_GATEWAY, SVM_WIRE_PROGRAM } from '../../../../__e2e__/shared/agw-svm-preflight'; + +const gateway = new PublicKey(SVM_WIRE_GATEWAY), program = new PublicKey(SVM_WIRE_PROGRAM); +const loader = new PublicKey('BPFLoaderUpgradeab1e11111111111111111111111'); +const config = PublicKey.findProgramAddressSync([Buffer.from('config')], gateway)[0]; +function fixture() { + const rows = new Map>(); + [gateway, program].forEach((key, i) => { + const programData = new PublicKey(Buffer.alloc(32, i + 1)); + const data = Buffer.alloc(36); data.writeUInt32LE(2); programData.toBuffer().copy(data, 4); + rows.set(key.toBase58(), { data, owner: loader, executable: true, lamports: 1, rentEpoch: 0 }); + const implementation = Buffer.alloc(49); implementation.writeUInt32LE(3); implementation.writeBigUInt64LE(BigInt(42), 4); + rows.set(programData.toBase58(), { data: implementation, owner: loader, executable: false, lamports: 1, rentEpoch: 0 }); + }); + const counter = Buffer.alloc(48); Buffer.from([255, 176, 4, 245, 188, 253, 124, 25]).copy(counter); counter.writeBigUInt64LE(BigInt(7), 8); + rows.set(SVM_WIRE_COUNTER.toBase58(), { data: counter, owner: program, executable: false, lamports: 1, rentEpoch: 0 }); + const data = Buffer.alloc(279); Buffer.from([155, 12, 170, 224, 30, 250, 204, 130]).copy(data); + rows.set(config.toBase58(), { data, owner: gateway, executable: false, lamports: 1, rentEpoch: 0 }); + const rpc = { + getGenesisHash: jest.fn().mockResolvedValue('EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG'), + getMultipleAccountsInfoAndContext: jest.fn(async (keys: PublicKey[]) => ({ context: { slot: 99 }, value: keys.map((k) => rows.get(k.toBase58()) ?? null) })), + }; + return { rows, rpc, connection: rpc as unknown as Connection }; +} + +describe('unfunded AGW SVM wire fixture gate', () => { + it('records program fingerprints, slot and the actual IDL-decoded counter/config', async () => { + const { connection } = fixture(); + const result = await inspectSvmWirePrograms(connection); + expect(result.counter.value).toBe('7'); expect(result.gatewayConfig.paused).toBe(false); + expect(result.programs).toHaveLength(2); expect(result.programs[0].lastUpgradeSlot).toBe('42'); + expect(result.programs[0].dataSha256).toMatch(/^[0-9a-f]{64}$/); expect(result.slot).toBe(99); + }); + it('rejects an RPC for another cluster before reading account state', async () => { + const { rpc, connection } = fixture(); rpc.getGenesisHash.mockResolvedValue('other-cluster'); + await expect(inspectSvmWirePrograms(connection)).rejects.toThrow('selected devnet'); + expect(rpc.getMultipleAccountsInfoAndContext).not.toHaveBeenCalled(); + }); + it('rejects a non-executable program before reading ProgramData', async () => { + const { rows, rpc, connection } = fixture(); rows.get(gateway.toBase58())!.executable = false; + await expect(inspectSvmWirePrograms(connection)).rejects.toThrow('executable upgradeable'); + expect(rpc.getMultipleAccountsInfoAndContext).toHaveBeenCalledTimes(1); + }); + it('rejects a paused gateway using the actual Config decoder', async () => { + const { rows, connection } = fixture(); rows.get(config.toBase58())!.data[136] = 1; + await expect(inspectSvmWirePrograms(connection)).rejects.toThrow('paused'); + }); + it('rejects a substituted counter owner and an incompatible discriminator', async () => { + const { rows, connection } = fixture(); rows.get(SVM_WIRE_COUNTER.toBase58())!.owner = gateway; + await expect(inspectSvmWirePrograms(connection)).rejects.toThrow('wrong owner'); + expect(() => decodeWireCounter(Buffer.alloc(48))).toThrow('selected IDL'); + expect(() => decodeWireCounter(Buffer.alloc(8))).toThrow('selected IDL'); + }); + it('rejects missing ProgramData rather than treating program metadata as delivery proof', async () => { + const { rows, connection } = fixture(); rows.delete(new PublicKey(Buffer.alloc(32, 1)).toBase58()); + await expect(inspectSvmWirePrograms(connection)).rejects.toThrow('ProgramData'); + }); +}); diff --git a/packages/core/src/lib/agentic/agentic.ts b/packages/core/src/lib/agentic/agentic.ts new file mode 100644 index 000000000..e15344c0a --- /dev/null +++ b/packages/core/src/lib/agentic/agentic.ts @@ -0,0 +1,49 @@ +import { AgenticCapability, requireCapability } from './capabilities'; +import { currentGeneration } from './deployments'; +import { createWallet } from './management/create'; +import { emitAgentic } from './management/common'; +import { deriveForOwner, listForOwner } from './reads/wallets'; +import { AgenticWalletHandle } from './wallet'; +import { PROGRESS_HOOK } from '../progress-hook/progress-hook.types'; +import type { AgenticRuntime } from './runtime'; +import type { + AgenticAddress, + AgenticNamespace, + AgenticWallet, + CreateOptions, + CreateResult, + WalletSummary, +} from './agentic.types'; + +/** + * client.agentic — owner-side AGW management (spec section 1). Ownership is + * always the connected signer's Push identity (EOA, or the UEA for its origin + * chain), never the AGW an agentic client exposes as universal.account. + */ +export class AgenticNamespaceImpl implements AgenticNamespace { + constructor(private readonly runtime: AgenticRuntime) {} + + async derive(opts?: { index?: number }): Promise<{ address: AgenticAddress; index: number; deployed: boolean }> { + const gen = currentGeneration(this.runtime.network); + requireCapability(gen.capabilities, AgenticCapability.WALLET_READS); + const owner = this.runtime.signerPushAccount(); + const r = await deriveForOwner(this.runtime.reader, gen, owner, opts?.index); + emitAgentic(this.runtime, undefined, PROGRESS_HOOK.AGENTIC_TX_101, r.address, r.index, r.deployed); + return { address: r.address as AgenticAddress, index: r.index, deployed: r.deployed }; + } + + create(label: string, options: CreateOptions): Promise { + return createWallet(this.runtime, label, options); + } + + async list(): Promise<{ wallets: WalletSummary[] }> { + const gen = currentGeneration(this.runtime.network); + requireCapability(gen.capabilities, AgenticCapability.WALLET_READS); + const wallets = await listForOwner(this.runtime.reader, gen, this.runtime.signerPushAccount()); + return { wallets }; + } + + wallet(address: AgenticAddress): AgenticWallet { + return new AgenticWalletHandle(this.runtime, address); + } +} diff --git a/packages/core/src/lib/agentic/agentic.types.ts b/packages/core/src/lib/agentic/agentic.types.ts new file mode 100644 index 000000000..a0d1b6b22 --- /dev/null +++ b/packages/core/src/lib/agentic/agentic.types.ts @@ -0,0 +1,281 @@ +/** + * Public AGW types — AGW SDK page section 3 (Notion export 2026-10-03 13:23 IST). + * + * Named-IDL Solana inputs and native/EVM raw offsets follow the October 7 + * decisions. Contract-dependent label editing remains capability-gated. + */ +import type { Idl } from '@coral-xyz/anchor'; +import type { SvmTermsWire } from './codec/svm-terms'; +import type { MoveableToken } from '../constants/tokens'; +import type { UniversalTxResponse } from '../orchestrator/orchestrator.types'; +import type { ProgressEvent } from '../progress-hook/progress-hook.types'; + +export type AgenticAddress = `0x${string}`; +export type AgenticHex = `0x${string}`; +export type ForeignChainNamespace = `eip155:${string}` | `solana:${string}`; +export type AgenticProgressHook = (event: ProgressEvent) => void; + +/** '0x617ba037' or 'supply(address,uint256,address,uint16)'. */ +export type Selector = AgenticHex | `${string}(${string})`; + +export interface NativeRule { + /** Push address: an EOA, or the UEA of an external key. Never the owner. */ + agent: AgenticAddress; + /** Omitted = the connected Push chain. */ + chainNamespace?: never; + target: AgenticAddress; + selector: Selector | 'value-only'; + /** Unix seconds. */ + validUntil: number; + /** Default 0: no native PC value allowance. */ + maxValuePerCall?: bigint; + /** Default 0: no native PC value allowance. */ + maxValueTotal?: bigint; + /** Default 0 (unlimited calls until expiry). */ + maxCalls?: number; + pins?: ArgPin[]; + /** `maxTotal` default uint256 max (unlimited total). */ + amount?: AmountLimit; +} + +/** + * Where a pin or amount sits in the calldata. `arg` is the argument index in + * the selector signature (requires the signature form of `selector`). + * `offset` is the raw calldata byte offset, selector included — the form the + * contracts store; decoded rules (rules.get/list, decodeRules) always use it + * because stored terms carry no ABI. Advanced input form; ABI argument indexes are preferred for authoring. + */ +export type ArgPin = + | { + arg: number; + offset?: never; + expected: AgenticHex | AgenticAddress | bigint; + } + | { offset: number; arg?: never; expected: AgenticHex }; + +export type AmountLimit = ( + | { arg: number; offset?: never } + | { offset: number; arg?: never } +) & { + maxPerCall: bigint; + maxTotal?: bigint; +}; + +export interface AssetCap { + /** The destination chain's token (MOVEABLE constant) or native marker. */ + token: MoveableToken | AgenticAddress; + maxPerCall: bigint; + /** Omitted = uint256 maximum (unlimited). Explicit zero forbids movement. */ + maxTotal?: bigint; +} + +export interface AllowedCall { + target: AgenticAddress; + selector: Selector; + /** Argument index whose address must be the wallet's destination account. */ + beneficiary?: number; + /** Exact stored calldata offset for lossless decoded rules; mutually exclusive with beneficiary. Approved advanced input form. */ + beneficiaryOffset?: number; + /** Default 0. */ + maxValue?: bigint; +} + +export interface UniversalRule { + agent: AgenticAddress; + chainNamespace: `eip155:${string}`; + assets: AssetCap[]; + /** PC (wei) per outbound for the destination leg. */ + maxGasPerCall: bigint; + validUntil: number; + /** 1..32, all on chainNamespace. */ + allowedCalls: AllowedCall[]; +} + +/** An address may be base58 or a 32-byte hex Solana public key. */ +export type SvmAccountRef = + | { kind: 'walletCEA' } + | { kind: 'walletATA'; token: string } + | { kind: 'address'; address: string }; +export type SvmFieldConstraint = + | { name: string; equals: AgenticHex | bigint | boolean } + | { name: string; min: bigint } + | { name: string; max: bigint } + | { + numerator: string; + denominator: string; + minRatio: { num: bigint; den: bigint }; + }; +export interface SvmInstructionRule { + program: string; + instruction: { idl: Idl; name: string }; + /** Top-level account names from a supported Anchor IDL; optional/nested accounts are rejected. */ + accounts: { name: string; expected: SvmAccountRef }[]; + /** Fixed-width fields only; unsupported/variable layouts fail before signing. */ + fields?: SvmFieldConstraint[]; +} +export interface SolanaRule { + agent: AgenticAddress; + chainNamespace: `solana:${string}`; + validUntil: number; + assets: { + token: MoveableToken | string; + maxPerCall: bigint; + maxTotal?: bigint; + }[]; + maxGasPerCall: bigint; + /** Additional output mints whose wallet ATAs must be protected. */ + outputTokens?: string[]; + allowedInstructions: SvmInstructionRule[]; +} +/** Stored constraints contain no IDL; supply a named SolanaRule when replacing. */ +export interface DecodedSolanaRule extends Omit { + format: 'decoded'; + agent: AgenticAddress; + chainNamespace: `solana:${string}`; + assets: { token: string; maxPerCall: bigint; maxTotal: bigint }[]; +} +export type Rule = NativeRule | UniversalRule | SolanaRule; + +export interface RulesRecord { + rulesId: AgenticHex; + enabled: boolean; + chainNamespace: string; + agent: AgenticAddress; + validUntil: number; + rule: Rule | DecodedSolanaRule; +} + +export interface CreateOptions { + rules: Rule[]; + progressHook?: AgenticProgressHook; +} + +export interface CreateResult { + wallet: AgenticAddress; + index: number; + rulesIds: AgenticHex[]; + tx: UniversalTxResponse; +} + +export interface WalletSummary { + address: AgenticAddress; + index: number; + label: string; + deployed: boolean; + rulesCount: number; +} + +export interface WalletInfo { + address: AgenticAddress; + label: string; + index: number; + owner: AgenticAddress; + deployed: boolean; + rulesCount: number; +} + +export type CheckpointKind = 'OWNER_ACTION' | 'RULES_GRANTED' | 'RULES_REVOKED'; + +export interface Checkpoint { + seq: number; + kind: CheckpointKind; + ref: AgenticHex; + blockNumber: bigint; + txHash: AgenticHex; +} + +export interface AgenticWallet { + address: AgenticAddress; + info(): Promise; + owner(): Promise<{ owner: AgenticAddress }>; + /** Owner-only rename, at most 64 UTF-8 bytes. Empty resets the default label. */ + setLabel( + label: string, + opts?: { progressHook?: AgenticProgressHook } + ): Promise; + checkpoints(opts?: { + sinceBlock?: bigint; + }): Promise<{ checkpoints: Checkpoint[] }>; + rules: { + /** Enabled rules only; expired-but-enabled rules remain visible. No public spend counters. */ + list(): Promise<{ rules: RulesRecord[] }>; + /** Unknown and revoked IDs both return RULE_NOT_FOUND; v1 has no historical records. */ + get(rulesId: AgenticHex): Promise; + add( + rules: Rule[], + opts?: { progressHook?: AgenticProgressHook } + ): Promise<{ rulesIds: AgenticHex[]; tx: UniversalTxResponse }>; + update(params: { + rules: { rulesId: AgenticHex; rule: Rule }[]; + progressHook?: AgenticProgressHook; + }): Promise<{ + rules: { rulesId: AgenticHex; replaced: AgenticHex }[]; + tx: UniversalTxResponse; + }>; + revoke( + rulesIds: AgenticHex[], + opts?: { progressHook?: AgenticProgressHook } + ): Promise; + revoke(params: { + all: true; + progressHook?: AgenticProgressHook; + }): Promise; + }; +} + +export interface AgenticNamespace { + derive(opts?: { + index?: number; + }): Promise<{ address: AgenticAddress; index: number; deployed: boolean }>; + create(label: string, options: CreateOptions): Promise; + list(): Promise<{ wallets: WalletSummary[] }>; + wallet(address: AgenticAddress): AgenticWallet; +} + +/** Door the client uses in agenticWallet mode. */ +export type AgenticDoor = 'owner' | 'agent'; + +/** AGW metadata attached to responses produced by an agentic client. */ +export interface AgenticTxMetadata { + wallet: AgenticAddress; + door: AgenticDoor; + /** Rule selected for an agent-door send. */ + rulesId?: AgenticHex; + /** Destination CAIP-2 chain for the selected rule / outbound. */ + chainNamespace?: string; + /** The wallet's destination account (CEA) for an outbound. */ + destinationAccount?: AgenticAddress; + /** + * Ordered calls encoded for the destination CEA. Outbound response + * to/data/value summarize the first call, including an SDK-generated token + * transfer. Replay uses the same representation; it cannot recover which + * calls were supplied explicitly versus generated from funds. + */ + destinationCalls?: readonly { + to: AgenticAddress; + data: AgenticHex; + value: bigint; + }[]; + /** SVM instruction executed by the wallet's destination CEA. */ + destinationInstruction?: { + program: AgenticHex; + data: AgenticHex; + accounts: readonly { pubkey: AgenticHex; isWritable: boolean }[]; + }; + /** Funds-only Solana transfer. token is the burned PRC20 on Push. */ + destinationTransfer?: { + recipient: AgenticHex; + token: AgenticAddress; + amount: bigint; + }; + /** Ordered actions in a native agent batch; to/data/value summarize its first action. */ + nativeCalls?: readonly { + to: AgenticAddress; + data: AgenticHex; + value: bigint; + }[]; + /** Account the signer's transaction actually called (wallet or outer sender account). */ + rawTo: string; + /** Calldata the signer's transaction actually carried. */ + rawData: string; +} diff --git a/packages/core/src/lib/agentic/capabilities.ts b/packages/core/src/lib/agentic/capabilities.ts new file mode 100644 index 000000000..6e481d075 --- /dev/null +++ b/packages/core/src/lib/agentic/capabilities.ts @@ -0,0 +1,79 @@ +import { capabilityUnavailable } from './errors'; + +/** + * Internal capability flags for an AGW contract generation. A method whose + * capability is absent fails before any signature. Each unresolved target + * feature identifies the contract or product capability that must be verified + * before it can be advertised. + */ +export enum AgenticCapability { + /** Factory derive/list/deploy and wallet owner/info reads. */ + WALLET_READS = 'walletReads', + /** Active (enabled) rule enumeration and native-rule decoding. */ + ACTIVE_RULE_READS = 'activeRuleReads', + /** Owner door: execute(mode, executionCalldata). */ + OWNER_EXECUTE = 'ownerExecute', + /** Agent door: executeAsAgent(rulesId, mode, executionCalldata). */ + AGENT_EXECUTE = 'agentExecute', + /** Native (Push-chain) rule grants with NativeTerms encoding. */ + NATIVE_RULES = 'nativeRules', + /** Exact-equality native spend assertion used by rules.update. */ + ASSERT_SPENT_NATIVE = 'assertSpentNative', + /** checkpointCount + Checkpointed events. */ + CHECKPOINTS = 'checkpoints', + /** Agent-door EVM outbound composition against v5 per-token caps. */ + UNIVERSAL_EVM_OUTBOUND = 'universalEvmOutbound', + /** Target UniversalRule (assets[] / maxGasPerCall) encode/decode. A05/A07. */ + UNIVERSAL_EVM_RULES = 'universalEvmRules', + /** Per-token expected-spend assertion for universal replacement. A05. */ + ASSERT_SPENT_UNIVERSAL_MULTI = 'assertSpentUniversalMulti', + /** Named-IDL Solana authoring, exact decoded reads and instruction execution. */ + UNIVERSAL_SVM_RULES = 'universalSvmRules', + /** Editable wallet label (setLabel / LabelSet). A07. */ + SET_LABEL = 'setLabel', +} + +/** Why each target capability is unavailable when a generation lacks it. */ +export const CAPABILITY_DEPENDENCY: Record = { + [AgenticCapability.WALLET_READS]: + 'no verified AGW deployment for this network (A07)', + [AgenticCapability.ACTIVE_RULE_READS]: + 'no verified AGW deployment for this network (A07)', + [AgenticCapability.OWNER_EXECUTE]: + 'no verified AGW deployment for this network (A07)', + [AgenticCapability.AGENT_EXECUTE]: + 'no verified AGW deployment for this network (A07)', + [AgenticCapability.NATIVE_RULES]: + 'no verified AGW deployment for this network (A07)', + [AgenticCapability.ASSERT_SPENT_NATIVE]: + 'no verified AGW deployment for this network (A07)', + [AgenticCapability.CHECKPOINTS]: + 'no verified AGW deployment for this network (A07)', + [AgenticCapability.UNIVERSAL_EVM_OUTBOUND]: + 'the selected generation has no verified multi-asset outbound token resolution (A05/A07)', + [AgenticCapability.UNIVERSAL_EVM_RULES]: + 'EVM universal rules are unavailable for the selected SDK configuration', + [AgenticCapability.ASSERT_SPENT_UNIVERSAL_MULTI]: + 'universal expected-spend assertions are unavailable for this configuration', + [AgenticCapability.UNIVERSAL_SVM_RULES]: + 'SVM rules are unavailable for this generation', + [AgenticCapability.SET_LABEL]: + 'the selected generation has no editable label (A07; labels are deploy-time only)', +}; + +export function hasCapability( + capabilities: ReadonlySet, + capability: AgenticCapability +): boolean { + return capabilities.has(capability); +} + +/** Throws CAPABILITY_UNAVAILABLE (before signing) when the flag is absent. */ +export function requireCapability( + capabilities: ReadonlySet | undefined, + capability: AgenticCapability +): void { + if (!capabilities || !capabilities.has(capability)) { + throw capabilityUnavailable(capability, CAPABILITY_DEPENDENCY[capability]); + } +} diff --git a/packages/core/src/lib/agentic/chain.ts b/packages/core/src/lib/agentic/chain.ts new file mode 100644 index 000000000..cac1f6e2f --- /dev/null +++ b/packages/core/src/lib/agentic/chain.ts @@ -0,0 +1,13 @@ +import { CHAIN_INFO } from '../constants/chain'; +import { CHAIN, PUSH_NETWORK } from '../constants/enums'; + +/** 'eip155:' of the Push chain for a network — the native rule chain. */ +export function pushChainNamespaceFor(network: PUSH_NETWORK): string { + const chain = + network === PUSH_NETWORK.MAINNET + ? CHAIN.PUSH_MAINNET + : network === PUSH_NETWORK.TESTNET_DONUT || network === PUSH_NETWORK.TESTNET + ? CHAIN.PUSH_TESTNET_DONUT + : CHAIN.PUSH_LOCALNET; + return `eip155:${CHAIN_INFO[chain].chainId}`; +} diff --git a/packages/core/src/lib/agentic/codec/abi-layout.ts b/packages/core/src/lib/agentic/codec/abi-layout.ts new file mode 100644 index 000000000..2e1d46290 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/abi-layout.ts @@ -0,0 +1,139 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + isAddress, + isHex, + type AbiParameter, +} from 'viem'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { AgenticHex } from '../agentic.types'; + +/** + * ABI head layout for argument-position terms (native pins/amount, universal + * beneficiary). Contracts take ABSOLUTE byte offsets into calldata with the + * 4-byte selector included and read exactly one 32-byte word there, so only a + * single-word static elementary argument can be addressed. Preceding + * arguments may be dynamic (one head word: the offset pointer) or static + * tuples / fixed arrays (several head words); both are accounted for here. + */ + +const UINT16_MAX = 0xffff; + +function components(p: AbiParameter): readonly AbiParameter[] { + return (p as { components?: readonly AbiParameter[] }).components ?? []; +} + +/** Parse a trailing fixed/dynamic array suffix: 'uint256[3]' → base 'uint256', length 3. */ +function arraySuffix(type: string): { base: string; length: number | null } | null { + const m = /^(.*)\[(\d*)\]$/.exec(type); + if (!m) return null; + return { base: m[1], length: m[2] === '' ? null : Number(m[2]) }; +} + +export function isDynamic(p: AbiParameter): boolean { + const arr = arraySuffix(p.type); + if (arr) { + if (arr.length === null) return true; + return isDynamic({ ...p, type: arr.base } as AbiParameter); + } + if (p.type === 'bytes' || p.type === 'string') return true; + if (p.type === 'tuple') return components(p).some(isDynamic); + return false; +} + +/** Bytes this parameter occupies in the head of an encoding. */ +export function headSize(p: AbiParameter): number { + if (isDynamic(p)) return 32; + const arr = arraySuffix(p.type); + if (arr && arr.length !== null) { + return arr.length * headSize({ ...p, type: arr.base } as AbiParameter); + } + if (p.type === 'tuple') { + return components(p).reduce((sum, c) => sum + headSize(c), 0); + } + return 32; +} + +const ELEMENTARY = /^(address|bool|u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?|bytes([1-9]|[12][0-9]|3[0-2]))$/; + +export function isSingleWordStatic(p: AbiParameter): boolean { + return ELEMENTARY.test(p.type); +} + +/** + * Absolute calldata byte offset (selector included) of argument `arg`. + * Rejects out-of-range indexes and any argument that is not one static word. + */ +export function argumentOffset( + inputs: readonly AbiParameter[] | undefined, + arg: number, + what: string +): number { + if (!inputs) { + throw invalid( + `${what} needs the function signature (e.g. 'supply(address,uint256,address,uint16)'), not a bare 4-byte selector` + ); + } + if (!Number.isInteger(arg) || arg < 0 || arg >= inputs.length) { + throw invalid(`${what} argument index ${arg} is out of range (0..${inputs.length - 1})`); + } + const target = inputs[arg]; + if (!isSingleWordStatic(target)) { + throw invalid( + `${what} argument ${arg} has type ${target.type}; only single-word static arguments can be pinned` + ); + } + let offset = 4; + for (let i = 0; i < arg; i++) offset += headSize(inputs[i]); + if (offset + 32 > UINT16_MAX) { + throw invalid(`${what} argument ${arg} lies beyond the uint16 offset range`); + } + return offset; +} + +/** Encode a pin's expected value as the exact 32-byte calldata word. */ +export function encodeArgWord( + param: AbiParameter, + expected: AgenticHex | bigint | string | boolean, + what: string +): AgenticHex { + try { + const isWord = typeof expected === 'string' && isHex(expected) && expected.length === 66; + if (param.type === 'address' && !isWord) { + if (typeof expected !== 'string' || !isAddress(expected, { strict: false })) { + throw new Error('expected an address'); + } + } else if (param.type.startsWith('bytes') && !isWord) { + if (typeof expected !== 'string' || !isHex(expected)) throw new Error('expected hex'); + } + // An already-encoded 32-byte word (as decoded rules return) is accepted + // when it is a canonical encoding of this argument type. + if (typeof expected === 'string' && isHex(expected) && expected.length === 66 && param.type !== 'bytes32') { + const [value] = decodeAbiParameters([param], expected as AgenticHex); + if (encodeAbiParameters([param], [value]).toLowerCase() !== expected.toLowerCase()) { + throw new Error('word is not a canonical encoding'); + } + return expected.toLowerCase() as AgenticHex; + } + return encodeAbiParameters([param], [expected]) as AgenticHex; + } catch (cause) { + throw invalid( + `${what} expected value does not encode as ${param.type}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause + ); + } +} + +/** Validate a raw calldata offset given directly (offset form). */ +export function rawOffset(offset: number, what: string): number { + if (!Number.isInteger(offset) || offset < 4 || offset + 32 > UINT16_MAX) { + throw invalid(`${what} offset ${offset} must be an integer in [4, ${UINT16_MAX - 32}] (selector included)`); + } + return offset; +} + +function invalid(message: string, cause?: unknown): AgenticError { + return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message, { cause }); +} diff --git a/packages/core/src/lib/agentic/codec/defaults.ts b/packages/core/src/lib/agentic/codec/defaults.ts new file mode 100644 index 000000000..cc652c461 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/defaults.ts @@ -0,0 +1,59 @@ +/** + * THE single home for omitted-limit defaults. Native PC defaults are zero as + * confirmed on October 7. Nothing else in the SDK may invent + * a fallback for these fields; update this table for future changes. + * + * Explicit zero always stays zero. In the native policy a zero value/amount + * cap forbids positive value/amount, while maxCalls = 0 means unlimited. + */ +export const UINT256_MAX = BigInt(2) ** BigInt(256) - BigInt(1); + +export interface DefaultRow { + value: bigint; + meaning: string; + source: 'product' | 'v5'; +} + +export const AGENTIC_DEFAULTS = { + native: { + maxValuePerCall: { + value: BigInt(0), + meaning: 'no native value transfer', + source: 'product', + }, + maxValueTotal: { + value: BigInt(0), + meaning: 'no native value transfer', + source: 'product', + }, + amountMaxTotal: { + value: UINT256_MAX, + meaning: 'unlimited metered total', + source: 'product', + }, + maxCalls: { + value: BigInt(0), + meaning: 'unlimited calls until expiry', + source: 'product', + }, + }, + universal: { + assetMaxTotal: { + value: UINT256_MAX, + meaning: 'unlimited total; explicit zero forbids movement', + source: 'v5', + }, + allowedCallMaxValue: { + value: BigInt(0), + meaning: 'no native value attached to the destination call', + source: 'product', + }, + }, +} as const satisfies Record>; + +export function withDefault( + value: bigint | undefined, + row: DefaultRow +): bigint { + return value === undefined ? row.value : value; +} diff --git a/packages/core/src/lib/agentic/codec/ids.ts b/packages/core/src/lib/agentic/codec/ids.ts new file mode 100644 index 000000000..56c4f473d --- /dev/null +++ b/packages/core/src/lib/agentic/codec/ids.ts @@ -0,0 +1,112 @@ +import { + concat, + encodeAbiParameters, + encodePacked, + getAddress, + getContractAddress, + keccak256, + numberToHex, + type Address, + type Hex, +} from 'viem'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; + +/** + * Pure identity helpers mirroring the pinned AGW generation (v5): + * smartsessions IdLib.toActionId / toConfigId / toPermissionId and + * AGWFactory._predict (OZ Clones immutable-args CREATE2). + * Every input that changes the result is explicit — validator, factory and + * implementation are internal generation context. + */ + +/** keccak256(abi.encodePacked(address target, bytes4 selector)). */ +export function actionId(target: Address, selector: Hex): Hex { + return keccak256(encodePacked(['address', 'bytes4'], [target, selector])); +} + +/** keccak256(abi.encodePacked(wallet, keccak256(abi.encodePacked(rulesId, actionId)))). */ +export function configId(wallet: Address, rulesId: Hex, action: Hex): Hex { + const inner = keccak256( + encodePacked(['bytes32', 'bytes32'], [rulesId, action]) + ); + return keccak256(encodePacked(['address', 'bytes32'], [wallet, inner])); +} + +/** sessionValidatorInitData for the agent validator: abi.encode(address agent). */ +export function agentConfig(agent: Address): Hex { + return encodeAbiParameters([{ type: 'address' }], [agent]); +} + +/** + * The rules ID a grant produces: + * keccak256(abi.encode(validator, bytes abi.encode(agent), bytes32(grantNonce))). + * Chain is NOT part of the hash; records are keyed by wallet + rulesId. + */ +export function rulesId(ctx: { + validator: Address; + agent: Address; + grantNonce: bigint; +}): Hex { + if (ctx.grantNonce < BigInt(0) || ctx.grantNonce >= BigInt(2) ** BigInt(64)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'grantNonce must fit uint64' + ); + } + return keccak256( + encodeAbiParameters( + [{ type: 'address' }, { type: 'bytes' }, { type: 'bytes32' }], + [ + ctx.validator, + agentConfig(ctx.agent), + numberToHex(ctx.grantNonce, { size: 32 }), + ] + ) + ); +} + +const UINT96_MAX = BigInt(2) ** BigInt(96) - BigInt(1); + +/** + * Counterfactual wallet address: CREATE2 by the factory proxy of an OZ clone + * of `walletImplementation` with immutable args (owner ‖ factory), salt + * keccak256(abi.encode(owner, uint96 index)). + */ +export function deriveWallet(ctx: { + factory: Address; + walletImplementation: Address; + owner: Address; + index: bigint | number; +}): Address { + const index = BigInt(ctx.index); + if (index < BigInt(0) || index > UINT96_MAX) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'wallet index must fit uint96' + ); + } + const salt = keccak256( + encodeAbiParameters( + [{ type: 'address' }, { type: 'uint96' }], + [ctx.owner, index] + ) + ); + const args = encodePacked(['address', 'address'], [ctx.owner, ctx.factory]); + const argsLength = (args.length - 2) / 2; + const initCode = concat([ + '0x61', + numberToHex(argsLength + 0x2d, { size: 2 }), + '0x3d81600a3d39f3363d3d373d3d3d363d73', + ctx.walletImplementation, + '0x5af43d82803e903d91602b57fd5bf3', + args, + ]); + return getAddress( + getContractAddress({ + opcode: 'CREATE2', + from: ctx.factory, + salt, + bytecodeHash: keccak256(initCode), + }) + ); +} diff --git a/packages/core/src/lib/agentic/codec/native.ts b/packages/core/src/lib/agentic/codec/native.ts new file mode 100644 index 000000000..b11976853 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/native.ts @@ -0,0 +1,278 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + getAddress, + isAddress, + isHex, + type AbiParameter, + type Address, + type Hex, +} from 'viem'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { AgenticHex, NativeRule } from '../agentic.types'; +import { argumentOffset, encodeArgWord, rawOffset } from './abi-layout'; +import { AGENTIC_DEFAULTS, UINT256_MAX, withDefault } from './defaults'; +import { + parseSelector, + VALUE_ONLY_SELECTOR, + type ParsedSelector, +} from './selectors'; + +/** URP native limits at v5 (Types.sol MAX_PINS, AGW MAX_NATIVE_ACTIONS). */ +export const MAX_PINS = 8; +const UINT32_MAX = 2 ** 32 - 1; +const UINT48_MAX = 2 ** 48 - 1; + +/** NativeTerms (Types.sol:354-363 at v5). */ +export interface NativeTermsWire { + validUntil: number; + target: Address; + selector: Hex; + maxValuePerCall: bigint; + maxValueTotal: bigint; + amount: { + enabled: boolean; + offset: number; + maxPerCall: bigint; + maxTotal: bigint; + }; + maxCalls: number; + pins: readonly { offset: number; expected: Hex }[]; +} + +export const NATIVE_TERMS_PARAM: AbiParameter = { + type: 'tuple', + components: [ + { name: 'validUntil', type: 'uint48' }, + { name: 'target', type: 'address' }, + { name: 'selector', type: 'bytes4' }, + { name: 'maxValuePerCall', type: 'uint256' }, + { name: 'maxValueTotal', type: 'uint256' }, + { + name: 'amount', + type: 'tuple', + components: [ + { name: 'enabled', type: 'bool' }, + { name: 'offset', type: 'uint16' }, + { name: 'maxPerCall', type: 'uint256' }, + { name: 'maxTotal', type: 'uint256' }, + ], + }, + { name: 'maxCalls', type: 'uint32' }, + { + name: 'pins', + type: 'tuple[]', + components: [ + { name: 'offset', type: 'uint16' }, + { name: 'expected', type: 'bytes32' }, + ], + }, + ], +}; + +function invalid( + message: string, + details?: Record +): AgenticError { + return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message, { + details, + }); +} + +function assertUint(value: bigint, bits: number, field: string): void { + if ( + typeof value !== 'bigint' || + value < BigInt(0) || + value >= BigInt(2) ** BigInt(bits) + ) { + throw invalid(`${field} must be a uint${bits} bigint`); + } +} + +export interface NativeEncodeContext { + /** Current unix time in seconds; grants with validUntil <= now are rejected. */ + nowSeconds: number; +} + +/** Normalize and validate a public NativeRule into wire terms. Pure. */ +export function nativeRuleToTerms( + rule: NativeRule, + ctx: NativeEncodeContext +): { terms: NativeTermsWire; parsed: ParsedSelector } { + if ( + !isAddress(rule.target, { strict: false }) || + /^0x0{40}$/i.test(rule.target) + ) { + throw invalid('native rule target must be a non-zero address'); + } + if ( + !Number.isInteger(rule.validUntil) || + rule.validUntil <= ctx.nowSeconds || + rule.validUntil > UINT48_MAX + ) { + throw invalid( + 'validUntil must be a future unix timestamp in seconds (uint48)', + { + validUntil: rule.validUntil, + } + ); + } + const parsed = parseSelector(rule.selector); + const pinsIn = rule.pins ?? []; + if (parsed.valueOnly && (pinsIn.length > 0 || rule.amount)) { + throw invalid('a value-only rule cannot carry pins or an amount rule'); + } + if (pinsIn.length > MAX_PINS) { + throw invalid(`at most ${MAX_PINS} pins are allowed`, { + pins: pinsIn.length, + }); + } + + const pins = pinsIn.map((p, i) => { + if (p.arg !== undefined && p.offset !== undefined) + throw invalid('pin arg and offset are mutually exclusive'); + if (p.offset !== undefined) { + if ( + typeof p.expected !== 'string' || + !isHex(p.expected) || + p.expected.length !== 66 + ) { + throw invalid( + `pins[${i}].expected must be the exact 32-byte calldata word in offset form` + ); + } + return { + offset: rawOffset(p.offset, `pins[${i}]`), + expected: p.expected.toLowerCase() as Hex, + }; + } + if (typeof p.arg !== 'number') throw invalid('pin requires arg or offset'); + const offset = argumentOffset(parsed.inputs, p.arg, `pins[${i}]`); + const param = (parsed.inputs as readonly AbiParameter[])[p.arg]; + return { offset, expected: encodeArgWord(param, p.expected, `pins[${i}]`) }; + }); + const offsets = new Set(pins.map((p) => p.offset)); + if (offsets.size !== pins.length) + throw invalid('two pins address the same argument'); + + let amount: NativeTermsWire['amount'] = { + enabled: false, + offset: 0, + maxPerCall: BigInt(0), + maxTotal: BigInt(0), + }; + if (rule.amount) { + let offset: number; + if (rule.amount.arg !== undefined && rule.amount.offset !== undefined) + throw invalid('amount arg and offset are mutually exclusive'); + if (rule.amount.offset !== undefined) { + offset = rawOffset(rule.amount.offset, 'amount'); + } else { + if (typeof rule.amount.arg !== 'number') + throw invalid('amount requires arg or offset'); + offset = argumentOffset(parsed.inputs, rule.amount.arg, 'amount'); + const param = (parsed.inputs as readonly AbiParameter[])[rule.amount.arg]; + if (!/^uint\d*$/.test(param.type)) { + throw invalid( + `amount argument must be an unsigned integer, got ${param.type}` + ); + } + } + if (offsets.has(offset)) throw invalid('amount argument is also pinned'); + assertUint(rule.amount.maxPerCall, 256, 'amount.maxPerCall'); + const maxTotal = withDefault( + rule.amount.maxTotal, + AGENTIC_DEFAULTS.native.amountMaxTotal + ); + assertUint(maxTotal, 256, 'amount.maxTotal'); + amount = { + enabled: true, + offset, + maxPerCall: rule.amount.maxPerCall, + maxTotal, + }; + } + + const maxValuePerCall = withDefault( + rule.maxValuePerCall, + AGENTIC_DEFAULTS.native.maxValuePerCall + ); + const maxValueTotal = withDefault( + rule.maxValueTotal, + AGENTIC_DEFAULTS.native.maxValueTotal + ); + assertUint(maxValuePerCall, 256, 'maxValuePerCall'); + assertUint(maxValueTotal, 256, 'maxValueTotal'); + const maxCalls = + rule.maxCalls === undefined + ? Number(AGENTIC_DEFAULTS.native.maxCalls.value) + : rule.maxCalls; + if (!Number.isInteger(maxCalls) || maxCalls < 0 || maxCalls > UINT32_MAX) { + throw invalid('maxCalls must be a uint32 integer'); + } + + return { + parsed, + terms: { + validUntil: rule.validUntil, + target: getAddress(rule.target), + selector: parsed.selector, + maxValuePerCall, + maxValueTotal, + amount, + maxCalls, + pins, + }, + }; +} + +export function encodeNativeTerms(terms: NativeTermsWire): Hex { + return encodeAbiParameters([NATIVE_TERMS_PARAM], [terms]); +} + +export function decodeNativeTerms(body: Hex): NativeTermsWire { + const [terms] = decodeAbiParameters([NATIVE_TERMS_PARAM], body); + return terms as unknown as NativeTermsWire; +} + +/** + * Wire terms back to the public NativeRule shape. Stored terms carry calldata + * offsets and exact words but no ABI, so pins and amount are returned in the + * raw `offset` form (never a guessed argument index) and `selector` is the + * 4-byte hex. The result re-encodes to the same terms. + */ +export function nativeTermsToRule( + terms: NativeTermsWire, + agent: Address +): NativeRule { + const rule: NativeRule = { + agent: getAddress(agent), + target: getAddress(terms.target), + selector: + terms.selector.toLowerCase() === VALUE_ONLY_SELECTOR + ? 'value-only' + : (terms.selector.toLowerCase() as AgenticHex), + validUntil: Number(terms.validUntil), + maxValuePerCall: terms.maxValuePerCall, + maxValueTotal: terms.maxValueTotal, + maxCalls: Number(terms.maxCalls), + }; + if (terms.pins.length > 0) { + rule.pins = terms.pins.map((p) => ({ + offset: Number(p.offset), + expected: p.expected.toLowerCase() as AgenticHex, + })); + } + if (terms.amount.enabled) { + rule.amount = { + offset: Number(terms.amount.offset), + maxPerCall: terms.amount.maxPerCall, + maxTotal: terms.amount.maxTotal, + }; + } + return rule; +} + +export function isUnlimited(value: bigint): boolean { + return value === UINT256_MAX; +} diff --git a/packages/core/src/lib/agentic/codec/rules.ts b/packages/core/src/lib/agentic/codec/rules.ts new file mode 100644 index 000000000..0d5364b82 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/rules.ts @@ -0,0 +1,198 @@ +import { + getAddress, + isAddress, + keccak256, + toBytes, + type Address, + type Hex, +} from 'viem'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { + NativeRule, + Rule, + UniversalRule, + SolanaRule, +} from '../agentic.types'; +import { + encodeNativeTerms, + nativeRuleToTerms, + type NativeTermsWire, +} from './native'; +import { buildSession, encodeEnvelope, type SessionWire } from './session'; +import { encodeUniversalRule, type ResolvedUniversalRule } from './universal'; +import { encodeSvmTerms, type SvmTermsWire } from './svm-terms'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import type { UniversalTermsWire } from './universal-terms'; + +export function isSolanaRule(rule: Rule): rule is SolanaRule { + return ( + typeof (rule as SolanaRule).chainNamespace === 'string' && + (rule as SolanaRule).chainNamespace.startsWith('solana:') + ); +} + +export function isUniversalRule(rule: Rule): rule is UniversalRule { + return ( + (rule as UniversalRule).chainNamespace !== undefined && !isSolanaRule(rule) + ); +} + +/** CAIP-2 chain a rule governs: omitted = the connected Push chain. */ +export function ruleChain(rule: Rule, pushChainNamespace: string): string { + return ( + (rule as UniversalRule | SolanaRule).chainNamespace ?? pushChainNamespace + ); +} + +export function chainHash(chainNamespace: string): Hex { + return keccak256(toBytes(chainNamespace)); +} + +export interface RuleEncodeContext { + /** 'eip155:' of the connected network. */ + pushChainNamespace: string; + validator: Address; + rulesPolicy: Address; + capabilities: ReadonlySet; + nowSeconds: number; + gateway?: Address; + universal?: ReadonlyMap; + svm?: ReadonlyMap; + /** + * Addresses a native rule may never target (AGW _requireGrantableTarget / + * URP NativeTargetIsGateway): wallet, engine, policy, validator, factory, + * gateway. 0x0 and 0x1 are always refused. + */ + forbiddenTargets?: readonly Address[]; +} + +export interface PreparedRule { + rule: Rule; + agent: Address; + chainNamespace: string; + session: SessionWire; + /** Native terms when the rule is native (used for previews and assertions). */ + nativeTerms?: NativeTermsWire; + universalTerms?: UniversalTermsWire; + svmTerms?: SvmTermsWire; +} + +/** Validate before signing. V5 permits several grants for the same agent/chain. */ +export function prepareRules( + rules: readonly Rule[], + ctx: RuleEncodeContext & { + /** Wallet owner; when given, an agent equal to it is AGENT_IS_OWNER. */ + owner?: Address; + } +): PreparedRule[] { + if (!Array.isArray(rules)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'rules must be an array' + ); + } + return rules.map((rule, i) => { + if (!rule || typeof rule !== 'object') { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `rules[${i}] is not an object` + ); + } + if ( + !isAddress(rule.agent, { strict: false }) || + /^0x0{40}$/i.test(rule.agent) + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `rules[${i}].agent must be a non-zero Push address` + ); + } + const agent = getAddress(rule.agent); + if (ctx.owner && agent === getAddress(ctx.owner)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.AGENT_IS_OWNER, + `rules[${i}].agent is the wallet owner; the owner uses the owner door` + ); + } + const chainNamespace = ruleChain(rule, ctx.pushChainNamespace); + if ( + isUniversalRule(rule) && + rule.chainNamespace === ctx.pushChainNamespace + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `rules[${i}] names the connected Push chain; omit chainNamespace for a native rule` + ); + } + if ('ref' in rule) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'Rule ref is not supported; remove it from the input' + ); + } + + if (isSolanaRule(rule)) { + requireCapability( + ctx.capabilities, + AgenticCapability.UNIVERSAL_SVM_RULES + ); + const terms = ctx.svm?.get(rule); + if (!terms || !ctx.gateway) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'SVM rules require resolved wallet context and named IDL inputs' + ); + return { + rule, + agent, + chainNamespace, + svmTerms: terms, + session: buildSession({ + validator: ctx.validator, + rulesPolicy: ctx.rulesPolicy, + agent, + actions: [ + { + target: ctx.gateway, + selector: SEND_OUTBOUND_SELECTOR, + initData: encodeEnvelope( + chainNamespace, + encodeSvmTerms(terms, ctx.nowSeconds) + ), + }, + ], + }), + }; + } + if (isUniversalRule(rule)) { + return encodeUniversalRule(rule, ctx); + } + requireCapability(ctx.capabilities, AgenticCapability.NATIVE_RULES); + const { terms } = nativeRuleToTerms(rule as NativeRule, ctx); + const forbidden = [ + '0x0000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000001', + ...(ctx.forbiddenTargets ?? []), + ].map((a) => a.toLowerCase()); + if (forbidden.includes(terms.target.toLowerCase())) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `rules[${i}].target ${terms.target} is a wallet-infrastructure address the contracts refuse as a native target` + ); + } + const session = buildSession({ + validator: ctx.validator, + agent, + rulesPolicy: ctx.rulesPolicy, + actions: [ + { + target: terms.target, + selector: terms.selector, + initData: encodeEnvelope(chainNamespace, encodeNativeTerms(terms)), + }, + ], + }); + return { rule, agent, chainNamespace, session, nativeTerms: terms }; + }); +} diff --git a/packages/core/src/lib/agentic/codec/selectors.ts b/packages/core/src/lib/agentic/codec/selectors.ts new file mode 100644 index 000000000..317575f76 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/selectors.ts @@ -0,0 +1,77 @@ +import { + parseAbiItem, + toFunctionSelector, + type AbiFunction, + type AbiParameter, +} from 'viem'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { AgenticHex, Selector } from '../agentic.types'; + +/** Selector URP uses for a value-only native rule (empty calldata). */ +export const VALUE_ONLY_SELECTOR = '0xffffffff' as const; + +export interface ParsedSelector { + /** 4-byte selector, lowercase. */ + selector: AgenticHex; + /** Canonical signature when the caller supplied one. */ + signature?: string; + /** ABI inputs, only available when the caller supplied a signature. */ + inputs?: readonly AbiParameter[]; + valueOnly: boolean; +} + +const HEX_SELECTOR = /^0x[0-9a-fA-F]{8}$/; + +/** + * Parse a public selector (`'0x617ba037'`, `'supply(address,uint256,address,uint16)'` + * or `'value-only'`). A signature is required wherever pins, amounts or + * beneficiaries need argument positions; a bare hex selector carries none. + */ +export function parseSelector(input: Selector | 'value-only'): ParsedSelector { + if (input === 'value-only') { + return { selector: VALUE_ONLY_SELECTOR, valueOnly: true }; + } + if (typeof input !== 'string') { + throw invalid(`selector must be a string, got ${typeof input}`); + } + if (HEX_SELECTOR.test(input)) { + const selector = input.toLowerCase() as AgenticHex; + if (selector === VALUE_ONLY_SELECTOR) { + return { selector, valueOnly: true }; + } + return { selector, valueOnly: false }; + } + let item: AbiFunction; + try { + const parsed = parseAbiItem(`function ${input}`); + if (parsed.type !== 'function') throw new Error('not a function'); + item = parsed; + } catch (cause) { + throw invalid( + `selector "${input}" is neither a 4-byte hex selector nor a function signature`, + cause + ); + } + const selector = toFunctionSelector(item).toLowerCase() as AgenticHex; + if (selector === VALUE_ONLY_SELECTOR) { + throw invalid(`selector "${input}" collides with the reserved value-only selector`); + } + return { + selector, + signature: `${item.name}(${item.inputs.map(canonicalType).join(',')})`, + inputs: item.inputs, + valueOnly: false, + }; +} + +function canonicalType(p: AbiParameter): string { + if (p.type.startsWith('tuple')) { + const components = (p as { components?: readonly AbiParameter[] }).components ?? []; + return `(${components.map(canonicalType).join(',')})${p.type.slice(5)}`; + } + return p.type; +} + +function invalid(message: string, cause?: unknown): AgenticError { + return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message, { cause }); +} diff --git a/packages/core/src/lib/agentic/codec/session.ts b/packages/core/src/lib/agentic/codec/session.ts new file mode 100644 index 000000000..fc98c1f6e --- /dev/null +++ b/packages/core/src/lib/agentic/codec/session.ts @@ -0,0 +1,116 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + getAbiItem, + type AbiParameter, + type Address, + type Hex, +} from 'viem'; +import { AGW_ABI } from '../contracts/abi/v5'; +import { agentConfig } from './ids'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; + +/** + * The smartsessions `Session` a grant carries (DataTypes.sol:56-86, pinned + * v5). The wallet overwrites `salt` with its grant nonce, so the value + * sent is irrelevant; it is always zero here. + */ +export interface SessionWire { + sessionValidator: Address; + sessionValidatorInitData: Hex; + salt: Hex; + userOpPolicies: readonly { policy: Address; initData: Hex }[]; + erc7739Policies: { + allowedERC7739Content: readonly { + appDomainSeparator: Hex; + contentNames: readonly string[]; + }[]; + erc1271Policies: readonly { policy: Address; initData: Hex }[]; + }; + actions: readonly { + actionTargetSelector: Hex; + actionTarget: Address; + actionPolicies: readonly { policy: Address; initData: Hex }[]; + }[]; + permitERC4337Paymaster: boolean; +} + +export const SESSION_PARAM: AbiParameter = getAbiItem({ + abi: AGW_ABI, + name: 'grantRules', +}).inputs[0] as AbiParameter; + +const ZERO_SALT = `0x${'00'.repeat(32)}` as Hex; + +export const ENVELOPE_VERSION = 1; + +/** V5 URP envelope: abi.encode(uint16(1), string chainNamespace, bytes body). */ +export function encodeEnvelope(chainNamespace: string, body: Hex): Hex { + return encodeAbiParameters( + [{ type: 'uint16' }, { type: 'string' }, { type: 'bytes' }], + [ENVELOPE_VERSION, chainNamespace, body] + ); +} + +export function decodeEnvelope(initData: Hex): { + chainNamespace: string; + body: Hex; +} { + try { + const [version, chainNamespace, body] = decodeAbiParameters( + [{ type: 'uint16' }, { type: 'string' }, { type: 'bytes' }], + initData + ); + if (version !== ENVELOPE_VERSION) + throw new Error(`unsupported rules envelope version ${version}`); + return { chainNamespace, body }; + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'malformed URP envelope', + { + cause, + } + ); + } +} + +export function buildSession(ctx: { + validator: Address; + agent: Address; + rulesPolicy: Address; + actions: { target: Address; selector: Hex; initData: Hex }[]; +}): SessionWire { + return { + sessionValidator: ctx.validator, + sessionValidatorInitData: agentConfig(ctx.agent), + salt: ZERO_SALT, + userOpPolicies: [], + erc7739Policies: { allowedERC7739Content: [], erc1271Policies: [] }, + actions: ctx.actions.map((a) => ({ + actionTargetSelector: a.selector, + actionTarget: a.target, + actionPolicies: [{ policy: ctx.rulesPolicy, initData: a.initData }], + })), + permitERC4337Paymaster: false, + }; +} + +export function encodeSession(session: SessionWire): Hex { + return encodeAbiParameters([SESSION_PARAM], [session]); +} + +export function decodeSession(bytes: Hex): SessionWire { + try { + const [session] = decodeAbiParameters([SESSION_PARAM], bytes); + return session as unknown as SessionWire; + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'malformed Session encoding', + { + cause, + } + ); + } +} diff --git a/packages/core/src/lib/agentic/codec/svm-accounts.ts b/packages/core/src/lib/agentic/codec/svm-accounts.ts new file mode 100644 index 000000000..75452b966 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/svm-accounts.ts @@ -0,0 +1,104 @@ +import { PublicKey } from '@solana/web3.js'; +import { + bytesToHex, + getAddress, + hexToBytes, + type Address, + type Hex, +} from 'viem'; +import { exactHex, SVM_LIMITS, svmInvalid } from './svm-terms'; +import type { AssetCapWire } from './universal-terms'; + +const SPL_TOKEN = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +const TOKEN_2022 = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'); +const ASSOCIATED_TOKEN = new PublicKey( + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' +); + +export function svmKey(value: string): Hex { + try { + if (value.startsWith('0x')) { + exactHex(value, 32, 'pubkey'); + return bytesToHex(new PublicKey(hexToBytes(value)).toBytes()); + } + return bytesToHex(new PublicKey(value).toBytes()); + } catch { + throw svmInvalid('invalid Solana public key'); + } +} +function key(value: string) { + return new PublicKey(hexToBytes(svmKey(value))); +} + +/** Gateway is supplied by the selected cluster registry, never a global hard-coded program. */ +export function deriveAgwSvmCea( + wallet: Address, + gatewayProgram: string +): { address: Hex; bump: number } { + if (key(gatewayProgram).equals(PublicKey.default)) + throw svmInvalid('gateway program cannot be zero'); + const [pda, bump] = PublicKey.findProgramAddressSync( + [Buffer.from('push_identity'), Buffer.from(hexToBytes(getAddress(wallet)))], + key(gatewayProgram) + ); + return { address: bytesToHex(pda.toBytes()), bump }; +} +export interface SvmTokenAccountInput { + mint: string; + tokenProgram?: string; +} +export type ResolvedSvmAsset = + | { kind: 'native'; cap: AssetCapWire } + | { kind: 'spl'; cap: AssetCapWire; mint: string; tokenProgram?: string }; + +/** Every resolved SPL cap contributes an ATA; caller cannot omit a listed mint. */ +export function resolveAgwSvmRuleContext( + wallet: Address, + gatewayProgram: string, + assets: readonly ResolvedSvmAsset[], + outputs: readonly SvmTokenAccountInput[] = [] +) { + if (!assets.length || assets.length > SVM_LIMITS.assets) + throw svmInvalid('resolved assets must contain 1..8 entries'); + const tokens = assets.flatMap((a) => + a.kind === 'spl' ? [{ mint: a.mint, tokenProgram: a.tokenProgram }] : [] + ); + return { + ...deriveAgwSvmValueAccounts(wallet, gatewayProgram, tokens, outputs), + assets: assets.map((a) => a.cap), + }; +} +export function deriveAgwSvmAta( + owner: string, + token: SvmTokenAccountInput +): Hex { + const mint = key(token.mint), + program = token.tokenProgram ? key(token.tokenProgram) : SPL_TOKEN; + if (mint.equals(PublicKey.default)) + throw svmInvalid('native SOL uses the CEA, not an ATA'); + if (!program.equals(SPL_TOKEN) && !program.equals(TOKEN_2022)) + throw svmInvalid('unsupported token program'); + const [ata] = PublicKey.findProgramAddressSync( + [key(owner).toBuffer(), program.toBuffer(), mint.toBuffer()], + ASSOCIATED_TOKEN + ); + return bytesToHex(ata.toBytes()); +} +/** Includes every listed mint and swap-output mint. Native SOL is already covered by the CEA. */ +export function deriveAgwSvmValueAccounts( + wallet: Address, + gatewayProgram: string, + tokens: readonly SvmTokenAccountInput[], + outputs: readonly SvmTokenAccountInput[] = [] +): { expectedCEA: Hex; gatewayProgram: Hex; ceaAccounts: Hex[] } { + const expectedCEA = deriveAgwSvmCea(wallet, gatewayProgram).address; + const ceaAccounts = [ + ...new Set([ + expectedCEA, + ...[...tokens, ...outputs].map((t) => deriveAgwSvmAta(expectedCEA, t)), + ]), + ]; + if (ceaAccounts.length > SVM_LIMITS.ceaAccounts) + throw svmInvalid('derived CEA value-account set exceeds 16'); + return { expectedCEA, gatewayProgram: svmKey(gatewayProgram), ceaAccounts }; +} diff --git a/packages/core/src/lib/agentic/codec/svm-idl.ts b/packages/core/src/lib/agentic/codec/svm-idl.ts new file mode 100644 index 000000000..4e7f22f44 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/svm-idl.ts @@ -0,0 +1,269 @@ +/** Compile named Anchor IDL constraints. Unsupported layouts are never guessed. */ +import type { Idl } from '@coral-xyz/anchor'; +import { bytesToHex, hexToBytes, type Hex } from 'viem'; +import type { SolanaRule, SvmAccountRef } from '../agentic.types'; +import { svmKey } from './svm-accounts'; +import { + SvmDataPinMode, + svmInvalid, + validateSvmTerms, + type SvmTermsWire, + type SvmDataPinWire, +} from './svm-terms'; + +export type SvmResolvedContext = Pick< + SvmTermsWire, + 'assets' | 'expectedCEA' | 'gatewayProgram' | 'ceaAccounts' +> & { + /** Verified mint -> wallet ATA mapping, including output mints. */ + tokenAccounts: ReadonlyMap; +}; +type Field = { offset: number; len: number; type: unknown }; +const widths: Record = { + bool: 1, + u8: 1, + i8: 1, + u16: 2, + i16: 2, + u32: 4, + i32: 4, + f32: 4, + u64: 8, + i64: 8, + f64: 8, + u128: 16, + i128: 16, + u256: 32, + i256: 32, + pubkey: 32, +}; +function width(type: unknown): number { + if (typeof type === 'string' && widths[type]) return widths[type]; + if (type && typeof type === 'object' && 'array' in type) { + const a = (type as { array: unknown }).array; + if ( + Array.isArray(a) && + a.length === 2 && + Number.isSafeInteger(a[1]) && + a[1] > 0 + ) { + const size = width(a[0]) * a[1]; + if (size <= 1024) return size; + } + } + throw svmInvalid( + 'IDL field layout is not supported: use fixed-width primitives or fixed arrays' + ); +} +export function svmIdlInstruction(idl: Idl, name: string) { + if (!idl || !Array.isArray(idl.instructions) || typeof name !== 'string') + throw svmInvalid('instruction requires an Anchor IDL and name'); + const found = idl.instructions.filter((i) => i.name === name); + if (found.length !== 1) + throw svmInvalid(`IDL instruction ${name} must exist exactly once`); + const ix = found[0]; + if ( + !Array.isArray(ix.discriminator) || + ix.discriminator.length !== 8 || + ix.discriminator.some((b) => !Number.isInteger(b) || b < 0 || b > 255) + ) + throw svmInvalid('Anchor IDL discriminator must contain eight bytes'); + return ix; +} +function accountNames(accounts: unknown[], prefix = ''): string[] { + return accounts.flatMap((entry) => { + if ( + !entry || + typeof entry !== 'object' || + !('name' in entry) || + typeof entry.name !== 'string' + ) + throw svmInvalid('invalid IDL account'); + const name = prefix + entry.name; + if ('accounts' in entry) + throw svmInvalid( + 'nested IDL accounts are not supported by the instruction resolver' + ); + if ('optional' in entry && entry.optional) + throw svmInvalid('optional IDL accounts are not supported for rules'); + return [name]; + }); +} +function resolveAccount(ref: SvmAccountRef, ctx: SvmResolvedContext): Hex { + if (!ref || typeof ref !== 'object') + throw svmInvalid('invalid named account reference'); + if (ref.kind === 'walletCEA') return ctx.expectedCEA; + if (ref.kind === 'address') return svmKey(ref.address); + if (ref.kind === 'walletATA') { + const ata = ctx.tokenAccounts.get(svmKey(ref.token)); + if (!ata) + throw svmInvalid( + 'walletATA mint must be listed in assets or outputTokens' + ); + return ata; + } + throw svmInvalid('unknown named account reference'); +} +function unsigned(f: Field): void { + if (typeof f.type !== 'string' || !/^u(8|16|32|64)$/.test(f.type)) + throw svmInvalid( + 'range/ratio constraints require unsigned integer fields of at most eight bytes' + ); +} +function bound(v: bigint, len: number): Hex { + if ( + typeof v !== 'bigint' || + v < BigInt(0) || + v >= BigInt(1) << BigInt(len * 8) + ) + throw svmInvalid('field constraint exceeds its unsigned width'); + return `0x${v.toString(16).padStart(64, '0')}`; +} +export function compileSvmIdlRule( + rule: SolanaRule, + ctx: SvmResolvedContext, + now: number +): SvmTermsWire { + if ( + !Array.isArray(rule.allowedInstructions) || + !rule.allowedInstructions.length || + rule.allowedInstructions.length > 32 + ) + throw svmInvalid('allowedInstructions must contain 1..32 entries'); + const terms: SvmTermsWire = { + validUntil: rule.validUntil, + maxGasPerCall: rule.maxGasPerCall, + assets: ctx.assets, + expectedCEA: ctx.expectedCEA, + gatewayProgram: ctx.gatewayProgram, + ceaAccounts: ctx.ceaAccounts, + programs: [], + pins: [], + dataPins: [], + }; + const programs = [], + pins = [], + dataPins: SvmDataPinWire[] = []; + for (const [ruleIndex, entry] of rule.allowedInstructions.entries()) { + const ix = svmIdlInstruction( + entry.instruction?.idl, + entry.instruction?.name + ); + const program = svmKey(entry.program); + if (svmKey(entry.instruction.idl.address) !== program) + throw svmInvalid('IDL address differs from rule program'); + if (!Array.isArray(ix.accounts) || !Array.isArray(entry.accounts)) + throw svmInvalid('instruction accounts are required'); + const names = accountNames(ix.accounts); + if (new Set(names).size !== names.length) + throw svmInvalid('duplicate IDL account names'); + programs.push({ + program, + discriminator: bytesToHex( + Uint8Array.from([ + ...ix.discriminator, + ...Array(8 - ix.discriminator.length).fill(0), + ]) + ), + discriminatorLen: ix.discriminator.length, + dataless: false, + maxAccounts: names.length, + }); + for (const a of entry.accounts) { + const accountIndex = names.indexOf(a.name); + if (accountIndex < 0) throw svmInvalid(`unknown IDL account ${a.name}`); + pins.push({ + ruleIndex, + accountIndex, + expected: resolveAccount(a.expected, ctx), + }); + } + const fields = new Map(); + let offset = ix.discriminator.length; + // Reject variable/nested-defined layouts rather than claiming a reliable offset. + for (const arg of ix.args) { + if (fields.has(arg.name)) throw svmInvalid('duplicate IDL field name'); + const len = width(arg.type); + fields.set(arg.name, { offset, len, type: arg.type }); + offset += len; + } + if (offset > 1024) + throw svmInvalid('IDL instruction data exceeds the policy limit'); + const field = (name: string) => { + const f = fields.get(name); + if (!f) throw svmInvalid(`unknown IDL field ${name}`); + return f; + }; + if (entry.fields !== undefined && !Array.isArray(entry.fields)) + throw svmInvalid('fields must be an array'); + for (const constraint of entry.fields ?? []) { + const keys = ['equals', 'min', 'max', 'minRatio'].filter( + (k) => k in constraint + ); + if (keys.length !== 1) + throw svmInvalid( + 'each field constraint must specify exactly one comparison' + ); + const f = field( + 'numerator' in constraint ? constraint.numerator : constraint.name + ); + const p: SvmDataPinWire = { + ruleIndex, + fromEnd: false, + offset: f.offset, + offsetB: 0, + len: f.len, + mode: SvmDataPinMode.EQ, + expected: `0x${'00'.repeat(32)}`, + num: BigInt(0), + den: BigInt(0), + }; + if ('minRatio' in constraint) { + unsigned(f); + const b = field(constraint.denominator); + unsigned(b); + if (f.len !== b.len) + throw svmInvalid('ratio fields must have the same width'); + Object.assign(p, { + mode: SvmDataPinMode.RATIO_GTE_LE, + offsetB: b.offset, + num: constraint.minRatio.num, + den: constraint.minRatio.den, + }); + } else if ('min' in constraint || 'max' in constraint) { + unsigned(f); + const min = 'min' in constraint; + p.mode = min ? SvmDataPinMode.GTE_LE : SvmDataPinMode.LTE_LE; + p.expected = bound(min ? constraint.min : constraint.max, f.len); + } else { + let bytes: Uint8Array; + if (typeof constraint.equals === 'bigint') { + unsigned(f); + bound(constraint.equals, f.len); + bytes = Uint8Array.from({ length: f.len }, (_, i) => + Number( + ((constraint.equals as bigint) >> BigInt(8 * i)) & BigInt(255) + ) + ); + } else if (typeof constraint.equals === 'boolean') { + if (f.type !== 'bool') + throw svmInvalid('boolean equality requires a bool field'); + bytes = Uint8Array.of(constraint.equals ? 1 : 0); + } else { + if (!/^0x(?:[a-fA-F0-9]{2})*$/.test(constraint.equals)) + throw svmInvalid('equality requires exact hex bytes'); + bytes = hexToBytes(constraint.equals); + } + if (bytes.length !== f.len || f.len > 32) + throw svmInvalid( + 'equality value must match field width (at most 32 bytes)' + ); + p.expected = `0x${bytesToHex(bytes).slice(2).padEnd(64, '0')}`; + } + dataPins.push(p); + } + } + Object.assign(terms, { programs, pins, dataPins }); + validateSvmTerms(terms, now); + return terms; +} diff --git a/packages/core/src/lib/agentic/codec/svm-terms.ts b/packages/core/src/lib/agentic/codec/svm-terms.ts new file mode 100644 index 000000000..dfae8ad73 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/svm-terms.ts @@ -0,0 +1,303 @@ +/** Internal v5 SVM wire representation; not a proposed public Rule API. */ +import { + decodeAbiParameters, + encodeAbiParameters, + isAddress, + type Hex, +} from 'viem'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { UINT256_MAX } from './defaults'; +import type { AssetCapWire } from './universal-terms'; + +export enum SvmDataPinMode { + EQ, + GTE_LE, + LTE_LE, + RATIO_GTE_LE, +} +export interface AllowedProgramWire { + program: Hex; + discriminator: Hex; + discriminatorLen: number; + dataless: boolean; + maxAccounts: number; +} +export interface SvmAccountPinWire { + ruleIndex: number; + accountIndex: number; + expected: Hex; +} +export interface SvmDataPinWire { + ruleIndex: number; + fromEnd: boolean; + offset: number; + offsetB: number; + len: number; + mode: SvmDataPinMode; + expected: Hex; + num: bigint; + den: bigint; +} +export interface SvmTermsWire { + validUntil: number; + expectedCEA: Hex; + gatewayProgram: Hex; + assets: readonly AssetCapWire[]; + maxGasPerCall: bigint; + ceaAccounts: readonly Hex[]; + programs: readonly AllowedProgramWire[]; + pins: readonly SvmAccountPinWire[]; + dataPins: readonly SvmDataPinWire[]; +} +export const SVM_LIMITS = { + assets: 8, + programs: 32, + pins: 16, + dataPins: 8, + ceaAccounts: 16, + accounts: 64, + ixData: 1024, +} as const; +export const UINT64_MAX = BigInt(2) ** BigInt(64) - BigInt(1); +export const FORBIDDEN_SVM_PROGRAMS: readonly Hex[] = [ + `0x${'00'.repeat(32)}`, + '0x06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9', + '0x06ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928d8a18bfc', + '0x06a1d8179137542a983437bdfe2a7ab2557f535c8a78722b68a49dc000000000', + '0x02a8f6914e88a1b0e210153ef763ae2b00c2b93d16c124d2c0537a1004800000', + '0x0277a6af97339b7ac88d1892c90446f50002309266f62e53c118244982000000', +]; +export const SVM_TERMS_PARAM = { + type: 'tuple', + components: [ + { name: 'validUntil', type: 'uint48' }, + { name: 'expectedCEA', type: 'bytes32' }, + { name: 'gatewayProgram', type: 'bytes32' }, + { + name: 'assets', + type: 'tuple[]', + components: [ + { name: 'token', type: 'address' }, + { name: 'maxPerCall', type: 'uint256' }, + { name: 'maxTotal', type: 'uint256' }, + ], + }, + { name: 'maxGasPerCall', type: 'uint256' }, + { name: 'ceaAccounts', type: 'bytes32[]' }, + { + name: 'programs', + type: 'tuple[]', + components: [ + { name: 'program', type: 'bytes32' }, + { name: 'discriminator', type: 'bytes8' }, + { name: 'discriminatorLen', type: 'uint8' }, + { name: 'dataless', type: 'bool' }, + { name: 'maxAccounts', type: 'uint8' }, + ], + }, + { + name: 'pins', + type: 'tuple[]', + components: [ + { name: 'ruleIndex', type: 'uint8' }, + { name: 'accountIndex', type: 'uint8' }, + { name: 'expected', type: 'bytes32' }, + ], + }, + { + name: 'dataPins', + type: 'tuple[]', + components: [ + { name: 'ruleIndex', type: 'uint8' }, + { name: 'fromEnd', type: 'bool' }, + { name: 'offset', type: 'uint16' }, + { name: 'offsetB', type: 'uint16' }, + { name: 'len', type: 'uint8' }, + { name: 'mode', type: 'uint8' }, + { name: 'expected', type: 'bytes32' }, + { name: 'num', type: 'uint64' }, + { name: 'den', type: 'uint64' }, + ], + }, + ], +} as const; + +export function svmInvalid(message: string): AgenticError { + return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message); +} +export function exactHex( + value: unknown, + bytes: number, + field: string +): asserts value is Hex { + if ( + typeof value !== 'string' || + !new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`).test(value) + ) + throw svmInvalid(`${field} must contain exactly ${bytes} bytes`); +} +function integer(value: number, max: number, field: string) { + if (!Number.isInteger(value) || value < 0 || value > max) + throw svmInvalid(`${field} is out of range`); +} +function uint(value: bigint, max: bigint, field: string) { + if (typeof value !== 'bigint' || value < BigInt(0) || value > max) + throw svmInvalid(`${field} is out of range`); +} +export function isForbiddenSvmProgram( + program: Hex, + terms: Pick +): boolean { + return [ + ...FORBIDDEN_SVM_PROGRAMS, + terms.gatewayProgram, + terms.expectedCEA, + ].some((x) => x.toLowerCase() === program.toLowerCase()); +} +function offsetValid(fromEnd: boolean, offset: number, len: number): boolean { + return fromEnd + ? offset >= len && offset <= SVM_LIMITS.ixData + : offset + len <= SVM_LIMITS.ixData; +} + +/** Grant-time structural rules. Chain/token/mint correctness requires resolved on-chain inputs. */ +export function validateSvmTerms(t: SvmTermsWire, nowSeconds: number): void { + integer(t.validUntil, 2 ** 48 - 1, 'validUntil'); + if (t.validUntil <= nowSeconds) + throw svmInvalid('validUntil must be in the future'); + exactHex(t.expectedCEA, 32, 'expectedCEA'); + exactHex(t.gatewayProgram, 32, 'gatewayProgram'); + if ( + BigInt(t.expectedCEA) === BigInt(0) || + BigInt(t.gatewayProgram) === BigInt(0) + ) + throw svmInvalid('CEA/gateway cannot be zero'); + uint(t.maxGasPerCall, UINT256_MAX, 'maxGasPerCall'); + if (!t.assets.length || t.assets.length > SVM_LIMITS.assets) + throw svmInvalid('assets must contain 1..8 entries'); + const tokens = new Set(); + for (const a of t.assets) { + if (!isAddress(a.token, { strict: false }) || BigInt(a.token) === BigInt(0)) + throw svmInvalid('asset token must be a non-zero PRC20'); + if (tokens.has(a.token.toLowerCase())) throw svmInvalid('duplicate asset'); + tokens.add(a.token.toLowerCase()); + uint(a.maxPerCall, UINT256_MAX, 'maxPerCall'); + uint(a.maxTotal, UINT256_MAX, 'maxTotal'); + } + if (!t.programs.length || t.programs.length > SVM_LIMITS.programs) + throw svmInvalid('programs must contain 1..32 entries'); + t.programs.forEach((r, i) => { + exactHex(r.program, 32, 'program'); + exactHex(r.discriminator, 8, 'discriminator'); + if (typeof r.dataless !== 'boolean') + throw svmInvalid('dataless must be boolean'); + integer(r.discriminatorLen, 8, 'discriminatorLen'); + integer(r.maxAccounts, SVM_LIMITS.accounts, 'maxAccounts'); + if (r.dataless !== (r.discriminatorLen === 0)) + throw svmInvalid('dataless and discriminator length disagree'); + if (isForbiddenSvmProgram(r.program, t)) + throw svmInvalid('forbidden program'); + for (const earlier of t.programs.slice(0, i)) { + if (earlier.program.toLowerCase() !== r.program.toLowerCase()) continue; + const overlap = + earlier.dataless || r.dataless + ? earlier.dataless && r.dataless + : earlier.discriminator + .slice( + 0, + 2 + 2 * Math.min(earlier.discriminatorLen, r.discriminatorLen) + ) + .toLowerCase() === + r.discriminator + .slice( + 0, + 2 + 2 * Math.min(earlier.discriminatorLen, r.discriminatorLen) + ) + .toLowerCase(); + if (overlap) + throw svmInvalid('ambiguous instruction discriminator prefixes'); + } + }); + if (t.pins.length > SVM_LIMITS.pins) + throw svmInvalid('too many account pins'); + const positions = new Set(), + covered = new Set(); + for (const p of t.pins) { + integer(p.ruleIndex, t.programs.length - 1, 'pin ruleIndex'); + integer(p.accountIndex, SVM_LIMITS.accounts - 1, 'accountIndex'); + exactHex(p.expected, 32, 'pin expected'); + const count = t.programs[p.ruleIndex].maxAccounts; + if (count && p.accountIndex >= count) + throw svmInvalid('pin outside fixed account count'); + const key = `${p.ruleIndex}:${p.accountIndex}`; + if (positions.has(key)) throw svmInvalid('duplicate account pin'); + positions.add(key); + covered.add(p.ruleIndex); + } + if (covered.size !== t.programs.length) + throw svmInvalid('every program rule needs an account pin'); + if (t.dataPins.length > SVM_LIMITS.dataPins) + throw svmInvalid('too many data pins'); + for (const p of t.dataPins) { + integer(p.ruleIndex, t.programs.length - 1, 'data pin ruleIndex'); + integer(p.mode, 3, 'data pin mode'); + integer(p.len, p.mode === SvmDataPinMode.EQ ? 32 : 8, 'data pin len'); + integer(p.offset, 65535, 'offset'); + integer(p.offsetB, 65535, 'offsetB'); + exactHex(p.expected, 32, 'data pin expected'); + uint(p.num, UINT64_MAX, 'num'); + uint(p.den, UINT64_MAX, 'den'); + if ( + typeof p.fromEnd !== 'boolean' || + !p.len || + t.programs[p.ruleIndex].dataless || + !offsetValid(p.fromEnd, p.offset, p.len) + ) + throw svmInvalid('invalid data pin shape'); + if ( + p.mode === SvmDataPinMode.EQ && + !/^0*$/.test(p.expected.slice(2 + p.len * 2)) + ) + throw svmInvalid('EQ expected must be left-aligned'); + if ( + (p.mode === SvmDataPinMode.GTE_LE || p.mode === SvmDataPinMode.LTE_LE) && + BigInt(p.expected) >= BigInt(1) << BigInt(p.len * 8) + ) + throw svmInvalid('integer expected exceeds field width'); + if ( + p.mode === SvmDataPinMode.RATIO_GTE_LE && + (!p.num || !p.den || !offsetValid(p.fromEnd, p.offsetB, p.len)) + ) + throw svmInvalid('invalid ratio pin'); + } + if (t.ceaAccounts.length > SVM_LIMITS.ceaAccounts) + throw svmInvalid('too many CEA accounts'); + const accounts = new Set(); + for (const key of t.ceaAccounts) { + exactHex(key, 32, 'CEA account'); + if ( + BigInt(key) === BigInt(0) || + accounts.has(key.toLowerCase()) || + t.programs.some((p) => p.program.toLowerCase() === key.toLowerCase()) + ) + throw svmInvalid('invalid CEA account list'); + accounts.add(key.toLowerCase()); + } + if (!accounts.has(t.expectedCEA.toLowerCase())) + throw svmInvalid('CEA account list must include expectedCEA'); +} +export function encodeSvmTerms(terms: SvmTermsWire, nowSeconds: number): Hex { + validateSvmTerms(terms, nowSeconds); + return encodeAbiParameters([SVM_TERMS_PARAM], [terms]); +} +export function decodeSvmTerms(body: Hex): SvmTermsWire { + try { + return decodeAbiParameters([SVM_TERMS_PARAM], body)[0]; + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'malformed SVM terms', + { cause } + ); + } +} diff --git a/packages/core/src/lib/agentic/codec/universal-terms.ts b/packages/core/src/lib/agentic/codec/universal-terms.ts new file mode 100644 index 000000000..4ac26304b --- /dev/null +++ b/packages/core/src/lib/agentic/codec/universal-terms.ts @@ -0,0 +1,60 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + type Address, + type Hex, +} from 'viem'; + +export interface AssetCapWire { + token: Address; + maxPerCall: bigint; + maxTotal: bigint; +} +export interface AllowedCallWire { + target: Address; + selector: Hex; + beneficiaryOffset: number; + hasBeneficiary: boolean; + maxValue: bigint; +} +export interface UniversalTermsWire { + validUntil: number; + expectedCEA: Address; + assets: readonly AssetCapWire[]; + maxGasPerCall: bigint; + allowedCalls: readonly AllowedCallWire[]; +} +export const UNIVERSAL_TERMS_PARAM = { + type: 'tuple', + components: [ + { name: 'validUntil', type: 'uint48' }, + { name: 'expectedCEA', type: 'address' }, + { + name: 'assets', + type: 'tuple[]', + components: [ + { name: 'token', type: 'address' }, + { name: 'maxPerCall', type: 'uint256' }, + { name: 'maxTotal', type: 'uint256' }, + ], + }, + { name: 'maxGasPerCall', type: 'uint256' }, + { + name: 'allowedCalls', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'selector', type: 'bytes4' }, + { name: 'beneficiaryOffset', type: 'uint16' }, + { name: 'hasBeneficiary', type: 'bool' }, + { name: 'maxValue', type: 'uint256' }, + ], + }, + ], +} as const; +export function encodeUniversalTerms(terms: UniversalTermsWire): Hex { + return encodeAbiParameters([UNIVERSAL_TERMS_PARAM], [terms]); +} +export function decodeUniversalTerms(body: Hex): UniversalTermsWire { + return decodeAbiParameters([UNIVERSAL_TERMS_PARAM], body)[0]; +} diff --git a/packages/core/src/lib/agentic/codec/universal.ts b/packages/core/src/lib/agentic/codec/universal.ts new file mode 100644 index 000000000..d27b48ffd --- /dev/null +++ b/packages/core/src/lib/agentic/codec/universal.ts @@ -0,0 +1,214 @@ +import { getAddress, isAddress, type Address } from 'viem'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { UniversalRule } from '../agentic.types'; +import type { RuleEncodeContext, PreparedRule } from './rules'; +import { argumentOffset, rawOffset } from './abi-layout'; +import { parseSelector } from './selectors'; +import { AGENTIC_DEFAULTS, UINT256_MAX, withDefault } from './defaults'; +import { buildSession, encodeEnvelope } from './session'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { + encodeUniversalTerms, + type AssetCapWire, + type UniversalTermsWire, +} from './universal-terms'; + +export const MAX_ASSETS = 8; +export const MAX_ALLOWED_CALLS = 32; +export interface ResolvedUniversalRule { + expectedCEA: Address; + assets: readonly AssetCapWire[]; +} + +const CAIP2 = /^(eip155:[0-9]+|solana:[1-9A-HJ-NP-Za-km-z]{32,44})$/; +const UINT48_MAX = 2 ** 48 - 1; +function cap(value: unknown, what: string): asserts value is bigint { + if (typeof value !== 'bigint' || value < BigInt(0) || value > UINT256_MAX) + throw invalid(`${what} must be a uint256 bigint`); +} +export function validateUniversalRuleShape( + rule: UniversalRule, + nowSeconds: number +): void { + if ( + typeof rule.chainNamespace !== 'string' || + !CAIP2.test(rule.chainNamespace) + ) + throw invalid('chainNamespace must be a CAIP-2 eip155/solana chain'); + if ( + !Number.isSafeInteger(rule.validUntil) || + rule.validUntil <= nowSeconds || + rule.validUntil > UINT48_MAX + ) + throw invalid('validUntil must be a future uint48 unix timestamp'); + if (!Array.isArray(rule.assets) || rule.assets.length > MAX_ASSETS) + throw invalid(`assets must be an array of at most ${MAX_ASSETS} caps`); + cap(rule.maxGasPerCall, 'maxGasPerCall'); + if (rule.maxGasPerCall === BigInt(0)) + throw invalid('maxGasPerCall must be positive'); + if ( + !Array.isArray(rule.allowedCalls) || + rule.allowedCalls.length < 1 || + rule.allowedCalls.length > MAX_ALLOWED_CALLS + ) + throw invalid(`allowedCalls must contain 1..${MAX_ALLOWED_CALLS} entries`); + const seen = new Set(); + rule.allowedCalls.forEach((call, i) => { + if (!isAddress(call.target, { strict: false })) + throw invalid(`allowedCalls[${i}].target is not an EVM address`); + const parsed = parseSelector(call.selector); + if (parsed.valueOnly) + throw invalid(`allowedCalls[${i}] cannot be value-only`); + if (call.beneficiary !== undefined && call.beneficiaryOffset !== undefined) + throw invalid('beneficiary and beneficiaryOffset are mutually exclusive'); + if (call.beneficiary !== undefined) { + argumentOffset( + parsed.inputs, + call.beneficiary, + `allowedCalls[${i}].beneficiary` + ); + if (parsed.inputs?.[call.beneficiary]?.type !== 'address') + throw invalid( + `allowedCalls[${i}].beneficiary must be an address argument` + ); + } + if (call.beneficiaryOffset !== undefined) + rawOffset(call.beneficiaryOffset, `allowedCalls[${i}].beneficiary`); + cap( + call.maxValue ?? AGENTIC_DEFAULTS.universal.allowedCallMaxValue.value, + `allowedCalls[${i}].maxValue` + ); + const key = `${call.target.toLowerCase()}:${parsed.selector}`; + if (seen.has(key)) + throw invalid(`allowedCalls[${i}] duplicates an earlier target/selector`); + seen.add(key); + }); + const tokens = new Set(); + rule.assets.forEach((a, i) => { + const address = typeof a.token === 'string' ? a.token : a.token?.address; + if (typeof address !== 'string') + throw invalid(`assets[${i}].token is missing an address`); + const key = address.toLowerCase(); + if (tokens.has(key)) + throw invalid(`assets[${i}] duplicates an earlier token`); + tokens.add(key); + cap(a.maxPerCall, `assets[${i}].maxPerCall`); + cap( + withDefault(a.maxTotal, AGENTIC_DEFAULTS.universal.assetMaxTotal), + `assets[${i}].maxTotal` + ); + }); +} + +export function encodeUniversalRule( + rule: UniversalRule, + ctx: RuleEncodeContext +): PreparedRule { + if (rule.chainNamespace.startsWith('solana:')) + requireCapability(ctx.capabilities, AgenticCapability.UNIVERSAL_SVM_RULES); + validateUniversalRuleShape(rule, ctx.nowSeconds); + requireCapability( + ctx.capabilities, + rule.chainNamespace.startsWith('solana:') + ? AgenticCapability.UNIVERSAL_SVM_RULES + : AgenticCapability.UNIVERSAL_EVM_RULES + ); + const resolved = ctx.universal?.get(rule); + if (!resolved || !ctx.gateway) + throw invalid( + 'universal encoding requires resolved wallet CEA, PRC20 assets and gateway context' + ); + if ( + resolved.assets.length < 1 || + resolved.assets.length > MAX_ASSETS || + new Set(resolved.assets.map((a) => a.token.toLowerCase())).size !== + resolved.assets.length + ) + throw invalid('resolved assets must be 1..8 distinct PRC20s'); + if ( + !isAddress(resolved.expectedCEA) || + /^0x0{40}$/i.test(resolved.expectedCEA) + ) + throw invalid('expectedCEA must be a non-zero wallet destination account'); + const terms: UniversalTermsWire = { + validUntil: rule.validUntil, + expectedCEA: getAddress(resolved.expectedCEA), + assets: resolved.assets, + maxGasPerCall: rule.maxGasPerCall, + allowedCalls: rule.allowedCalls.map((call) => ({ + target: getAddress(call.target), + selector: parseSelector(call.selector).selector, + hasBeneficiary: + call.beneficiary !== undefined || call.beneficiaryOffset !== undefined, + beneficiaryOffset: + call.beneficiaryOffset ?? + (call.beneficiary === undefined + ? 0 + : argumentOffset( + parseSelector(call.selector).inputs, + call.beneficiary, + 'beneficiary' + )), + maxValue: withDefault( + call.maxValue, + AGENTIC_DEFAULTS.universal.allowedCallMaxValue + ), + })), + }; + const agent = getAddress(rule.agent); + return { + rule, + agent, + chainNamespace: rule.chainNamespace, + universalTerms: terms, + session: buildSession({ + validator: ctx.validator, + agent, + rulesPolicy: ctx.rulesPolicy, + actions: [ + { + target: ctx.gateway, + selector: SEND_OUTBOUND_SELECTOR, + initData: encodeEnvelope( + rule.chainNamespace, + encodeUniversalTerms(terms) + ), + }, + ], + }), + }; +} + +/** Source token addresses must be resolved from each PRC20 before calling this. */ +export function universalTermsToRule( + terms: UniversalTermsWire, + agent: Address, + chainNamespace: UniversalRule['chainNamespace'], + tokens: readonly Address[] +): UniversalRule { + if (tokens.length !== terms.assets.length) + throw invalid('origin-token list does not match stored asset order'); + return { + agent, + chainNamespace, + validUntil: Number(terms.validUntil), + maxGasPerCall: terms.maxGasPerCall, + assets: terms.assets.map((a, i) => ({ + token: tokens[i], + maxPerCall: a.maxPerCall, + maxTotal: a.maxTotal, + })), + allowedCalls: terms.allowedCalls.map((c) => ({ + target: c.target, + selector: c.selector, + maxValue: c.maxValue, + ...(c.hasBeneficiary + ? { beneficiaryOffset: Number(c.beneficiaryOffset) } + : {}), + })), + }; +} +function invalid(message: string): AgenticError { + return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message); +} diff --git a/packages/core/src/lib/agentic/constants.ts b/packages/core/src/lib/agentic/constants.ts new file mode 100644 index 000000000..f2f7d24b2 --- /dev/null +++ b/packages/core/src/lib/agentic/constants.ts @@ -0,0 +1,23 @@ +import { PUSH_NETWORK } from '../constants/enums'; +import { DONUT_V5_ADDRESSES } from './contracts/donut-v5'; + +/** Checked Donut v5 proxies. Unsupported networks are absent. */ +export interface AgenticNetworkConstants { + FACTORY: `0x${string}`; + RULES_POLICY: `0x${string}`; + MAX_PINS: number; + MAX_ALLOWED_CALLS: number; + ENVELOPE_VERSION: number; +} + +export const AGENTIC: Readonly< + Partial> +> = Object.freeze({ + [PUSH_NETWORK.TESTNET_DONUT]: Object.freeze({ + FACTORY: DONUT_V5_ADDRESSES.factory, + RULES_POLICY: DONUT_V5_ADDRESSES.rulesPolicy, + MAX_PINS: 8, + MAX_ALLOWED_CALLS: 32, + ENVELOPE_VERSION: 1, + }), +}); diff --git a/packages/core/src/lib/agentic/context.ts b/packages/core/src/lib/agentic/context.ts new file mode 100644 index 000000000..fc1f82031 --- /dev/null +++ b/packages/core/src/lib/agentic/context.ts @@ -0,0 +1,78 @@ +import { getAddress, type Address } from 'viem'; +import { CHAIN } from '../constants/enums'; +import { AgenticCapability, requireCapability } from './capabilities'; +import { resolveWalletGeneration, type AgenticGeneration } from './deployments'; +import { AGENTIC_ERROR_CODE, AgenticError } from './errors'; +import { readActiveRules } from './reads/rules'; +import { Snapshot } from './reads/snapshot'; +import type { AgenticRuntime } from './runtime'; +import type { AgenticDoor } from './agentic.types'; + +/** + * Identity of an agentic client. Three values stay separate: + * - signerOrigin: the key that signs (universal.origin) + * - signerPushAccount: its Push identity — EOA or UEA for its origin chain; + * pays Push gas and is the identity ownership checks use + * - wallet: the AGW, the execution account (universal.account) + */ +export interface AgenticExecutionContext { + wallet: Address; + door: AgenticDoor; + signerPushAccount: Address; + signerOrigin: { chain: CHAIN; address: string }; + generation: AgenticGeneration; +} + +/** + * Initialization-time resolution for `initialize(signer, { agenticWallet })`. + * Owner → owner door. Otherwise at least one ENABLED rule must name the + * signer's Push identity (expired-but-enabled still qualifies; expiry is an + * on-chain send check). Nothing here is cached for later sends. + */ +export async function resolveAgenticContext( + runtime: AgenticRuntime, + walletInput: string +): Promise { + let wallet: Address; + try { + wallet = getAddress(walletInput); + } catch (cause) { + throw new AgenticError(AGENTIC_ERROR_CODE.NOT_AGENTIC_WALLET, `"${walletInput}" is not an address`, { + cause, + }); + } + const generation = await resolveWalletGeneration(runtime.reader, runtime.network, wallet); + const signerPushAccount = runtime.signerPushAccount(); + const snap = await Snapshot.at(runtime.reader); + const owner = getAddress( + await snap.read
(wallet, generation.contracts.abis.wallet, 'owner') + ); + let door: AgenticDoor; + if (owner === signerPushAccount) { + requireCapability(generation.capabilities, AgenticCapability.OWNER_EXECUTE); + door = 'owner'; + } else { + requireCapability(generation.capabilities, AgenticCapability.AGENT_EXECUTE); + const active = await readActiveRules(snap, generation, wallet); + if (!active.some((r) => r.agent === signerPushAccount)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_OWNER_OR_AGENT, + `${signerPushAccount} is neither the owner of ${wallet} nor the agent of an enabled rule`, + { + hint: runtime.signerIsPushNative() + ? 'Grant a rule to this Push address, or initialize with the owner signer.' + : 'An external key acts through its UEA for the chain it was initialized on; grant the rule to that UEA (PushChain.utils.account.deriveExecutorAccount).', + details: { wallet, signerPushAccount, owner }, + } + ); + } + door = 'agent'; + } + return { + wallet, + door, + signerPushAccount, + signerOrigin: runtime.signerOrigin(), + generation, + }; +} diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/agent-validator.ts b/packages/core/src/lib/agentic/contracts/abi/v5/agent-validator.ts new file mode 100644 index 000000000..069206f63 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/abi/v5/agent-validator.ts @@ -0,0 +1,145 @@ +// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. +// Source: packages/core/scripts/agw-local/abis/validator.json +// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb +// sha256(AgentValidator.json) = a4570ddc373241580de8742cbb3d9f2c837094c65bfd1cdd52243ca0c38196fd +export const AGENT_VALIDATOR_ABI = [ + { + "type": "function", + "name": "isInitialized", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "isModuleType", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onInstall", + "inputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onUninstall", + "inputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "validateConfig", + "inputs": [ + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "validateSignatureWithData", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "validSig", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "error", + "name": "AlreadyInitialized", + "inputs": [ + { + "name": "smartAccount", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "MalformedConfig", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitialized", + "inputs": [ + { + "name": "smartAccount", + "type": "address", + "internalType": "address" + } + ] + } +] as const; diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/agw-factory.ts b/packages/core/src/lib/agentic/contracts/abi/v5/agw-factory.ts new file mode 100644 index 000000000..45519e583 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/abi/v5/agw-factory.ts @@ -0,0 +1,1104 @@ +// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. +// Source: packages/core/scripts/agw-local/abis/factory.json +// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb +// sha256(AGWFactory.json) = b65f66a1556d2a9ddf9aabe9f595c685c2aada436772892f5a40fb1eec846397 +export const AGW_FACTORY_ABI = [ + { + "type": "function", + "name": "DEFAULT_ADMIN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "OPERATOR_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PAUSER_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UPGRADE_INTERFACE_VERSION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "acceptDefaultAdminTransfer", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "beginDefaultAdminTransfer", + "inputs": [ + { + "name": "newAdmin", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "cancelDefaultAdminTransfer", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "changeDefaultAdminDelay", + "inputs": [ + { + "name": "newDelay", + "type": "uint48", + "internalType": "uint48" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "defaultAdmin", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "defaultAdminDelay", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "defaultAdminDelayIncreaseWait", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "deployWallet", + "inputs": [ + { + "name": "label", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "deployWalletWithSig", + "inputs": [ + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "label", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "domainSeparator", + "inputs": [ + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "indexOf", + "inputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "admin", + "type": "address", + "internalType": "address" + }, + { + "name": "walletImplementation_", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isWallet", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "ownerOf", + "inputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pendingDefaultAdmin", + "inputs": [], + "outputs": [ + { + "name": "newAdmin", + "type": "address", + "internalType": "address" + }, + { + "name": "schedule", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pendingDefaultAdminDelay", + "inputs": [], + "outputs": [ + { + "name": "newDelay", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "schedule", + "type": "uint48", + "internalType": "uint48" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "predictWallet", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "deployed", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "proxiableUUID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "rollbackDefaultAdminDelay", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "unpause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeToAndCall", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "walletCount", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "walletImplementation", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "DefaultAdminDelayChangeCanceled", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "DefaultAdminDelayChangeScheduled", + "inputs": [ + { + "name": "newDelay", + "type": "uint48", + "indexed": false, + "internalType": "uint48" + }, + { + "name": "effectSchedule", + "type": "uint48", + "indexed": false, + "internalType": "uint48" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "DefaultAdminTransferCanceled", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "DefaultAdminTransferScheduled", + "inputs": [ + { + "name": "newAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "acceptSchedule", + "type": "uint48", + "indexed": false, + "internalType": "uint48" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleAdminChanged", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "previousAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleGranted", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleRevoked", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Upgraded", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "WalletDeployed", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "index", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "wallet", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "label", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AccessControlBadConfirmation", + "inputs": [] + }, + { + "type": "error", + "name": "AccessControlEnforcedDefaultAdminDelay", + "inputs": [ + { + "name": "schedule", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "AccessControlEnforcedDefaultAdminRules", + "inputs": [] + }, + { + "type": "error", + "name": "AccessControlInvalidDefaultAdmin", + "inputs": [ + { + "name": "defaultAdmin", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AccessControlUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "neededRole", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AddressEmptyCode", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CloneArgumentsTooLong", + "inputs": [] + }, + { + "type": "error", + "name": "Create2EmptyBytecode", + "inputs": [] + }, + { + "type": "error", + "name": "ERC1967InvalidImplementation", + "inputs": [ + { + "name": "implementation", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ERC1967NonPayable", + "inputs": [] + }, + { + "type": "error", + "name": "EnforcedPause", + "inputs": [] + }, + { + "type": "error", + "name": "ExecutorMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ExpectedPause", + "inputs": [] + }, + { + "type": "error", + "name": "FailedCall", + "inputs": [] + }, + { + "type": "error", + "name": "FailedDeployment", + "inputs": [] + }, + { + "type": "error", + "name": "ImplementationNotSet", + "inputs": [] + }, + { + "type": "error", + "name": "IndexMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "provided", + "type": "uint96", + "internalType": "uint96" + } + ] + }, + { + "type": "error", + "name": "IndexOutOfRange", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "next", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [ + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "IntentWalletMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "provided", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidOwnerSignature", + "inputs": [] + }, + { + "type": "error", + "name": "NotAWallet", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "SafeCastOverflowedUintDowncast", + "inputs": [ + { + "name": "bits", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SignatureExpired", + "inputs": [ + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "UUPSUnauthorizedCallContext", + "inputs": [] + }, + { + "type": "error", + "name": "UUPSUnsupportedProxiableUUID", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] as const; diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/agw.ts b/packages/core/src/lib/agentic/contracts/abi/v5/agw.ts new file mode 100644 index 000000000..d3bed3290 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/abi/v5/agw.ts @@ -0,0 +1,1584 @@ +// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. +// Source: packages/core/scripts/agw-local/abis/wallet.json +// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb +// sha256(AGW.json) = 6a145964d6c43eaa6223dc8f3d59a540ac0c2f447f25c3a99007eea25de9c695 +export const AGW_ABI = [ + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "RULES_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SESSION_ENGINE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SESSION_VALIDATOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNIVERSAL_GATEWAY_PC", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "accountId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "agentOf", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "checkpointCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "domainSeparator", + "inputs": [ + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "execute", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "executionCalldata", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "executeAsAgent", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "executionCalldata", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "executeWithSig", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "executionCalldata", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "factory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getNonce", + "inputs": [ + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + } + ], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantNonce", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantRules", + "inputs": [ + { + "name": "session", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "grantRulesWithSig", + "inputs": [ + { + "name": "session", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initializeAccount", + "inputs": [ + { + "name": "label_", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "installModule", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isModuleInstalled", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isValidSignature", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "label", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastCheckpointBlock", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "onERC1155BatchReceived", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onERC1155Received", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "onERC721Received", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "revokeAllRules", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRules", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setLabel", + "inputs": [ + { + "name": "label_", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsExecutionMode", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "supportsModule", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "uninstallModule", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "internalType": "address" + }, + { + "name": "deInitData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "AccountInitialized", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "engine", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Checkpointed", + "inputs": [ + { + "name": "seq", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + }, + { + "name": "kind", + "type": "uint8", + "indexed": false, + "internalType": "enum CheckpointKind" + }, + { + "name": "ref", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "blockNumber", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "LabelSet", + "inputs": [ + { + "name": "label", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ModuleInstalled", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ModuleUninstalled", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "module", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnerExecuted", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "executionCalldataHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnerExecutedWithSig", + "inputs": [ + { + "name": "mode", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "executionCalldataHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "indexed": false, + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesActionAuthorized", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "agent", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "callsHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesGranted", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "uint8", + "indexed": false, + "internalType": "enum RulesType" + }, + { + "name": "chainHash", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "chainNamespace", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesRevoked", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "UninstallCallbackFailed", + "inputs": [ + { + "name": "module", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AlreadyInitialized", + "inputs": [] + }, + { + "type": "error", + "name": "CallerIsNotAgent", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "caller", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CallerIsNotFactory", + "inputs": [] + }, + { + "type": "error", + "name": "CallerIsNotOwner", + "inputs": [] + }, + { + "type": "error", + "name": "DuplicateAction", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "EmptyChain", + "inputs": [] + }, + { + "type": "error", + "name": "EngineStillHoldsPermissions", + "inputs": [] + }, + { + "type": "error", + "name": "ExecutorMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ForbiddenActionSelector", + "inputs": [ + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "ForbiddenActionTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ForbiddenDispatchTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InconsistentChain", + "inputs": [ + { + "name": "actionIndex", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "IntentExecMismatch", + "inputs": [ + { + "name": "actualCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "IntentGrantNonceMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "provided", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "IntentSessionMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "IntentWalletMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "provided", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidModuleAddress", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidNonce", + "inputs": [ + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "expected", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "provided", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "InvalidOwnerSignature", + "inputs": [] + }, + { + "type": "error", + "name": "LabelTooLong", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "MalformedBatchCalldata", + "inputs": [] + }, + { + "type": "error", + "name": "MalformedSessionShape", + "inputs": [] + }, + { + "type": "error", + "name": "ModuleAlreadyInstalled", + "inputs": [ + { + "name": "module", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OutsideTimeWindow", + "inputs": [ + { + "name": "validAfter", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "OwnerLaneRequired", + "inputs": [ + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + } + ] + }, + { + "type": "error", + "name": "OwnerSigExpired", + "inputs": [ + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "RulesTypeMismatch", + "inputs": [ + { + "name": "derived", + "type": "uint8", + "internalType": "enum RulesType" + }, + { + "name": "actionIndex", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "TooManyActions", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "UnknownPermission", + "inputs": [ + { + "name": "rulesId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "UnsupportedExecutionMode", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedModuleType", + "inputs": [ + { + "name": "moduleTypeId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ValidationFailed", + "inputs": [ + { + "name": "authorizer", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ValidatorNotInstalled", + "inputs": [ + { + "name": "validator", + "type": "address", + "internalType": "address" + } + ] + } +] as const; diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/index.ts b/packages/core/src/lib/agentic/contracts/abi/v5/index.ts new file mode 100644 index 000000000..fa7ddd6ae --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/abi/v5/index.ts @@ -0,0 +1,8 @@ +// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. +// Review generation v5: Donut v5 deployment; source 2e61e13. +export const V5_SOURCE_COMMIT = '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb' as const; +export { AGW_ABI } from './agw'; +export { AGW_FACTORY_ABI } from './agw-factory'; +export { UNIVERSAL_RULES_POLICY_ABI } from './universal-rules-policy'; +export { SMART_SESSION_ABI } from './smart-session'; +export { AGENT_VALIDATOR_ABI } from './agent-validator'; diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/smart-session.ts b/packages/core/src/lib/agentic/contracts/abi/v5/smart-session.ts new file mode 100644 index 000000000..20e207b12 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/abi/v5/smart-session.ts @@ -0,0 +1,1961 @@ +// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. +// Source: packages/core/scripts/agw-local/abis/engine.json +// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb +// sha256(SmartSession.json) = 6e5d9cea4dbed01f06af913c1e093743b86b7432195f4a4bf90fb94b282c51b2 +export const SMART_SESSION_ABI = [ + { + "type": "function", + "name": "areActionsEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "areERC1271PoliciesEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "areUserOpPoliciesEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "disableActionId", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "disableActionPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + }, + { + "name": "policies", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "disableERC1271Policies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policies", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "contexts", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "disableUserOpPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policies", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableActionPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableERC1271Policies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "erc1271Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableSessions", + "inputs": [ + { + "name": "sessions", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "permissionIds", + "type": "bytes32[]", + "internalType": "PermissionId[]" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "enableUserOpPolicies", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getActionPolicies", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getERC1271Policies", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEnabledActions", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEnabledERC7739Content", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "enabledERC7739ContentHashes", + "type": "tuple[]", + "internalType": "struct ERC7739ContextHashes[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNameHashes", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getNonce", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPermissionIDs", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "permissionIds", + "type": "bytes32[]", + "internalType": "PermissionId[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getPermissionId", + "inputs": [ + { + "name": "session", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "getSessionDigest", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "tuple", + "internalType": "struct Session", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "mode", + "type": "uint8", + "internalType": "enum SmartSessionMode" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getSessionValidatorAndConfig", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "address" + }, + { + "name": "sessionValidatorData", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getUserOpPolicies", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isActionIdEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isActionPolicyEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "internalType": "ActionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isERC1271PolicyEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isERC7739ContentEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "content", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isISessionValidatorSet", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isInitialized", + "inputs": [ + { + "name": "smartAccount", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isModuleType", + "inputs": [ + { + "name": "typeID", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "isPermissionEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isUserOpPolicyEnabled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isValidSignatureWithSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "hash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "signature", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "result", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "onInstall", + "inputs": [ + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "onUninstall", + "inputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "removeSession", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeEnableSignature", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPermit4337Paymaster", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "enabled", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "validateUserOp", + "inputs": [ + { + "name": "userOp", + "type": "tuple", + "internalType": "struct PackedUserOperation", + "components": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "initCode", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "callData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "accountGasLimits", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "preVerificationGas", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "gasFees", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "paymasterAndData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "signature", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "userOpHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "vd", + "type": "uint256", + "internalType": "ValidationData" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "ActionIdDisabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "actionId", + "type": "bytes32", + "indexed": false, + "internalType": "ActionId" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "NonceIterated", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "newValue", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PermissionIdPermit4337Paymaster", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "enabled", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyDisabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "policyType", + "type": "uint8", + "indexed": false, + "internalType": "enum PolicyType" + }, + { + "name": "policy", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "policyType", + "type": "uint8", + "indexed": false, + "internalType": "enum PolicyType" + }, + { + "name": "policy", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionCreated", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionRemoved", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionValidatorDisabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "sessionValidator", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SessionValidatorEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "indexed": false, + "internalType": "PermissionId" + }, + { + "name": "sessionValidator", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "smartAccount", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AssociatedArray_OutOfBounds", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ChainIdMismatch", + "inputs": [ + { + "name": "providedChainId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "ChainIdMismatch", + "inputs": [ + { + "name": "providedChainId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "ForbiddenValidationData", + "inputs": [] + }, + { + "type": "error", + "name": "HashIndexOutOfBounds", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "HashMismatch", + "inputs": [ + { + "name": "providedHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "computedHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "HashMismatch", + "inputs": [ + { + "name": "providedHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "computedHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidActionId", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidCallTarget", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidData", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidEnableSignature", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "hash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidISessionValidator", + "inputs": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + } + ] + }, + { + "type": "error", + "name": "InvalidMode", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidPermissionId", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "InvalidSelfCall", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSession", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "InvalidSessionKeySignature", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "sessionValidator", + "type": "address", + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "userOpHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidTarget", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidUserOpSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NoExecutionsInBatch", + "inputs": [] + }, + { + "type": "error", + "name": "NoPoliciesSet", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "PartlyEnabledActions", + "inputs": [] + }, + { + "type": "error", + "name": "PartlyEnabledPolicies", + "inputs": [] + }, + { + "type": "error", + "name": "PaymasterValidationNotEnabled", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + } + ] + }, + { + "type": "error", + "name": "PolicyViolation", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SignerNotFound", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SignerNotFound", + "inputs": [ + { + "name": "permissionId", + "type": "bytes32", + "internalType": "PermissionId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SmartSessionModuleAlreadyInstalled", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UnsupportedCallType", + "inputs": [ + { + "name": "callType", + "type": "bytes1", + "internalType": "CallType" + } + ] + }, + { + "type": "error", + "name": "UnsupportedExecutionType", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedPolicy", + "inputs": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UnsupportedSmartSessionMode", + "inputs": [ + { + "name": "mode", + "type": "uint8", + "internalType": "enum SmartSessionMode" + } + ] + } +] as const; diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/universal-rules-policy.ts b/packages/core/src/lib/agentic/contracts/abi/v5/universal-rules-policy.ts new file mode 100644 index 000000000..5b0e2f34b --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/abi/v5/universal-rules-policy.ts @@ -0,0 +1,2156 @@ +// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. +// Source: packages/core/scripts/agw-local/abis/policy.json +// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb +// sha256(UniversalRulesPolicy.json) = c7e3cd5c7f8e47be217c77ebebde4c090903cea7db63024838e434eb6f1fd77b +export const UNIVERSAL_RULES_POLICY_ABI = [ + { + "type": "function", + "name": "SEND_OUTBOUND_SELECTOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SESSION_ENGINE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNIVERSAL_EXECUTOR_MODULE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNIVERSAL_GATEWAY_PC", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "assertSpent", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "expectedValueSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expectedAmountSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expectedCalls", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "assertSpent", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "expectedSpent", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "checkAction", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "creditRevert", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "outboundTxId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getConfig", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct Config", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "expectedCEA", + "type": "address", + "internalType": "address" + }, + { + "name": "maxGasPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "assets", + "type": "tuple[]", + "internalType": "struct AssetCapState[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spent", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "allowedCalls", + "type": "tuple[]", + "internalType": "struct AllowedCall[]", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "beneficiaryOffset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "hasBeneficiary", + "type": "bool", + "internalType": "bool" + }, + { + "name": "maxValue", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getMode", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct ModeSlot", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "mode", + "type": "uint8", + "internalType": "enum RulesType" + }, + { + "name": "vm", + "type": "uint8", + "internalType": "enum VmFamily" + }, + { + "name": "chainHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getNativeConfig", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct NativeConfig", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "maxValuePerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxValueTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "valueSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amount", + "type": "tuple", + "internalType": "struct AmountRule", + "components": [ + { + "name": "enabled", + "type": "bool", + "internalType": "bool" + }, + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxTotal", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "amountSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxCalls", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "callsUsed", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "pins", + "type": "tuple[]", + "internalType": "struct ArgPin[]", + "components": [ + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getSvmConfig", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct SvmConfig", + "components": [ + { + "name": "initialized", + "type": "bool", + "internalType": "bool" + }, + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "expectedCEA", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "gatewayProgram", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "maxGasPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "assets", + "type": "tuple[]", + "internalType": "struct AssetCapState[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxPerCall", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "spent", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "ceaAccounts", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "programs", + "type": "tuple[]", + "internalType": "struct AllowedProgram[]", + "components": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "discriminator", + "type": "bytes8", + "internalType": "bytes8" + }, + { + "name": "discriminatorLen", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "dataless", + "type": "bool", + "internalType": "bool" + }, + { + "name": "maxAccounts", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "name": "pins", + "type": "tuple[]", + "internalType": "struct SvmAccountPin[]", + "components": [ + { + "name": "ruleIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "dataPins", + "type": "tuple[]", + "internalType": "struct SvmDataPin[]", + "components": [ + { + "name": "ruleIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "fromEnd", + "type": "bool", + "internalType": "bool" + }, + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "offsetB", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "len", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "mode", + "type": "uint8", + "internalType": "enum SvmDataPinMode" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "num", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "den", + "type": "uint64", + "internalType": "uint64" + } + ] + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "universalGatewayPC", + "type": "address", + "internalType": "address" + }, + { + "name": "universalExecutorModule", + "type": "address", + "internalType": "address" + }, + { + "name": "sessionEngine", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initializeWithMultiplexer", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "configId", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isCredited", + "inputs": [ + { + "name": "outboundTxId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pushChainHash", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "iid", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "NativeCallMetered", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OutboundMetered", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicySet", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": false, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RevertCredited", + "inputs": [ + { + "name": "outboundTxId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "amountApplied", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RulesConfigured", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "indexed": true, + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "mode", + "type": "uint8", + "indexed": false, + "internalType": "enum RulesType" + }, + { + "name": "vm", + "type": "uint8", + "indexed": false, + "internalType": "enum VmFamily" + }, + { + "name": "chainHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AllowListOutOfRange", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AlreadyCredited", + "inputs": [ + { + "name": "outboundTxId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AlreadyInitialized", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + } + ] + }, + { + "type": "error", + "name": "AmbiguousRule", + "inputs": [ + { + "name": "i", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "j", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AmountExceedsCap", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AmountExceedsU64", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ArgPinMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AssetListOutOfRange", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "AssetNotAllowed", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AssetSpentMismatch", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "BatchSizeOutOfRange", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "BeneficiaryMismatch", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CallLimitReached", + "inputs": [ + { + "name": "callsUsed", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "maxCalls", + "type": "uint32", + "internalType": "uint32" + } + ] + }, + { + "type": "error", + "name": "CallNotAllowed", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "CalldataTooShort", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "CalldataTooShortForAmount", + "inputs": [ + { + "name": "actualLength", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "CalldataTooShortForPin", + "inputs": [ + { + "name": "actualLength", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "CallerIsNotUEModule", + "inputs": [ + { + "name": "caller", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "CeaAccountAtUnpinnedIndex", + "inputs": [ + { + "name": "accountIndex", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "account", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "CeaAccountsMissExpectedCEA", + "inputs": [] + }, + { + "type": "error", + "name": "ChainMismatch", + "inputs": [ + { + "name": "declared", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "assetChain", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "DiscriminatorLenOutOfRange", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "len", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "DuplicateAsset", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "DuplicateSvmPin", + "inputs": [ + { + "name": "first", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "second", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "EmptyChain", + "inputs": [] + }, + { + "type": "error", + "name": "ForbiddenInnerTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ForbiddenProgramInAllowList", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ForbiddenTargetProgram", + "inputs": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InnerValueExceedsAllowance", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxValue", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidAsset", + "inputs": [ + { + "name": "asset", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidCeaAccount", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidConfigField", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidExpiry", + "inputs": [ + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidRevertRecipient", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSelector", + "inputs": [ + { + "name": "selector", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "InvalidSvmConfigField", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidTarget", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "MalformedInnerCalldata", + "inputs": [] + }, + { + "type": "error", + "name": "MalformedOutboundRequest", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "MalformedSvmPayload", + "inputs": [ + { + "name": "code", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "NativeAmountExceedsCap", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "NativeTargetIsGateway", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NativeTargetZero", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitialized", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "PCValueExceedsCap", + "inputs": [ + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "PayloadNotMulticall", + "inputs": [] + }, + { + "type": "error", + "name": "PolicyNotInitialized", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "ConfigId" + }, + { + "name": "multiplexer", + "type": "address", + "internalType": "address" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ProgramListOutOfRange", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ProgramNotAllowed", + "inputs": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "discriminator", + "type": "bytes8", + "internalType": "bytes8" + } + ] + }, + { + "type": "error", + "name": "RecipientMustBeEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "RecipientNotPubkey", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "RecipientTargetMismatch", + "inputs": [ + { + "name": "recipient", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "target", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "RuleWithoutPin", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "RulesExpired", + "inputs": [ + { + "name": "validUntil", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "type": "error", + "name": "SelectorMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "expected", + "type": "bytes4", + "internalType": "bytes4" + } + ] + }, + { + "type": "error", + "name": "SpentLengthMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SpentMismatch", + "inputs": [ + { + "name": "expected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmAccountCountBelowPin", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmAccountCountMismatch", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmAccountPinMismatch", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "SvmAccountsOutOfRange", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataCeilingExceeded", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "ceiling", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataFloorNotMet", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "floor", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataPinInvalid", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmDataPinMismatch", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "SvmDataRatioNotMet", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "a", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "b", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "num", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "den", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "SvmDataTooShortForPin", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "ixLen", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmInstructionNotExecute", + "inputs": [ + { + "name": "instructionId", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmIxDataTooLong", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "SvmMaxAccountsOutOfRange", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxAccounts", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmPayloadEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "SvmPinIndexOutOfRange", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "SvmPinRuleOutOfRange", + "inputs": [ + { + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "rule", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "TargetMismatch", + "inputs": [ + { + "name": "actual", + "type": "address", + "internalType": "address" + }, + { + "name": "expected", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "TooManyCeaAccounts", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TooManyPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TooManySvmDataPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TooManySvmPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalNativeAmountExceeded", + "inputs": [ + { + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalSpendCapExceeded", + "inputs": [ + { + "name": "wouldBeTotal", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalValueExceeded", + "inputs": [ + { + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "UncappedGasSwapRejected", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedEnvelopeVersion", + "inputs": [ + { + "name": "version", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "UnsupportedNamespace", + "inputs": [ + { + "name": "chainHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ValueExceedsCap", + "inputs": [ + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ValueOnlyCalldataNotEmpty", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ValueOnlyWithAmountRule", + "inputs": [] + }, + { + "type": "error", + "name": "ValueOnlyWithPins", + "inputs": [] + }, + { + "type": "error", + "name": "WrongModeForCall", + "inputs": [ + { + "name": "actual", + "type": "uint8", + "internalType": "enum RulesType" + } + ] + }, + { + "type": "error", + "name": "WrongVmForCall", + "inputs": [ + { + "name": "actual", + "type": "uint8", + "internalType": "enum VmFamily" + } + ] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] as const; diff --git a/packages/core/src/lib/agentic/contracts/donut-v5.ts b/packages/core/src/lib/agentic/contracts/donut-v5.ts new file mode 100644 index 000000000..c75425ea6 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/donut-v5.ts @@ -0,0 +1,13 @@ +import type { Address } from 'viem'; + +/** Source 2e61e13; code/proxy wiring checked at Donut block 23991912. */ +export const V5_SOURCE_COMMIT = '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb'; +export const DONUT_V5_START_BLOCK = BigInt(23989983); +export const DONUT_V5_ADDRESSES = { + factory: '0x8137F96A50EBF41d904e3678c84c391a0D1BCcc5', + walletImplementation: '0x4D459Da499C14548aa16c46c57fD92880A88EBb4', + sessionEngine: '0x165A5E6782f39D30B38c7D97e1303e4CB2aD102a', + rulesPolicy: '0x603E7f0aF6e1aAFf46DDfb28b1e99364f8BC59af', + sessionValidator: '0x068EE2388475A98EE1f5a434C58bFF3444fffFe6', + gateway: '0x00000000000000000000000000000000000000C1', +} as const satisfies Record; diff --git a/packages/core/src/lib/agentic/contracts/prc20-metadata.ts b/packages/core/src/lib/agentic/contracts/prc20-metadata.ts new file mode 100644 index 000000000..332bb0b62 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/prc20-metadata.ts @@ -0,0 +1,128 @@ +import { + getAddress, + isAddress, + parseAbi, + zeroAddress, + type Address, + type Hex, +} from 'viem'; +import { UNIVERSAL_CORE_EVM } from '../../constants/abi/prc20.evm'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { Snapshot } from '../reads/snapshot'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { MOVEABLE_TOKENS } from '../../constants/tokens'; +import { getPRC20Address } from '../../universal/prc20-address'; +import { svmKey } from '../codec/svm-accounts'; + +export const PRC20_SOURCE_ABI = parseAbi([ + 'function SOURCE_CHAIN_NAMESPACE() view returns (string)', + 'function SOURCE_TOKEN_ADDRESS() view returns (string)', +]); +const NATIVE_SOURCE_ABI = parseAbi([ + 'function SOURCE_TOKEN_ADDRESS() view returns (address)', +]); +const SVM_SOURCE_WORD_ABI = parseAbi([ + 'function SOURCE_TOKEN_ADDRESS() view returns (bytes32)', +]); + +/** Resolve the SVM source mint from metadata or the current chain-specific token registry. */ +export async function readSvmSourceToken( + snap: Snapshot, + token: Address, + chain: string, + network: PUSH_NETWORK = PUSH_NETWORK.TESTNET_DONUT +): Promise { + let source: string; + try { + source = await snap.read( + token, + PRC20_SOURCE_ABI, + 'SOURCE_TOKEN_ADDRESS' + ); + } catch (cause) { + try { + source = await snap.read( + token, + SVM_SOURCE_WORD_ABI, + 'SOURCE_TOKEN_ADDRESS' + ); + } catch { + throw cause; + } + } + const zeroKey = `0x${'00'.repeat(32)}` as Hex; + if ( + source === '' || + source.toLowerCase() === zeroAddress || + source.toLowerCase() === zeroKey + ) { + if (getAddress(token) === (await readGasPrc20(snap, chain))) return zeroKey; + // Current Donut SVM synthetic metadata exposes a zero word for SPL assets. + // Ordinary Route 2 already resolves these through the same token registry. + // Match both full origin chain and exact PRC20; never infer an unknown mint. + for (const asset of MOVEABLE_TOKENS[chain as CHAIN] ?? []) { + if (asset.mechanism === 'native') continue; + try { + const resolved = getPRC20Address( + { chain, address: asset.address }, + { network } + ); + if (getAddress(resolved.address) === getAddress(token)) + return svmKey(asset.address); + } catch { + // A token without a deployment in this network is not a candidate. + } + } + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `source mint unavailable for PRC20 ${token} on ${chain}; no registered mapping matches` + ); + } + return svmKey(source); +} +export const CORE_ADDRESS = + '0x00000000000000000000000000000000000000C0' as Address; +export async function readGasPrc20( + snap: Snapshot, + chain: string +): Promise
{ + return getAddress( + await snap.read
( + CORE_ADDRESS, + UNIVERSAL_CORE_EVM, + 'gasTokenPRC20ByChainNamespace', + [chain] + ) + ); +} +/** Donut native precompile returns a zero word; ERC20 metadata is a dynamic string. */ +export async function readOriginToken( + snap: Snapshot, + token: Address, + chain: string +): Promise
{ + let source: string; + try { + source = await snap.read( + token, + PRC20_SOURCE_ABI, + 'SOURCE_TOKEN_ADDRESS' + ); + } catch (cause) { + if ((await readGasPrc20(snap, chain)) !== getAddress(token)) throw cause; + const native = await snap.read
( + token, + NATIVE_SOURCE_ABI, + 'SOURCE_TOKEN_ADDRESS' + ); + if (getAddress(native) === zeroAddress) return zeroAddress; + throw cause; + } + if (isAddress(source, { strict: false })) return getAddress(source); + if (source === '' && (await readGasPrc20(snap, chain)) === getAddress(token)) + return zeroAddress; + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + `PRC20 ${token} has an invalid source token address` + ); +} diff --git a/packages/core/src/lib/agentic/contracts/reader.ts b/packages/core/src/lib/agentic/contracts/reader.ts new file mode 100644 index 000000000..7b9780684 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/reader.ts @@ -0,0 +1,42 @@ +import type { Abi, AbiEvent, Address, Hex, Log, PublicClient, TransactionReceipt } from 'viem'; + +/** + * The narrow chain-read surface the AGW module needs. Tests mock it; the SDK + * adapts the Push client's viem PublicClient. Every snapshot read passes an + * explicit `blockNumber` so dependent reads observe one state. + */ +export interface ChainReader { + readContract(args: { + address: Address; + abi: Abi | readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + blockNumber?: bigint; + }): Promise; + getBlockNumber(): Promise; + getCode(args: { address: Address; blockNumber?: bigint }): Promise; + getBalance(args: { address: Address; blockNumber?: bigint }): Promise; + getLogs(args: { + address: Address; + event: AbiEvent; + args?: Record; + fromBlock: bigint; + toBlock: bigint; + }): Promise; + getTransactionReceipt(args: { hash: Hex }): Promise; +} + +export function chainReaderFromPublicClient(client: PublicClient): ChainReader { + // viem's generics are narrower than this module's needs; the casts are + // confined to this adapter. + return { + readContract: (args) => + client.readContract(args as Parameters[0]), + getBlockNumber: () => client.getBlockNumber({ cacheTime: 0 }), + getCode: (args) => client.getCode(args), + getBalance: (args) => client.getBalance(args), + getLogs: (args) => + client.getLogs(args as Parameters[0]) as Promise, + getTransactionReceipt: (args) => client.getTransactionReceipt(args), + }; +} diff --git a/packages/core/src/lib/agentic/contracts/v5.ts b/packages/core/src/lib/agentic/contracts/v5.ts new file mode 100644 index 000000000..444dc5def --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/v5.ts @@ -0,0 +1,422 @@ +import { + decodeAbiParameters, + decodeEventLog, + decodeFunctionData, + encodeAbiParameters, + encodeFunctionData, + encodePacked, + getAbiItem, + getAddress, + toEventSelector, + type AbiEvent, + type Address, + type Hex, + type Log, +} from 'viem'; +import { + AGW_ABI, + AGW_FACTORY_ABI, + SMART_SESSION_ABI, + UNIVERSAL_RULES_POLICY_ABI, +} from './abi/v5'; +import type { SessionWire } from '../codec/session'; +import type { CheckpointKind } from '../agentic.types'; +import type { NativeTermsWire } from '../codec/native'; +import type { UniversalTermsWire } from '../codec/universal-terms'; +import type { SvmTermsWire } from '../codec/svm-terms'; + +/** + * Contract adapter for the reviewed v5 ABI generation. All version- + * sensitive encoding, decoding and view names live here; callers never touch + * a raw ABI. A future generation gets its own adapter implementing the same + * interface. + */ + +export interface Call { + target: Address; + value: bigint; + data: Hex; +} + +export const MODE_SINGLE = `0x${'00'.repeat(32)}` as Hex; +export const MODE_BATCH = `0x01${'00'.repeat(31)}` as Hex; +export const SEND_OUTBOUND_SELECTOR = '0x77b86bec' as const; +export const UEA_MULTICALL_PREFIX = '0x2cc2842d' as const; + +const CHECKPOINT_KINDS: readonly CheckpointKind[] = [ + 'OWNER_ACTION', + 'RULES_GRANTED', + 'RULES_REVOKED', +]; + +export interface NativeConfigRead extends NativeTermsWire { + initialized: boolean; + valueSpent: bigint; + amountSpent: bigint; + callsUsed: number; +} + +export interface ModeRead { + initialized: boolean; + /** 0 = UNIVERSAL, 1 = NATIVE (RulesType). */ + mode: number; + /** 0 = EVM, 1 = SVM (VmFamily). */ + vm: number; + chainHash: Hex; +} + +export interface UniversalConfigRead + extends Omit { + initialized: boolean; + assets: readonly { + token: Address; + maxPerCall: bigint; + maxTotal: bigint; + spent: bigint; + }[]; +} + +export interface SvmConfigRead extends Omit { + initialized: boolean; + assets: readonly { + token: Address; + maxPerCall: bigint; + maxTotal: bigint; + spent: bigint; + }[]; +} + +export type DecodedWalletCall = + | { kind: 'execute'; mode: Hex; calls: Call[] } + | { kind: 'executeAsAgent'; rulesId: Hex; mode: Hex; calls: Call[] } + | { kind: 'grantRules'; session: SessionWire } + | { kind: 'revokeRules'; rulesId: Hex } + | { kind: 'revokeAllRules' }; + +const EXECUTION_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'callData', type: 'bytes' }, + ], + }, +] as const; + +export const v5 = { + id: 'v5', + abis: { + wallet: AGW_ABI, + factory: AGW_FACTORY_ABI, + engine: SMART_SESSION_ABI, + policy: UNIVERSAL_RULES_POLICY_ABI, + }, + events: { + walletDeployed: getAbiItem({ + abi: AGW_FACTORY_ABI, + name: 'WalletDeployed', + }) as AbiEvent, + checkpointed: getAbiItem({ + abi: AGW_ABI, + name: 'Checkpointed', + }) as AbiEvent, + rulesGranted: getAbiItem({ + abi: AGW_ABI, + name: 'RulesGranted', + }) as AbiEvent, + rulesRevoked: getAbiItem({ + abi: AGW_ABI, + name: 'RulesRevoked', + }) as AbiEvent, + }, + + // ---------------------------------------------------------------- encoders + encodeDeployWallet(label: string): Hex { + return encodeFunctionData({ + abi: AGW_FACTORY_ABI, + functionName: 'deployWallet', + args: [label], + }); + }, + /** + * Index-bound deployment for an owner calling the factory itself. + * deployWalletWithSig skips signature checks when msg.sender == intent.owner + * but still reverts IndexMismatch / IntentWalletMismatch unless the owner's + * next slot is exactly `index` and predicts to `wallet`. A concurrent + * creation therefore makes this call revert instead of deploying another + * slot while later calls in the batch target the predicted address. + */ + encodeDeployWalletAt( + owner: Address, + index: bigint, + wallet: Address, + label: string + ): Hex { + const zero32 = `0x${'00'.repeat(32)}` as Hex; + const zeroAddress = '0x0000000000000000000000000000000000000000' as Address; + return encodeFunctionData({ + abi: AGW_FACTORY_ABI, + functionName: 'deployWalletWithSig', + args: [ + { + owner, + wallet, + executor: zeroAddress, + index, + sessionHash: zero32, + mode: zero32, + execCalldataHash: zero32, + nonceKey: BigInt(0), + nonceSeq: BigInt(0), + grantNonce: BigInt(0), + deadline: 0, + signerChainId: BigInt(0), + }, + '0x', + label, + ], + }); + }, + encodeGrantRules(session: SessionWire): Hex { + return encodeFunctionData({ + abi: AGW_ABI, + functionName: 'grantRules', + args: [session as never], + }); + }, + encodeRevokeRules(rulesId: Hex): Hex { + return encodeFunctionData({ + abi: AGW_ABI, + functionName: 'revokeRules', + args: [rulesId], + }); + }, + encodeRevokeAllRules(): Hex { + return encodeFunctionData({ + abi: AGW_ABI, + functionName: 'revokeAllRules', + args: [], + }); + }, + encodeSetLabel(label: string): Hex { + return encodeFunctionData({ + abi: AGW_ABI, + functionName: 'setLabel', + args: [label], + }); + }, + /** ERC-7579 single execution calldata: abi.encodePacked(target, value, callData). */ + packSingle(call: Call): Hex { + return encodePacked( + ['address', 'uint256', 'bytes'], + [call.target, call.value, call.data] + ); + }, + encodeExecute(calls: Call[]): Hex { + if (calls.length === 0) throw new Error('execute needs at least one call'); + if (calls.length === 1) { + return encodeFunctionData({ + abi: AGW_ABI, + functionName: 'execute', + args: [MODE_SINGLE, v5.packSingle(calls[0])], + }); + } + const batch = encodeAbiParameters(EXECUTION_TUPLE, [ + calls.map((c) => ({ + target: c.target, + value: c.value, + callData: c.data, + })), + ]); + return encodeFunctionData({ + abi: AGW_ABI, + functionName: 'execute', + args: [MODE_BATCH, batch], + }); + }, + encodeExecuteAsAgent(rulesId: Hex, call: Call): Hex { + return encodeFunctionData({ + abi: AGW_ABI, + functionName: 'executeAsAgent', + args: [rulesId, MODE_SINGLE, v5.packSingle(call)], + }); + }, + encodeAssertSpentNative( + configId: Hex, + wallet: Address, + expected: { valueSpent: bigint; amountSpent: bigint; callsUsed: number } + ): Hex { + return encodeFunctionData({ + abi: UNIVERSAL_RULES_POLICY_ABI, + functionName: 'assertSpent', + args: [ + configId, + wallet, + expected.valueSpent, + expected.amountSpent, + expected.callsUsed, + ], + }); + }, + encodeAssertSpentUniversal( + configId: Hex, + wallet: Address, + expectedSpent: readonly bigint[] + ): Hex { + return encodeFunctionData({ + abi: UNIVERSAL_RULES_POLICY_ABI, + functionName: 'assertSpent', + args: [configId, wallet, expectedSpent], + }); + }, + + // ---------------------------------------------------------------- decoders + decodeWalletCall(data: Hex): DecodedWalletCall | null { + let decoded; + try { + decoded = decodeFunctionData({ abi: AGW_ABI, data }); + } catch { + return null; + } + switch (decoded.functionName) { + case 'execute': { + const [mode, ecd] = decoded.args as readonly [Hex, Hex]; + return { kind: 'execute', mode, calls: unpackExecution(mode, ecd) }; + } + case 'executeAsAgent': { + const [rulesId, mode, ecd] = decoded.args as readonly [Hex, Hex, Hex]; + return { + kind: 'executeAsAgent', + rulesId, + mode, + calls: unpackExecution(mode, ecd), + }; + } + case 'grantRules': + return { + kind: 'grantRules', + session: decoded.args[0] as unknown as SessionWire, + }; + case 'revokeRules': + return { kind: 'revokeRules', rulesId: decoded.args[0] as Hex }; + case 'revokeAllRules': + return { kind: 'revokeAllRules' }; + default: + return null; + } + }, + decodeCheckpointKind(kind: number): CheckpointKind { + const k = CHECKPOINT_KINDS[kind]; + if (!k) throw new Error(`unknown checkpoint kind ${kind}`); + return k; + }, + parseWalletDeployed(logs: readonly Log[], factory: Address) { + return decodeMatching(logs, factory, v5.events.walletDeployed).map( + (args) => ({ + owner: getAddress(args['owner'] as Address), + index: BigInt(args['index'] as bigint), + wallet: getAddress(args['wallet'] as Address), + label: args['label'] as string, + }) + ); + }, + parseRulesGranted(logs: readonly Log[], wallet: Address) { + return decodeMatching(logs, wallet, v5.events.rulesGranted).map((args) => ({ + rulesId: args['rulesId'] as Hex, + mode: Number(args['mode']), + chainNamespace: args['chainNamespace'] as string, + })); + }, + parseRulesRevoked(logs: readonly Log[], wallet: Address) { + return decodeMatching(logs, wallet, v5.events.rulesRevoked).map((args) => ({ + rulesId: args['rulesId'] as Hex, + })); + }, + parseCheckpointed(logs: readonly Log[], wallet: Address) { + return logs + .filter( + (l) => + sameAddress(l.address, wallet) && + l.topics[0]?.toLowerCase() === + toEventSelector(v5.events.checkpointed).toLowerCase() + ) + .flatMap((log) => { + try { + const ev = decodeEventLog({ + abi: [v5.events.checkpointed], + ...log, + strict: true, + }); + return [{ log, args: ev.args as unknown as Record }]; + } catch { + return []; + } + }) + .map(({ log, args }) => ({ + seq: Number(args['seq']), + kind: v5.decodeCheckpointKind(Number(args['kind'])), + ref: args['ref'] as Hex, + blockNumber: BigInt(log.blockNumber ?? BigInt(0)), + txHash: log.transactionHash as Hex, + logIndex: Number(log.logIndex ?? 0), + transactionIndex: Number(log.transactionIndex ?? 0), + })); + }, +}; + +function unpackExecution(mode: Hex, ecd: Hex): Call[] { + const callType = mode.slice(2, 4); + if (callType === '00') { + const bytes = ecd.slice(2); + return [ + { + target: getAddress(`0x${bytes.slice(0, 40)}`), + value: BigInt(`0x${bytes.slice(40, 104) || '0'}`), + data: `0x${bytes.slice(104)}` as Hex, + }, + ]; + } + if (callType === '01') { + const [execs] = decodeAbiParameters(EXECUTION_TUPLE, ecd); + return execs.map((e) => ({ + target: getAddress(e.target), + value: e.value, + data: e.callData as Hex, + })); + } + return []; +} + +function sameAddress(a: string | null | undefined, b: string): boolean { + return !!a && a.toLowerCase() === b.toLowerCase(); +} + +function decodeMatching( + logs: readonly Log[], + emitter: Address, + event: AbiEvent +): Record[] { + // viem's decodeEventLog skips the topic0 check when given a one-event ABI, + // so the event signature is matched explicitly here. + const topic0 = toEventSelector(event).toLowerCase(); + const out: Record[] = []; + for (const log of logs) { + if (!sameAddress(log.address, emitter)) continue; + if (log.topics[0]?.toLowerCase() !== topic0) continue; + try { + const ev = decodeEventLog({ + abi: [event], + data: log.data, + topics: log.topics, + strict: true, + }); + out.push(ev.args as unknown as Record); + } catch { + // not this event + } + } + return out; +} + +export type AgwContracts = typeof v5; diff --git a/packages/core/src/lib/agentic/deployments.ts b/packages/core/src/lib/agentic/deployments.ts new file mode 100644 index 000000000..421a0a025 --- /dev/null +++ b/packages/core/src/lib/agentic/deployments.ts @@ -0,0 +1,299 @@ +import { getAddress, type Address } from 'viem'; +import { PUSH_NETWORK } from '../constants/enums'; +import { AgenticCapability } from './capabilities'; +import { v5, type AgwContracts } from './contracts/v5'; +import type { ChainReader } from './contracts/reader'; +import { AGENTIC_ERROR_CODE, AgenticError } from './errors'; +import { + DONUT_V5_ADDRESSES, + DONUT_V5_START_BLOCK, + V5_SOURCE_COMMIT, +} from './contracts/donut-v5'; + +/** + * A verified AGW contract generation on one Push network. The registry is the + * ONLY source of AGW addresses in the SDK: no address is hard-coded anywhere + * else, and historical Donut deployments (generation 67929f2, grantMandate) + * are NOT registered because they do not implement this ABI. + */ +export interface AgenticGeneration { + /** Stable identifier, e.g. 'v5'. */ + id: string; + /** Contract source commit the ABI was generated from. */ + sourceCommit: string; + network: PUSH_NETWORK; + /** false = review fixture (local harness); never advertised as a release deployment. */ + advertised: boolean; + addresses: { + factory: Address; + walletImplementation: Address; + sessionEngine: Address; + rulesPolicy: Address; + sessionValidator: Address; + gateway: Address; + }; + /** First block to scan for factory/wallet events. */ + startBlock: bigint; + /** Expected wallet accountId(). */ + accountId: string; + capabilities: ReadonlySet; + contracts: AgwContracts; +} + +/** Capabilities the v5 ABI actually implements. Target-only features are absent. */ +export const V5_CAPABILITIES: ReadonlySet = new Set([ + AgenticCapability.WALLET_READS, + AgenticCapability.ACTIVE_RULE_READS, + AgenticCapability.OWNER_EXECUTE, + AgenticCapability.AGENT_EXECUTE, + AgenticCapability.NATIVE_RULES, + AgenticCapability.ASSERT_SPENT_NATIVE, + AgenticCapability.CHECKPOINTS, + AgenticCapability.UNIVERSAL_EVM_OUTBOUND, + AgenticCapability.UNIVERSAL_EVM_RULES, + AgenticCapability.UNIVERSAL_SVM_RULES, + AgenticCapability.ASSERT_SPENT_UNIVERSAL_MULTI, + AgenticCapability.SET_LABEL, +]); + +/** + * V5 only. Unsupported networks fail before signing; no legacy adapter fallback. + */ +const VERIFIED_DEPLOYMENTS: readonly AgenticGeneration[] = [ + { + id: 'v5', + sourceCommit: V5_SOURCE_COMMIT, + network: PUSH_NETWORK.TESTNET_DONUT, + advertised: true, + addresses: DONUT_V5_ADDRESSES, + startBlock: DONUT_V5_START_BLOCK, + accountId: 'push.agw.1.0.0', + capabilities: V5_CAPABILITIES, + contracts: v5, + }, +]; + +const registry: AgenticGeneration[] = [...VERIFIED_DEPLOYMENTS]; + +/** + * @internal Register a generation that is NOT a verified release deployment — + * the local contract harness and E2E manifest loader use this. Not exported + * from the package entry point. + */ +export function registerAgenticGeneration( + input: Omit & { + capabilities?: ReadonlySet; + } +): AgenticGeneration { + if (input.id !== v5.id || input.sourceCommit !== V5_SOURCE_COMMIT) { + throw new AgenticError( + AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + `no contract adapter for generation "${input.id}"` + ); + } + const gen: AgenticGeneration = { + ...input, + addresses: { + factory: getAddress(input.addresses.factory), + walletImplementation: getAddress(input.addresses.walletImplementation), + sessionEngine: getAddress(input.addresses.sessionEngine), + rulesPolicy: getAddress(input.addresses.rulesPolicy), + sessionValidator: getAddress(input.addresses.sessionValidator), + gateway: getAddress(input.addresses.gateway), + }, + capabilities: input.capabilities ?? V5_CAPABILITIES, + contracts: v5, + }; + const existing = registry.findIndex( + (g) => + g.network === gen.network && g.addresses.factory === gen.addresses.factory + ); + if (existing >= 0) registry.splice(existing, 1); + registry.push(gen); + verifiedWiring.clear(); + return gen; +} + +/** @internal Test/harness cleanup. Restores the checked Donut v5 registry. */ +export function resetAgenticGenerations(): void { + registry.splice(0, registry.length, ...VERIFIED_DEPLOYMENTS); + verifiedWiring.clear(); +} + +/** Grant compatibility check; the unrestricted owner door does not consult URP. */ +export async function verifyPolicyVersion( + reader: ChainReader, + gen: AgenticGeneration +): Promise { + const version = await reader.readContract({ + address: gen.addresses.rulesPolicy, + abi: gen.contracts.abis.policy, + functionName: 'version', + }); + if (version !== '3.1.0') + throw new AgenticError( + AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + `URP version ${String(version)} is not supported by the v5 adapter` + ); +} + +export function generationsFor( + network: PUSH_NETWORK +): readonly AgenticGeneration[] { + return registry.filter((g) => g.network === network); +} + +/** The generation new wallets are created on for this network. */ +export function currentGeneration(network: PUSH_NETWORK): AgenticGeneration { + const gens = generationsFor(network); + const gen = gens[gens.length - 1]; + if (!gen) { + throw new AgenticError( + AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + `no verified AGW deployment is registered for ${network}`, + { + hint: 'AGW v5 is configured on Donut only; this Push network has no registered v5 deployment.', + details: { network, assumption: 'A07' }, + } + ); + } + return gen; +} + +/** Generation infrastructure a native rule may not target (the wallet itself is added per call). */ +export function forbiddenTargets( + gen: AgenticGeneration, + wallet?: Address +): Address[] { + const a = gen.addresses; + return [ + a.factory, + a.sessionEngine, + a.rulesPolicy, + a.sessionValidator, + a.gateway, + ...(wallet ? [wallet] : []), + ]; +} + +const verifiedWiring = new Map>(); + +/** Confirms the factory still points at the registered implementation (cached per generation). */ +export function verifyFactoryWiring( + reader: ChainReader, + gen: AgenticGeneration +): Promise { + const key = `${gen.network}:${gen.addresses.factory}:${gen.addresses.walletImplementation}`; + let p = verifiedWiring.get(key); + if (!p) { + p = (async () => { + const impl = (await reader.readContract({ + address: gen.addresses.factory, + abi: gen.contracts.abis.factory, + functionName: 'walletImplementation', + })) as Address; + if (getAddress(impl) !== gen.addresses.walletImplementation) { + throw new AgenticError( + AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + `factory ${gen.addresses.factory} points at implementation ${impl}, not the registered ${gen.addresses.walletImplementation}` + ); + } + })(); + p.catch(() => verifiedWiring.delete(key)); + verifiedWiring.set(key, p); + } + return p; +} + +/** + * Resolve and verify the generation of a deployed wallet: + * code present → factory() is a registered factory → factory.isWallet(wallet) + * → immutable wiring and accountId match the registered generation. + */ +export async function resolveWalletGeneration( + reader: ChainReader, + network: PUSH_NETWORK, + wallet: Address +): Promise { + const gens = generationsFor(network); + if (gens.length === 0) currentGeneration(network); // throws GENERATION_UNSUPPORTED (A07) + + const code = await reader.getCode({ address: wallet }); + if (!code || code === '0x') { + throw new AgenticError( + AGENTIC_ERROR_CODE.WALLET_NOT_DEPLOYED, + `no contract is deployed at ${wallet}`, + { + hint: 'Deploy it with client.agentic.create, or check the address and network.', + } + ); + } + let factory: Address; + try { + factory = getAddress( + (await reader.readContract({ + address: wallet, + abi: v5.abis.wallet, + functionName: 'factory', + })) as Address + ); + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_AGENTIC_WALLET, + `${wallet} does not expose an AGW factory()`, + { cause } + ); + } + const gen = gens.find((g) => g.addresses.factory === factory); + if (!gen) { + throw new AgenticError( + AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + `${wallet} was created by factory ${factory}, which is not a verified AGW generation on ${network}`, + { details: { factory, assumption: 'A07' } } + ); + } + const isWallet = (await reader.readContract({ + address: gen.addresses.factory, + abi: gen.contracts.abis.factory, + functionName: 'isWallet', + args: [wallet], + })) as boolean; + if (!isWallet) { + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_AGENTIC_WALLET, + `${wallet} is not a wallet of factory ${factory}` + ); + } + const [engine, policy, validator, gateway, accountId] = await Promise.all( + ( + [ + 'SESSION_ENGINE', + 'RULES_POLICY', + 'SESSION_VALIDATOR', + 'UNIVERSAL_GATEWAY_PC', + 'accountId', + ] as const + ).map((functionName) => + reader.readContract({ + address: wallet, + abi: gen.contracts.abis.wallet, + functionName, + }) + ) + ); + const mismatch = + getAddress(engine as Address) !== gen.addresses.sessionEngine || + getAddress(policy as Address) !== gen.addresses.rulesPolicy || + getAddress(validator as Address) !== gen.addresses.sessionValidator || + getAddress(gateway as Address) !== gen.addresses.gateway || + accountId !== gen.accountId; + if (mismatch) { + throw new AgenticError( + AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + `${wallet} wiring does not match registered generation ${gen.id}`, + { details: { engine, policy, validator, gateway, accountId } } + ); + } + await verifyFactoryWiring(reader, gen); + return gen; +} diff --git a/packages/core/src/lib/agentic/errors.ts b/packages/core/src/lib/agentic/errors.ts new file mode 100644 index 000000000..d66055dc5 --- /dev/null +++ b/packages/core/src/lib/agentic/errors.ts @@ -0,0 +1,124 @@ +import { + PushChainExecutionError, + type DecodedErrorPayload, +} from '../orchestrator/internals/errors'; + +/** + * Stable AGW error codes. The first groups are specified by the AGW SDK page + * (section 3.f); the remainder are SDK-owned codes that keep unsupported or + * unverified capabilities from being mistaken for success. + */ +export const AGENTIC_ERROR_CODE = { + // create / rule validation (spec 3.f) + AGENT_IS_OWNER: 'AGENT_IS_OWNER', + AMBIGUOUS_RULE: 'AMBIGUOUS_RULE', + TARGET_NOT_ON_RULE_CHAIN: 'TARGET_NOT_ON_RULE_CHAIN', + ASSET_CHAIN_MISMATCH: 'ASSET_CHAIN_MISMATCH', + // initialize with agenticWallet (spec 3.f) + NOT_OWNER_OR_AGENT: 'NOT_OWNER_OR_AGENT', + WALLET_NOT_DEPLOYED: 'WALLET_NOT_DEPLOYED', + NOT_AGENTIC_WALLET: 'NOT_AGENTIC_WALLET', + // rules.revoke (spec 3.f) + REVOKE_NEEDS_TARGET: 'REVOKE_NEEDS_TARGET', + // sendTransaction (spec 3.f) + NO_RULES_FOR_CHAIN: 'NO_RULES_FOR_CHAIN', + FROM_NOT_ALLOWED: 'FROM_NOT_ALLOWED', + NOT_ALLOWED_IN_AGENTIC_MODE: 'NOT_ALLOWED_IN_AGENTIC_MODE', + // SDK-owned + INVALID_RULE: 'INVALID_RULE', + CAPABILITY_UNAVAILABLE: 'CAPABILITY_UNAVAILABLE', + GENERATION_UNSUPPORTED: 'GENERATION_UNSUPPORTED', + READ_ONLY: 'READ_ONLY', + NOT_WALLET_OWNER: 'NOT_WALLET_OWNER', + RULE_NOT_FOUND: 'RULE_NOT_FOUND', + RULE_READ_FAILED: 'RULE_READ_FAILED', + INCONSISTENT_READ: 'INCONSISTENT_READ', + INDEX_RACE: 'INDEX_RACE', + CREATE_PARTIAL: 'CREATE_PARTIAL', + RECEIPT_MISMATCH: 'RECEIPT_MISMATCH', + RECEIPT_UNAVAILABLE: 'RECEIPT_UNAVAILABLE', + AGENT_GAS_INSUFFICIENT: 'AGENT_GAS_INSUFFICIENT', + WALLET_BALANCE_INSUFFICIENT: 'WALLET_BALANCE_INSUFFICIENT', + GATEWAY_ALLOWANCE_INSUFFICIENT: 'GATEWAY_ALLOWANCE_INSUFFICIENT', + RULE_LIMIT_EXCEEDED: 'RULE_LIMIT_EXCEEDED', +} as const; + +export type AgenticErrorCode = + (typeof AGENTIC_ERROR_CODE)[keyof typeof AGENTIC_ERROR_CODE]; + +/** + * Pre-signature AGW failure: invalid input, unsupported capability, failed + * eligibility or a read that cannot be trusted. Nothing was signed or sent + * unless `details` explicitly records committed hashes (CREATE_PARTIAL). + */ +export class AgenticError extends Error { + readonly code: AgenticErrorCode; + readonly hint?: string; + readonly details?: Readonly>; + override readonly cause?: unknown; + + constructor( + code: AgenticErrorCode, + message: string, + opts: { + hint?: string; + details?: Record; + cause?: unknown; + } = {} + ) { + super(message); + this.name = 'AgenticError'; + this.code = code; + this.hint = opts.hint; + this.details = opts.details; + this.cause = opts.cause; + } +} + +/** + * On-chain AGW, URP, factory or engine revert surfaced from an agentic send. + * Extends core's structured execution error so `instanceof + * PushChainExecutionError` keeps working, and keeps the underlying cause plus + * any batch recovery hashes. + */ +export class AgenticRevertError extends PushChainExecutionError { + readonly agenticCode?: string; + readonly transactionHashes?: `0x${string}`[]; + readonly pendingTransactionHash?: `0x${string}`; + + constructor( + message: string, + opts: { + decodedError?: DecodedErrorPayload; + gatewayTxHash?: string; + agenticCode?: string; + transactionHashes?: `0x${string}`[]; + pendingTransactionHash?: `0x${string}`; + cause?: unknown; + } = {} + ) { + super(message, { + decodedError: opts.decodedError, + gatewayTxHash: opts.gatewayTxHash, + cause: opts.cause, + }); + this.name = 'AgenticRevertError'; + this.agenticCode = opts.agenticCode; + this.transactionHashes = opts.transactionHashes + ? [...opts.transactionHashes] + : undefined; + this.pendingTransactionHash = opts.pendingTransactionHash; + } +} + +export function capabilityUnavailable( + capability: string, + dependency: string, + hint?: string +): AgenticError { + return new AgenticError( + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, + `AGW capability "${capability}" is not available: ${dependency}`, + { hint, details: { capability, dependency } } + ); +} diff --git a/packages/core/src/lib/agentic/execution/guards.ts b/packages/core/src/lib/agentic/execution/guards.ts new file mode 100644 index 000000000..2aa5f68ed --- /dev/null +++ b/packages/core/src/lib/agentic/execution/guards.ts @@ -0,0 +1,46 @@ +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; + +/** Methods the spec forbids on an agentic client (spec 2.a). */ +export type ForbiddenAgenticMethod = + | 'prepareTransaction' + | 'executeTransactions' + | 'migrateCEA' + | 'rescueFunds' + | 'payGasWith'; + +export function notAllowedInAgenticMode(method: ForbiddenAgenticMethod | string): AgenticError { + return new AgenticError( + AGENTIC_ERROR_CODE.NOT_ALLOWED_IN_AGENTIC_MODE, + `${method} is not available on a client initialized with agenticWallet`, + { + hint: + method === 'prepareTransaction' || method === 'executeTransactions' + ? 'Use sendTransaction; cascades would run from the signer, not the wallet.' + : 'Use a client initialized without agenticWallet for signer-level operations.', + } + ); +} + +/** + * Reject inputs that would execute from somewhere other than the wallet, or + * that agentic mode does not support, before any signature or RPC call. + */ +export function guardAgenticSendParams(params: unknown): void { + if (!params || typeof params !== 'object') { + throw new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, 'sendTransaction needs a transaction object'); + } + const p = params as { + from?: unknown; + payGasWith?: unknown; + migration?: unknown; + }; + if (p.from !== undefined && p.from !== null) { + throw new AgenticError( + AGENTIC_ERROR_CODE.FROM_NOT_ALLOWED, + '`from` (Routes 3 and 4) is not allowed in agenticWallet mode', + { hint: 'A CEA-originated call makes msg.sender a CEA, so the wallet door check would fail.' } + ); + } + if (p.payGasWith !== undefined) throw notAllowedInAgenticMode('payGasWith'); + if (p.migration) throw notAllowedInAgenticMode('migrateCEA'); +} diff --git a/packages/core/src/lib/agentic/execution/outbound.ts b/packages/core/src/lib/agentic/execution/outbound.ts new file mode 100644 index 000000000..ab78059f0 --- /dev/null +++ b/packages/core/src/lib/agentic/execution/outbound.ts @@ -0,0 +1,263 @@ +import { + concat, + encodeAbiParameters, + encodeFunctionData, + getAddress, + type Address, + type Hex, +} from 'viem'; +import { CHAIN, VM } from '../../constants/enums'; +import { CHAIN_INFO } from '../../constants/chain'; +import type { MoveableToken } from '../../constants/tokens'; +import { ERC20_EVM, UNIVERSAL_GATEWAY_PC } from '../../constants/abi'; +import type { + UniversalExecuteParams, + UniversalOutboundTxRequest, +} from '../../orchestrator/orchestrator.types'; +import { isPC20Reference } from '../../orchestrator/orchestrator.types'; +import { resolveR2DestinationFundsToken } from '../../orchestrator/internals/route-handlers'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import type { AgenticGeneration } from '../deployments'; +import type { Call } from '../contracts/v5'; +import { UEA_MULTICALL_PREFIX } from '../contracts/v5'; +import { + AGENTIC_ERROR_CODE, + AgenticError, + capabilityUnavailable, +} from '../errors'; +import type { AgenticRuntime } from '../runtime'; + +/** URP MAX_ACTIONS_PER_REQUEST at v5. */ +export const MAX_DESTINATION_CALLS = 10; + +const MULTICALL_TUPLE = [ + { + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, +] as const; + +export interface DestinationCall { + to: Address; + value: bigint; + data: Hex; +} + +export interface ComposedOutbound { + chain: CHAIN; + destinationAccount: Address; + destinationAccountDeployed: boolean; + token: Address; + amount: bigint; + /** PC the wallet attaches: protocolFee + maxPCForGas. */ + value: bigint; + request: UniversalOutboundTxRequest; + /** The wallet's call into the gateway. */ + gatewayCall: Call; + destinationCalls: DestinationCall[]; +} + +/** + * The calls the wallet's destination account (CEA) executes, built exactly as + * core's Route 2 builds them for an ordinary account (buildR2CeaPayloadEvm): + * - explicit call arrays are used as given (the caller handles transfers); + * - with ERC-20 funds, a transfer(target, amount) is prepended to a single + * call, or is the whole payload for a transfer-only send; + * - native value (and native funds) goes to the target with the call, or as + * a value-only call; a value transfer to the CEA itself is skipped. + * Every encoded call names the requested target, so a response never reports + * a delivery the request does not instruct. + */ +export function destinationCalls( + params: UniversalExecuteParams, + ctx: { cea: Address; fundsToken?: MoveableToken } +): DestinationCall[] { + const target = getAddress((params.to as { address: string }).address); + if (Array.isArray(params.data)) { + return params.data.map((c) => ({ + to: getAddress(c.to), + value: c.value ?? BigInt(0), + data: c.data, + })); + } + const amount = params.funds?.amount ?? BigInt(0); + const token = ctx.fundsToken; + const erc20 = amount > BigInt(0) && token && token.mechanism !== 'native'; + const nativeFunds = + amount > BigInt(0) && token?.mechanism === 'native' ? amount : BigInt(0); + const value = params.value ?? BigInt(0); + const calls: DestinationCall[] = []; + if (erc20) { + calls.push({ + to: getAddress((token as MoveableToken).address), + value: BigInt(0), + data: encodeFunctionData({ + abi: ERC20_EVM, + functionName: 'transfer', + args: [target, amount], + }), + }); + } + const data = params.data && params.data !== '0x' ? params.data : undefined; + if (data) { + calls.push({ to: target, value: value + nativeFunds, data }); + } else { + const forward = value > BigInt(0) ? value : nativeFunds; + if (forward > BigInt(0) && target !== ctx.cea) { + calls.push({ to: target, value: forward, data: '0x' }); + } + } + return calls; +} + +/** + * Compose the AGW-originated EVM outbound (Route 2 shape, wallet context): + * - recipient = empty bytes, revertRecipient = the AGW, maxPCForGas > 0 + * - payload = UEA_MULTICALL ‖ abi.encode(Multicall[1..10]) executed by the AGW's CEA + * - value = protocolFee + maxPCForGas, paid from the AGW's PC + * The destination account is resolved from the AGW, never from the signer. + * The SDK never writes a gateway allowance on either door: the gateway pulls + * from an allowance the owner set separately with an ordinary send. + */ +export async function composeOutbound( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + params: UniversalExecuteParams, + opts: { ruleAssets?: readonly Address[]; door: 'owner' | 'agent' } +): Promise { + requireCapability(gen.capabilities, AgenticCapability.UNIVERSAL_EVM_OUTBOUND); + const target = params.to as { address: string; chain: CHAIN }; + const chain = target.chain; + const info = CHAIN_INFO[chain]; + if (!info) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `unknown destination chain ${String(chain)}` + ); + } + if (info.vm !== VM.EVM) { + throw capabilityUnavailable( + AgenticCapability.UNIVERSAL_SVM_RULES, + 'This composer accepts EVM destinations; use the Solana instruction route' + ); + } + const funds = params.funds; + if (funds && isPC20Reference(funds.token)) { + throw capabilityUnavailable( + 'pc20Outbound', + 'PC20 tokens are not supported through an agentic wallet' + ); + } + // An omitted funds.token means the destination chain's native token, as in Route 2. + const fundsToken: MoveableToken | undefined = funds?.amount + ? funds.token + ? resolveR2DestinationFundsToken( + funds.token as MoveableToken, + chain, + runtime.network + ) + : { + symbol: 'native', + decimals: 18, + address: '0x0000000000000000000000000000000000000000', + mechanism: 'native', + } + : undefined; + + // Burn token and amount, as Route 2 sizes them for an ordinary account. + let token: Address; + let amount = BigInt(0); + if (funds) { + token = runtime.resolvePrc20( + funds.token as MoveableToken | undefined, + chain + ); + amount = funds.amount; + } else if ((params.value ?? BigInt(0)) > BigInt(0)) { + token = runtime.resolvePrc20(undefined, chain); + amount = params.value as bigint; + } else if (opts.ruleAssets?.length) { + token = getAddress(opts.ruleAssets[0]); + } else { + token = runtime.resolvePrc20(undefined, chain); + } + const resolved = await runtime.resolveCEA(wallet, chain); + const cea = getAddress(resolved.cea); + const calls = destinationCalls(params, { cea, fundsToken }); + if (calls.length === 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'this outbound carries no destination call; parking funds in the wallet’s destination account is not supported', + { + hint: 'Name a recipient other than the wallet’s own destination account, or add calldata.', + } + ); + } + if (calls.length > MAX_DESTINATION_CALLS) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `at most ${MAX_DESTINATION_CALLS} destination calls per outbound` + ); + } + if (opts.door === 'agent') { + const bare = calls.findIndex((c) => (c.data.length - 2) / 2 < 4); + if (bare >= 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `destination call ${bare} has no function selector; the rules policy only admits allow-listed calls`, + { + hint: 'A value-only destination transfer is not representable in an agent rule.', + } + ); + } + } + + const quote = await runtime.quoteOutbound( + token, + params.gasLimit ?? BigInt(0), + chain + ); + if (quote.nativeValueForGas <= BigInt(0)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'outbound gas quote returned zero; URP rejects an uncapped gas swap' + ); + } + const maxPCForGas = quote.nativeValueForGas; + const value = quote.protocolFee + maxPCForGas; + const payload = concat([ + UEA_MULTICALL_PREFIX, + encodeAbiParameters(MULTICALL_TUPLE, [calls]), + ]); + const request: UniversalOutboundTxRequest = { + recipient: '0x', + token, + amount, + gasLimit: quote.gasLimitUsed, + gasPrice: BigInt(0), + maxPCForGas, + payload, + revertRecipient: wallet, + }; + const data = encodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + functionName: 'sendUniversalTxOutbound', + args: [request], + }); + return { + chain, + destinationAccount: cea, + destinationAccountDeployed: resolved.isDeployed, + token, + amount, + value, + request, + gatewayCall: { target: gen.addresses.gateway, value, data }, + destinationCalls: calls, + }; +} diff --git a/packages/core/src/lib/agentic/execution/send.ts b/packages/core/src/lib/agentic/execution/send.ts new file mode 100644 index 000000000..6f18a4ca1 --- /dev/null +++ b/packages/core/src/lib/agentic/execution/send.ts @@ -0,0 +1,401 @@ +import { sendPublicSvm } from './svm-public'; +import { getAddress, type Address, type Hex } from 'viem'; +import type { CHAIN } from '../../constants/enums'; +import { ERC20_EVM } from '../../constants/abi'; +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import type { + ExecuteParams, + MultiCall, + TransactionExecutionOptions, + UniversalExecuteParams, + UniversalTxResponse, +} from '../../orchestrator/orchestrator.types'; +import { isChainTarget } from '../../orchestrator/route-detector'; +import { + getPushChainForNetwork, + isPushChain, +} from '../../orchestrator/internals/helpers'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import type { AgenticExecutionContext } from '../context'; +import type { Call, UniversalConfigRead } from '../contracts/v5'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { actionId, configId } from '../codec/ids'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { + assertCanSign, + emitAgentic, + wrapSendError, +} from '../management/common'; +import { selectRuleForSend } from '../reads/rules'; +import { Snapshot } from '../reads/snapshot'; +import { adaptAgenticResponse, outboundResponseCall } from '../response'; +import type { AgenticRuntime } from '../runtime'; +import type { AgenticHex, AgenticProgressHook } from '../agentic.types'; +import { guardAgenticSendParams } from './guards'; +import { composeOutbound, type DestinationCall } from './outbound'; + +const MODE_UNIVERSAL = 0; +const VM_EVM = 0; +/** R1's own required-funds estimate when no gasLimit is given (execute-standard). */ +const DEFAULT_GAS_ESTIMATE = BigInt(1e7); + +/** Signer-level fields that legitimately carry over to the wrapped Push tx. */ +function signerFields(p: UniversalExecuteParams): Partial { + const out: Partial = {}; + if (p.gasLimit !== undefined && !isChainTarget(p.to)) + out.gasLimit = p.gasLimit; + if (p.maxFeePerGas !== undefined) out.maxFeePerGas = p.maxFeePerGas; + if (p.maxPriorityFeePerGas !== undefined) + out.maxPriorityFeePerGas = p.maxPriorityFeePerGas; + if (p.nonce !== undefined) out.nonce = p.nonce; + if (p.deadline !== undefined) out.deadline = p.deadline; + if (p.feeLockTxHash !== undefined) out.feeLockTxHash = p.feeLockTxHash; + return out; +} + +/** + * Spec 2.a gas rule: in agenticWallet mode an external signer's UEA must + * already hold the PC for its Push gas, except for the very first action + * while that UEA is undeployed (which takes the one-time origin fee lock). + * Mirrors R1's fee-lock condition so an underfunded UEA fails loudly here + * instead of silently locking origin funds on every action. + */ +async function enforceSignerGas( + runtime: AgenticRuntime, + gasLimit: bigint | undefined +): Promise { + if (runtime.signerIsPushNative()) return; + if (!(await runtime.signerAccountDeployed())) return; + const [gasPrice, balance] = await Promise.all([ + runtime.getGasPrice(), + runtime.signerBalance(), + ]); + const required = (gasLimit ?? DEFAULT_GAS_ESTIMATE) * gasPrice; + if (balance < required) { + throw new AgenticError( + AGENTIC_ERROR_CODE.AGENT_GAS_INSUFFICIENT, + `the signer's UEA ${runtime.signerPushAccount()} holds ${balance} PC wei but needs ~${required} for Push gas`, + { + hint: "The wallet's PC pays outbound fees, not the signer's gas. Top up the signer's UEA (PushChain.utils.account.deriveExecutorAccount) from a client without agenticWallet.", + details: { required, balance }, + } + ); + } +} + +/** + * A ChainTarget naming the connected Push chain is a native send, exactly like + * a bare address (core's route detector treats it the same way). A ChainTarget + * naming a different Push network is refused rather than routed outbound. + */ +function normalizePushDestination( + p: UniversalExecuteParams, + runtime: AgenticRuntime +): UniversalExecuteParams { + if (!isChainTarget(p.to)) return p; + const chain = p.to.chain; + if (chain === getPushChainForNetwork(runtime.network)) { + return { ...p, to: getAddress(p.to.address) }; + } + if (isPushChain(chain)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `destination ${chain} is a Push chain other than the connected network (${runtime.network})` + ); + } + return p; +} + +function toCalls(params: UniversalExecuteParams): Call[] { + if (Array.isArray(params.data)) { + return params.data.map((c) => ({ + target: getAddress(c.to), + value: c.value ?? BigInt(0), + data: c.data, + })); + } + return [ + { + target: getAddress(params.to as Address), + value: params.value ?? BigInt(0), + data: (params.data ?? '0x') as Hex, + }, + ]; +} + +/** + * sendTransaction in agenticWallet mode. Owner door → execute; agent door → + * executeAsAgent under the one enabled rule for (signer, destination), looked + * up on every send. Everything is validated before the signer is asked. + */ +export async function agenticSend( + runtime: AgenticRuntime, + actx: AgenticExecutionContext, + params: UniversalExecuteParams | ExecuteParams, + options?: TransactionExecutionOptions +): Promise { + assertCanSign(runtime, 'sendTransaction'); + guardAgenticSendParams(params); + const p = normalizePushDestination(params as UniversalExecuteParams, runtime); + const hook: AgenticProgressHook | undefined = + options?.progressHook ?? p.progressHook; + const gen = actx.generation; + const wallet = actx.wallet; + const outbound = isChainTarget(p.to); + const destination = outbound + ? (p.to as { chain: string }).chain + : runtime.pushChainNamespace; + + if (!outbound && p.funds) { + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_ALLOWED_IN_AGENTIC_MODE, + "`funds` on a Push-chain send would bridge from the signer; it does not move the wallet's tokens", + { + hint: "Transfer the wallet's PRC20 with a token transfer call in `data`.", + } + ); + } + + if (outbound && destination.startsWith('solana:')) { + await enforceSignerGas(runtime, undefined); + return sendPublicSvm(runtime, actx, p, options); + } + + let wrappedData: Hex; + let agentBatch: MultiCall[] | undefined; + let nativeCalls: Call[] | undefined; + let rulesId: AgenticHex | undefined; + let logical: { to: string; data: string; value: bigint }; + let meta: { route?: 'UOA_TO_CEA'; destinationAccount?: Address } = {}; + let outboundChain: CHAIN | undefined; + let destinationCalls: DestinationCall[] | undefined; + + if (actx.door === 'agent') { + requireCapability(gen.capabilities, AgenticCapability.AGENT_EXECUTE); + } else { + requireCapability(gen.capabilities, AgenticCapability.OWNER_EXECUTE); + } + + if (!outbound) { + const calls = toCalls(p); + if (calls.length === 0) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'an empty call array cannot execute' + ); + if (actx.door === 'owner') { + wrappedData = gen.contracts.encodeExecute(calls); + } else { + const snap = await Snapshot.at(runtime.reader); + const rule = await selectRuleForSend( + snap, + gen, + wallet, + actx.signerPushAccount, + destination + ); + rulesId = rule.rulesId as AgenticHex; + wrappedData = gen.contracts.encodeExecuteAsAgent(rule.rulesId, calls[0]); + if (calls.length > 1) { + agentBatch = calls.map((call) => ({ + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeExecuteAsAgent(rule.rulesId, call), + })); + nativeCalls = calls; + } + } + logical = + calls.length === 1 + ? { to: calls[0].target, data: calls[0].data, value: calls[0].value } + : agentBatch + ? { to: calls[0].target, data: calls[0].data, value: calls[0].value } + : { to: wallet, data: wrappedData, value: BigInt(0) }; + } else { + let ruleAssets: readonly Address[] | undefined; + let expectedCEA: Address | undefined; + const snap = await Snapshot.at(runtime.reader); + if (actx.door === 'agent') { + const rule = await selectRuleForSend( + snap, + gen, + wallet, + actx.signerPushAccount, + destination + ); + if (rule.mode !== MODE_UNIVERSAL || rule.vm !== VM_EVM) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `rule ${rule.rulesId} is not an EVM universal rule` + ); + } + rulesId = rule.rulesId as AgenticHex; + const cfg = await snap.read( + gen.addresses.rulesPolicy, + gen.contracts.abis.policy, + 'getConfig', + [ + configId( + wallet, + rule.rulesId, + actionId(gen.addresses.gateway, SEND_OUTBOUND_SELECTOR) + ), + wallet, + ] + ); + ruleAssets = cfg.assets.map((a) => getAddress(a.token)); + expectedCEA = getAddress(cfg.expectedCEA); + } + const out = await composeOutbound(runtime, gen, wallet, p, { + ruleAssets, + door: actx.door, + }); + outboundChain = out.chain; + meta = { route: 'UOA_TO_CEA', destinationAccount: out.destinationAccount }; + const [walletPc, tokenBalance, allowance] = await Promise.all([ + runtime.reader.getBalance({ + address: wallet, + blockNumber: snap.blockNumber, + }), + out.amount > BigInt(0) + ? snap.read( + out.token, + ERC20_EVM as unknown as readonly unknown[], + 'balanceOf', + [wallet] + ) + : Promise.resolve(BigInt(0)), + snap.read( + out.token, + ERC20_EVM as unknown as readonly unknown[], + 'allowance', + [wallet, gen.addresses.gateway] + ), + ]); + if (walletPc < out.value) { + throw new AgenticError( + AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, + `wallet ${wallet} holds ${walletPc} PC wei; this outbound needs ${out.value} (protocol fee + gas cap)`, + { hint: "Fund the wallet's PC with an ordinary send to its address." } + ); + } + if (tokenBalance < out.amount) { + throw new AgenticError( + AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, + `wallet ${wallet} holds ${tokenBalance} of ${out.token}; this outbound burns ${out.amount}` + ); + } + // Both doors only consume an allowance the owner set separately; the SDK + // never writes one (a write from a stale read could restore revoked authority). + if (out.amount > allowance) { + throw new AgenticError( + AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, + `the gateway may pull ${allowance} of ${out.token} from the wallet; this outbound needs ${out.amount}`, + { + hint: 'Set a bounded allowance first with an owner-door send: approve(gateway, cap) on the token (approve(gateway, 0) removes it).', + } + ); + } + if (actx.door === 'agent') { + if (expectedCEA && expectedCEA !== out.destinationAccount) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `rule ${rulesId} committed destination account ${expectedCEA}, but the wallet's account on ${out.chain} now derives to ${out.destinationAccount}`, + { + hint: 'Destination-account derivation drift (obligation 13). The owner must regrant the rule.', + } + ); + } + wrappedData = gen.contracts.encodeExecuteAsAgent( + rulesId as Hex, + out.gatewayCall + ); + } else { + wrappedData = gen.contracts.encodeExecute([out.gatewayCall]); + } + destinationCalls = out.destinationCalls; + logical = outboundResponseCall(destinationCalls, { + to: out.gatewayCall.target, + data: out.gatewayCall.data, + value: out.gatewayCall.value, + }); + } + + await enforceSignerGas(runtime, signerFields(p).gasLimit); + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_107, + rulesId, + outboundChain ?? destination, + actx.door + ); + + let resp: UniversalTxResponse; + try { + const sendOptions = hook ? { ...options, progressHook: hook } : options; + resp = agentBatch + ? await runtime.executeAtomicBatch( + { + ...signerFields(p), + to: wallet, + value: BigInt(0), + data: agentBatch, + }, + sendOptions + ) + : await runtime.execute( + { + ...signerFields(p), + to: wallet, + value: BigInt(0), + data: wrappedData, + }, + sendOptions + ); + } catch (err) { + const wrapped = wrapSendError(err) as { + code?: string; + message?: string; + decodedError?: unknown; + }; + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_02, + 'sendTransaction', + wrapped?.code ?? 'UNKNOWN', + wrapped?.message ?? String(err), + wrapped?.decodedError + ); + throw wrapped; + } + adaptAgenticResponse(resp, { + wallet, + door: actx.door, + rulesId, + chainNamespace: outboundChain ?? destination, + destinationAccount: meta.destinationAccount as AgenticHex | undefined, + destinationCalls, + ...(nativeCalls + ? { + nativeCalls: nativeCalls.map((c) => ({ + to: c.target, + data: c.data, + value: c.value, + })), + } + : {}), + logical, + route: meta.route, + chain: outboundChain, + }); + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_01, + 'sendTransaction', + resp.hash + ); + return resp; +} diff --git a/packages/core/src/lib/agentic/execution/svm-instruction.ts b/packages/core/src/lib/agentic/execution/svm-instruction.ts new file mode 100644 index 000000000..3d81bcd5f --- /dev/null +++ b/packages/core/src/lib/agentic/execution/svm-instruction.ts @@ -0,0 +1,67 @@ +import type { CHAIN } from '../../constants/enums'; +import { resolveSvmCallForCea } from '../../orchestrator/svm-idl/resolve'; +import type { SvmTermsWire } from '../codec/svm-terms'; +import { svmKey } from '../codec/svm-accounts'; +import { + encodeAgwSvmPayload, + validateAgwSvmPayload, + type SvmPayloadAccount, +} from './svm-payload'; +import type { Address } from 'viem'; + +/** Resolve IDL accounts with the wallet authority; granted permissions are checked by URP. */ +export function resolveAgwSvmInstruction( + input: { + wallet: Address; + chain: CHAIN; + program: string; + instructionData: Uint8Array; + accounts?: readonly SvmPayloadAccount[]; + }, + terms: SvmTermsWire +) { + const program = svmKey(input.program); + if (input.accounts) { + const payload = encodeAgwSvmPayload( + program, + input.accounts, + input.instructionData + ); + return { + recipient: program, + payload, + accounts: [...input.accounts], + instructionData: input.instructionData, + }; + } + const resolved = resolveSvmCallForCea( + { + programAddress: program, + data: input.instructionData, + senderUea: input.wallet, + targetChain: input.chain, + }, + terms.expectedCEA + ); + const payload = encodeAgwSvmPayload( + resolved.targetProgram, + resolved.accounts, + resolved.ixData + ); + return { + recipient: program, + payload, + accounts: resolved.accounts, + instructionData: resolved.ixData, + }; +} + +/** Diagnostic-only local policy preview. Sends use the resolver and contract enforcement. */ +export function prepareAgwSvmInstruction( + input: Parameters[0], + terms: SvmTermsWire +) { + const resolved = resolveAgwSvmInstruction(input, terms); + validateAgwSvmPayload(terms, resolved.recipient, resolved.payload); + return resolved; +} diff --git a/packages/core/src/lib/agentic/execution/svm-outbound.ts b/packages/core/src/lib/agentic/execution/svm-outbound.ts new file mode 100644 index 000000000..6f35c58f6 --- /dev/null +++ b/packages/core/src/lib/agentic/execution/svm-outbound.ts @@ -0,0 +1,102 @@ +/** Outbound encoding plus a diagnostic-only policy preview. */ +import { encodeFunctionData, getAddress, type Address, type Hex } from 'viem'; +import { UNIVERSAL_GATEWAY_PC } from '../../constants/abi'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { Call, SvmConfigRead } from '../contracts/v5'; +import { UINT256_MAX } from '../codec/defaults'; +import { UINT64_MAX } from '../codec/svm-terms'; +import { validateAgwSvmPayload } from './svm-payload'; + +export interface SvmOutboundInput { + wallet: Address; + gateway: Address; + token: Address; + amount: bigint; + recipient: Hex; + payload: Hex; + gasLimit: bigint; + protocolFee: bigint; + maxPCForGas: bigint; +} +/** Diagnostic-only local preview; production sends use buildAgwSvmAction. */ +export function composeAgwSvmAction( + input: SvmOutboundInput, + config: SvmConfigRead, + nowSeconds: number +): Call { + function fail(name: string): never { + throw new AgenticError(AGENTIC_ERROR_CODE.RULE_LIMIT_EXCEEDED, name, { + details: { contractError: name }, + }); + } + if (!config.initialized) fail('NotInitialized'); + if (nowSeconds > config.validUntil) fail('RulesExpired'); + for (const [name, value] of Object.entries({ + amount: input.amount, + gasLimit: input.gasLimit, + protocolFee: input.protocolFee, + maxPCForGas: input.maxPCForGas, + })) { + if (typeof value !== 'bigint' || value < BigInt(0) || value > UINT256_MAX) + fail(`${name}OutOfRange`); + } + const token = getAddress(input.token), + cap = config.assets.find((a) => getAddress(a.token) === token); + if (!cap) fail('AssetNotAllowed'); + if (input.amount > UINT64_MAX) fail('AmountExceedsU64'); + if (input.amount > cap.maxPerCall) fail('AmountExceedsCap'); + if (cap.spent + input.amount > cap.maxTotal) fail('TotalSpendCapExceeded'); + const value = input.protocolFee + input.maxPCForGas; + if (value > UINT256_MAX || value > config.maxGasPerCall) + fail('PCValueExceedsCap'); + if (!input.maxPCForGas) fail('UncappedGasSwapRejected'); + validateAgwSvmPayload(config, input.recipient, input.payload); + return buildAgwSvmAction(input); +} + +/** Encode an outbound without evaluating the granted policy; the wallet/URP enforces it. */ +export function buildAgwSvmAction(input: SvmOutboundInput): Call { + for (const [name, value] of Object.entries({ + amount: input.amount, + gasLimit: input.gasLimit, + protocolFee: input.protocolFee, + maxPCForGas: input.maxPCForGas, + })) { + if (typeof value !== 'bigint' || value < BigInt(0) || value > UINT256_MAX) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `${name} must fit uint256` + ); + } + if (input.amount > UINT64_MAX) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'Solana amount must fit uint64' + ); + const value = input.protocolFee + input.maxPCForGas; + if (value > UINT256_MAX || input.maxPCForGas === BigInt(0)) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'invalid outbound gas quote' + ); + const token = getAddress(input.token); + const request = { + recipient: input.recipient, + token, + amount: input.amount, + gasLimit: input.gasLimit, + gasPrice: BigInt(0), + maxPCForGas: input.maxPCForGas, + payload: input.payload, + revertRecipient: getAddress(input.wallet), + }; + return { + target: getAddress(input.gateway), + value, + data: encodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + functionName: 'sendUniversalTxOutbound', + args: [request], + }), + }; +} diff --git a/packages/core/src/lib/agentic/execution/svm-payload.ts b/packages/core/src/lib/agentic/execution/svm-payload.ts new file mode 100644 index 000000000..da76d7ee4 --- /dev/null +++ b/packages/core/src/lib/agentic/execution/svm-payload.ts @@ -0,0 +1,146 @@ +/** Parser/validator for the deployed S12–S18 instruction grammar. */ +import { bytesToHex, hexToBytes, type Hex } from 'viem'; +import { encodeSvmExecutePayload } from '../../orchestrator/payload-builders'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { + exactHex, + isForbiddenSvmProgram, + SVM_LIMITS, + SvmDataPinMode, + type SvmTermsWire, +} from '../codec/svm-terms'; + +export interface SvmPayloadAccount { + pubkey: Hex; + isWritable: boolean; +} +export interface ParsedSvmPayload { + program: Hex; + accounts: (SvmPayloadAccount & { writableFlag: number })[]; + instructionData: Uint8Array; +} +function fail(name: string): never { + throw new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, name, { + details: { contractError: name }, + }); +} +export function parseAgwSvmPayload(payload: Hex): ParsedSvmPayload { + if (!/^0x(?:[0-9a-fA-F]{2})*$/.test(payload)) fail('MalformedSvmPayload'); + const bytes = hexToBytes(payload); + if (!bytes.length) fail('SvmPayloadEmpty'); + if (bytes.length < 41) fail('MalformedSvmPayload'); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), + count = view.getUint32(0, false); + if (count > SVM_LIMITS.accounts) fail('SvmAccountsOutOfRange'); + const lenOffset = 4 + count * 33; + if (bytes.length < lenOffset + 4) fail('MalformedSvmPayload'); + const length = view.getUint32(lenOffset, false); + if (length > SVM_LIMITS.ixData) fail('SvmIxDataTooLong'); + const dataOffset = lenOffset + 4; + if (bytes.length !== dataOffset + length + 33) fail('MalformedSvmPayload'); + if (bytes[dataOffset + length] !== 2) fail('SvmInstructionNotExecute'); + const accounts = Array.from({ length: count }, (_, i) => { + const offset = 4 + i * 33, + writableFlag = bytes[offset + 32]; + // URP does not judge this flag. Preserve the raw value for diagnostics. + return { + pubkey: bytesToHex(bytes.slice(offset, offset + 32)), + isWritable: writableFlag === 1, + writableFlag, + }; + }); + return { + program: bytesToHex(bytes.slice(dataOffset + length + 1)), + accounts, + instructionData: bytes.slice(dataOffset, dataOffset + length), + }; +} +export function encodeAgwSvmPayload( + program: Hex, + accounts: readonly SvmPayloadAccount[], + instructionData: Uint8Array +): Hex { + exactHex(program, 32, 'target program'); + if (accounts.length > SVM_LIMITS.accounts) fail('SvmAccountsOutOfRange'); + if (instructionData.length > SVM_LIMITS.ixData) fail('SvmIxDataTooLong'); + accounts.forEach((a) => { + exactHex(a.pubkey, 32, 'account'); + if (typeof a.isWritable !== 'boolean') fail('MalformedSvmPayload'); + }); + return encodeSvmExecutePayload({ + targetProgram: program, + accounts: [...accounts], + ixData: instructionData, + instructionId: 2, + }); +} +function uintLE(data: Uint8Array): bigint { + return data.reduceRight((v, b) => (v << BigInt(8)) + BigInt(b), BigInt(0)); +} +/** Terms must have passed grant-shape validation. On-chain URP remains the enforcement authority. */ +export function validateAgwSvmPayload( + terms: SvmTermsWire, + recipient: Hex, + payload: Hex +): number { + exactHex(recipient, 32, 'recipient'); + if (BigInt(recipient) === BigInt(0)) fail('RecipientNotPubkey'); + const v = parseAgwSvmPayload(payload), + data = v.instructionData; + if (v.program.toLowerCase() !== recipient.toLowerCase()) + fail('RecipientTargetMismatch'); + if (isForbiddenSvmProgram(v.program, terms)) fail('ForbiddenTargetProgram'); + const rule = terms.programs.findIndex( + (p) => + p.program.toLowerCase() === v.program.toLowerCase() && + (p.dataless + ? data.length === 0 + : data.length >= p.discriminatorLen && + bytesToHex(data.slice(0, p.discriminatorLen)).toLowerCase() === + p.discriminator.slice(0, 2 + p.discriminatorLen * 2).toLowerCase()) + ); + if (rule < 0) fail('ProgramNotAllowed'); + const fixed = terms.programs[rule].maxAccounts; + if (fixed && fixed !== v.accounts.length) fail('SvmAccountCountMismatch'); + const pins = terms.pins.filter((p) => p.ruleIndex === rule); + for (const p of pins) { + if (p.accountIndex >= v.accounts.length) fail('SvmAccountCountBelowPin'); + if ( + v.accounts[p.accountIndex].pubkey.toLowerCase() !== + p.expected.toLowerCase() + ) + fail('SvmAccountPinMismatch'); + } + function field(fromEnd: boolean, offset: number, len: number): Uint8Array { + const start = fromEnd ? data.length - offset : offset; + if (start < 0 || start + len > data.length) fail('SvmDataTooShortForPin'); + return data.slice(start, start + len); + } + for (const p of terms.dataPins.filter((p) => p.ruleIndex === rule)) { + const a = field(p.fromEnd, p.offset, p.len); + if (p.mode === SvmDataPinMode.EQ) { + if ( + bytesToHex(a).toLowerCase() !== + p.expected.slice(0, 2 + p.len * 2).toLowerCase() + ) + fail('SvmDataPinMismatch'); + } else if (p.mode === SvmDataPinMode.GTE_LE) { + if (uintLE(a) < BigInt(p.expected)) fail('SvmDataFloorNotMet'); + } else if (p.mode === SvmDataPinMode.LTE_LE) { + if (uintLE(a) > BigInt(p.expected)) fail('SvmDataCeilingExceeded'); + } else if ( + uintLE(a) * p.den < + uintLE(field(p.fromEnd, p.offsetB, p.len)) * p.num + ) + fail('SvmDataRatioNotMet'); + } + const protectedKeys = new Set(terms.ceaAccounts.map((k) => k.toLowerCase())); + v.accounts.forEach((a, i) => { + if ( + protectedKeys.has(a.pubkey.toLowerCase()) && + !pins.some((p) => p.accountIndex === i) + ) + fail('CeaAccountAtUnpinnedIndex'); + }); + return rule; +} diff --git a/packages/core/src/lib/agentic/execution/svm-public.ts b/packages/core/src/lib/agentic/execution/svm-public.ts new file mode 100644 index 000000000..00c9b3479 --- /dev/null +++ b/packages/core/src/lib/agentic/execution/svm-public.ts @@ -0,0 +1,278 @@ +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import { emitAgentic } from '../management/common'; +import { bytesToHex, hexToBytes, erc20Abi, getAddress, type Hex } from 'viem'; +import type { CHAIN } from '../../constants/enums'; +import { + isPC20Reference, + type UniversalExecuteParams, + type TransactionExecutionOptions, +} from '../../orchestrator/orchestrator.types'; +import { resolveSvmCallForCea } from '../../orchestrator/svm-idl/resolve'; +import type { AgenticRuntime } from '../runtime'; +import type { AgenticExecutionContext } from '../context'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import { selectRuleForSend } from '../reads/rules'; +import { readSvmConfig } from '../reads/svm'; +import { Snapshot } from '../reads/snapshot'; +import { svmMetadata } from '../management/svm-public'; +import { wrapSendError } from '../management/common'; +import type { MoveableToken } from '../../constants/tokens'; +import { svmKey, deriveAgwSvmCea } from '../codec/svm-accounts'; +import { svmInvalid, UINT64_MAX } from '../codec/svm-terms'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { prepareSvmAgentExecution } from './svm-send'; +import { encodeAgwSvmPayload } from './svm-payload'; +import { quoteSvmRequest } from './svm-quote'; +import { buildAgwSvmAction } from './svm-outbound'; +import type { AgenticTxMetadata } from '../agentic.types'; +import { adaptAgenticResponse } from '../response'; + +/** Public single-instruction route; the contract enforces the supplied rule. */ +export async function sendPublicSvm( + runtime: AgenticRuntime, + actx: AgenticExecutionContext, + p: UniversalExecuteParams, + options?: TransactionExecutionOptions +) { + const gen = actx.generation, + wallet = actx.wallet; + requireCapability(gen.capabilities, AgenticCapability.UNIVERSAL_SVM_RULES); + requireCapability( + gen.capabilities, + actx.door === 'agent' + ? AgenticCapability.AGENT_EXECUTE + : AgenticCapability.OWNER_EXECUTE + ); + const to = p.to as { address: string; chain: CHAIN }; + const target = svmKey(to.address); + if (Array.isArray(p.data)) + throw svmInvalid('Solana AGW sends do not support instruction arrays'); + const hasInstruction = p.data !== undefined && p.data !== '0x'; + if ( + hasInstruction && + (typeof p.data !== 'string' || !/^0x(?:[0-9a-fA-F]{2})+$/.test(p.data)) + ) + throw svmInvalid('invalid Solana instruction bytes'); + if (!hasInstruction && actx.door === 'agent') + throw svmInvalid('Solana agent sends require an encoded instruction'); + if (p.funds && isPC20Reference(p.funds.token)) + throw svmInvalid('PC20 SVM outbound is not supported'); + if (p.funds && (p.value ?? BigInt(0)) !== BigInt(0)) + throw svmInvalid('use either funds or value for the bridged amount'); + const amount = p.funds?.amount ?? p.value ?? BigInt(0); + if (typeof amount !== 'bigint' || amount < BigInt(0) || amount > UINT64_MAX) + throw svmInvalid('Solana outbound amount must fit uint64'); + if (!hasInstruction && amount === BigInt(0)) + throw svmInvalid('a funds-only send requires a positive amount'); + const metadata = svmMetadata(runtime), + snap = await Snapshot.at(runtime.reader); + let rulesId: Hex | undefined, + data: Hex, + instruction: + | { + recipient: Hex; + instructionData: Uint8Array; + accounts: readonly { pubkey: Hex; isWritable: boolean }[]; + } + | undefined, + cea: Hex; + let destinationTransfer: AgenticTxMetadata['destinationTransfer']; + let transferValue = BigInt(0); + const gasLimit = p.gasLimit ?? BigInt(1_000_000); + if (actx.door === 'agent') { + const active = await selectRuleForSend( + snap, + gen, + wallet, + actx.signerPushAccount, + to.chain + ); + const stored = await readSvmConfig( + snap, + gen, + wallet, + active, + runtime.pushChainNamespace + ); + const token = + p.funds || amount > BigInt(0) + ? runtime.resolvePrc20( + p.funds?.token as MoveableToken | undefined, + to.chain + ) + : getAddress(stored.config.assets[0].token); + const prepared = await prepareSvmAgentExecution( + runtime, + gen, + { + wallet, + rulesId: active.rulesId, + token, + amount, + gasLimit, + program: target, + instructionData: hexToBytes(p.data as Hex), + }, + metadata + ); + data = prepared.data; + instruction = prepared.instruction; + cea = stored.config.expectedCEA; + rulesId = active.rulesId; + } else { + const gateway = await metadata.gateway(to.chain as `solana:${string}`); + cea = deriveAgwSvmCea(wallet, gateway.program).address; + const resolved = hasInstruction + ? resolveSvmCallForCea( + { + programAddress: target, + data: hexToBytes(p.data as Hex), + senderUea: wallet, + targetChain: to.chain, + }, + cea + ) + : undefined; + if (resolved) + instruction = { + recipient: resolved.targetProgram, + instructionData: resolved.ixData, + accounts: resolved.accounts, + }; + const token = runtime.resolvePrc20( + p.funds?.token as MoveableToken | undefined, + to.chain + ); + const quote = await quoteSvmRequest( + runtime, + snap, + wallet, + token, + to.chain, + gasLimit, + amount + ); + if (!hasInstruction) { + destinationTransfer = { recipient: target, token, amount }; + transferValue = quote.isNative ? amount : BigInt(0); + } + const [pc, balance, allowance] = await Promise.all([ + runtime.reader.getBalance({ + address: wallet, + blockNumber: snap.blockNumber, + }), + snap.read(token, erc20Abi, 'balanceOf', [wallet]), + snap.read(token, erc20Abi, 'allowance', [ + wallet, + gen.addresses.gateway, + ]), + ]); + const value = quote.protocolFee + quote.nativeValueForGas; + if (pc < value || balance < amount) + throw new AgenticError( + AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, + 'wallet cannot cover Solana outbound' + ); + if (allowance < amount) + throw new AgenticError( + AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, + 'set the wallet gateway allowance separately' + ); + const call = buildAgwSvmAction({ + wallet, + gateway: gen.addresses.gateway, + token, + amount, + recipient: target, + payload: resolved + ? encodeAgwSvmPayload( + resolved.targetProgram, + resolved.accounts, + resolved.ixData + ) + : '0x', + gasLimit: quote.gasLimitUsed, + protocolFee: quote.protocolFee, + maxPCForGas: quote.nativeValueForGas, + }); + data = gen.contracts.encodeExecute([call]); + } + const hook = options?.progressHook ?? p.progressHook; + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_107, + rulesId, + to.chain, + actx.door + ); + let response; + try { + response = await runtime.execute( + { + to: wallet, + value: BigInt(0), + data, + ...(p.nonce === undefined ? {} : { nonce: p.nonce }), + ...(p.maxFeePerGas === undefined + ? {} + : { maxFeePerGas: p.maxFeePerGas }), + ...(p.maxPriorityFeePerGas === undefined + ? {} + : { maxPriorityFeePerGas: p.maxPriorityFeePerGas }), + ...(p.deadline === undefined ? {} : { deadline: p.deadline }), + ...(p.feeLockTxHash === undefined + ? {} + : { feeLockTxHash: p.feeLockTxHash }), + }, + { ...options, progressHook: options?.progressHook ?? p.progressHook } + ); + } catch (error) { + const wrapped = wrapSendError(error) as AgenticError & { + decodedError?: unknown; + }; + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_02, + 'sendTransaction', + wrapped.code ?? 'UNKNOWN', + wrapped.message, + wrapped.decodedError + ); + throw wrapped; + } + const result = adaptAgenticResponse(response, { + wallet, + door: actx.door, + rulesId, + chainNamespace: to.chain, + chain: to.chain, + route: 'UOA_TO_CEA', + ...(instruction + ? { + destinationAccount: cea, + destinationInstruction: { + program: instruction.recipient, + data: bytesToHex(instruction.instructionData), + accounts: instruction.accounts, + }, + } + : { destinationTransfer }), + logical: instruction + ? { + to: instruction.recipient, + data: bytesToHex(instruction.instructionData), + value: BigInt(0), + } + : { to: target, data: '0x', value: transferValue }, + }); + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_01, + 'sendTransaction', + response.hash + ); + return result; +} diff --git a/packages/core/src/lib/agentic/execution/svm-quote.ts b/packages/core/src/lib/agentic/execution/svm-quote.ts new file mode 100644 index 000000000..f78fa563a --- /dev/null +++ b/packages/core/src/lib/agentic/execution/svm-quote.ts @@ -0,0 +1,51 @@ +import { PublicKey } from '@solana/web3.js'; +import { getAddress, hexToBytes, type Address } from 'viem'; +import type { CHAIN } from '../../constants/enums'; +import { + PRC20_SOURCE_ABI, + readGasPrc20, + readSvmSourceToken, +} from '../contracts/prc20-metadata'; +import type { AgenticRuntime } from '../runtime'; +import type { Snapshot } from '../reads/snapshot'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; + +/** Route/fee validation, independent of the selected rule's permissions. */ +export async function quoteSvmRequest( + runtime: AgenticRuntime, + snap: Snapshot, + wallet: Address, + token: Address, + chain: CHAIN, + gasLimit: bigint, + amount: bigint +) { + if ( + (await snap.read( + token, + PRC20_SOURCE_ABI, + 'SOURCE_CHAIN_NAMESPACE' + )) !== chain + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, + 'outbound asset belongs to another chain' + ); + } + const isNative = + getAddress(token) === getAddress(await readGasPrc20(snap, chain)); + const splMintBase58 = + !isNative && amount > BigInt(0) + ? new PublicKey( + hexToBytes( + await readSvmSourceToken(snap, token, chain, runtime.network) + ) + ).toBase58() + : undefined; + const quote = await runtime.quoteOutbound(token, gasLimit, chain, { + wallet, + splMintBase58, + burnAmount: amount, + }); + return { ...quote, isNative }; +} diff --git a/packages/core/src/lib/agentic/execution/svm-send.ts b/packages/core/src/lib/agentic/execution/svm-send.ts new file mode 100644 index 000000000..b28d80d78 --- /dev/null +++ b/packages/core/src/lib/agentic/execution/svm-send.ts @@ -0,0 +1,178 @@ +import { quoteSvmRequest } from './svm-quote'; +/** Prepared-wire agent execution used by the public Solana route. */ +import { getAddress, erc20Abi, type Address, type Hex } from 'viem'; +import { CHAIN } from '../../constants/enums'; +import type { AgenticGeneration } from '../deployments'; +import type { AgenticRuntime } from '../runtime'; +import { assertCanSign, wrapSendError } from '../management/common'; +import { Snapshot } from '../reads/snapshot'; +import { readSvmRule } from '../reads/svm'; +import { + resolveSvmContext, + type SvmMetadataProvider, +} from '../management/svm-context'; +import { buildAgwSvmAction } from './svm-outbound'; +import { resolveAgwSvmInstruction } from './svm-instruction'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { verifySvmWallet } from '../management/svm'; +import type { SvmPayloadAccount } from './svm-payload'; + +export interface PreparedSvmRequest { + wallet: Address; + rulesId: Hex; + token: Address; + amount: bigint; + gasLimit: bigint; + program: string; + instructionData: Uint8Array; + /** Already-resolved wire accounts for non-Anchor/dataless programs. */ + accounts?: readonly SvmPayloadAccount[]; +} + +export async function prepareSvmAgentExecution( + runtime: AgenticRuntime, + gen: AgenticGeneration, + input: PreparedSvmRequest, + metadata: SvmMetadataProvider +) { + assertCanSign(runtime, 'internal SVM send'); + await verifySvmWallet(runtime, gen, input.wallet); + const snap = await Snapshot.at(runtime.reader); + const stored = await readSvmRule( + snap, + gen, + input.wallet, + input.rulesId, + runtime.pushChainNamespace + ); + if (getAddress(stored.agent) !== getAddress(runtime.signerPushAccount())) + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_OWNER_OR_AGENT, + 'signer is not this SVM rule agent' + ); + const chain = stored.chainNamespace as CHAIN; + const resolved = await resolveSvmContext( + snap, + input.wallet, + stored.chainNamespace, + stored.config.assets, + metadata + ); + if ( + resolved.expectedCEA.toLowerCase() !== + stored.config.expectedCEA.toLowerCase() || + resolved.gatewayProgram.toLowerCase() !== + stored.config.gatewayProgram.toLowerCase() || + resolved.ceaAccounts.some( + (a) => + !stored.config.ceaAccounts.some( + (b) => a.toLowerCase() === b.toLowerCase() + ) + ) + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'stored SVM context does not cover the wallet and every asset' + ); + const instruction = resolveAgwSvmInstruction( + { + wallet: input.wallet, + chain, + program: input.program, + instructionData: input.instructionData, + accounts: input.accounts, + }, + stored.config + ); + const quote = await quoteSvmRequest( + runtime, + snap, + input.wallet, + getAddress(input.token), + chain, + input.gasLimit, + input.amount + ); + const call = buildAgwSvmAction({ + ...input, + gateway: gen.addresses.gateway, + recipient: instruction.recipient, + payload: instruction.payload, + gasLimit: quote.gasLimitUsed, + protocolFee: quote.protocolFee, + maxPCForGas: quote.nativeValueForGas, + }); + const walletPc = await runtime.reader.getBalance({ + address: input.wallet, + blockNumber: snap.blockNumber, + }); + if (walletPc < call.value) + throw new AgenticError( + AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, + 'wallet PC is below the SVM outbound quote' + ); + if (input.amount > BigInt(0)) { + const balance = await snap.read( + input.token, + erc20Abi, + 'balanceOf', + [input.wallet] + ); + if (balance < input.amount) + throw new AgenticError( + AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, + 'wallet token balance is below the SVM amount' + ); + const allowance = await snap.read( + input.token, + erc20Abi, + 'allowance', + [input.wallet, gen.addresses.gateway] + ); + if (allowance < input.amount) + throw new AgenticError( + AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, + 'owner must establish the SVM gateway allowance separately' + ); + } + const signerPc = await runtime.signerBalance(); + const gasReserve = (await runtime.getGasPrice()) * BigInt(10_000_000); + if ((await runtime.signerAccountDeployed()) && signerPc < gasReserve) + throw new AgenticError( + AGENTIC_ERROR_CODE.AGENT_GAS_INSUFFICIENT, + 'deployed SVM agent identity has no Push gas balance' + ); + return { + stored, + call, + instruction, + quote, + data: gen.contracts.encodeExecuteAsAgent(input.rulesId, call), + }; +} + +export async function sendSvmAgentWire( + runtime: AgenticRuntime, + gen: AgenticGeneration, + input: PreparedSvmRequest, + metadata: SvmMetadataProvider +) { + const prepared = await prepareSvmAgentExecution( + runtime, + gen, + input, + metadata + ); + try { + const tx = await runtime.execute({ + to: input.wallet, + value: BigInt(0), + data: prepared.data, + }); + // Return the ordinary signer response and explicit wire context internally. + // The public wrapper applies the response presentation. + return { tx, context: prepared.stored, instruction: prepared.instruction }; + } catch (error) { + throw wrapSendError(error); + } +} diff --git a/packages/core/src/lib/agentic/index.ts b/packages/core/src/lib/agentic/index.ts new file mode 100644 index 000000000..0bfceff36 --- /dev/null +++ b/packages/core/src/lib/agentic/index.ts @@ -0,0 +1,33 @@ +export type { + AgenticAddress, + AgenticHex, + AgenticDoor, + AgenticNamespace, + AgenticProgressHook, + AgenticTxMetadata, + AgenticWallet, + AllowedCall, + AssetCap, + Checkpoint, + CheckpointKind, + CreateOptions, + CreateResult, + ForeignChainNamespace, + NativeRule, + SolanaRule, + DecodedSolanaRule, + SvmInstructionRule, + SvmAccountRef, + SvmFieldConstraint, + Rule, + RulesRecord, + Selector, + UniversalRule, + WalletInfo, + WalletSummary, +} from './agentic.types'; +export { AGENTIC_ERROR_CODE, AgenticError, AgenticRevertError } from './errors'; +export type { AgenticErrorCode } from './errors'; +export { AGENTIC } from './constants'; +export type { AgenticNetworkConstants } from './constants'; +export { agenticUtils } from './utils'; diff --git a/packages/core/src/lib/agentic/management/common.ts b/packages/core/src/lib/agentic/management/common.ts new file mode 100644 index 000000000..5a67bd01a --- /dev/null +++ b/packages/core/src/lib/agentic/management/common.ts @@ -0,0 +1,145 @@ +import { getAddress, type Address, type Hex, type Log } from 'viem'; +import type { MultiCall, UniversalTxResponse } from '../../orchestrator/orchestrator.types'; +import { + PushChainBatchExecutionError, + PushChainExecutionError, + type DecodedErrorPayload, +} from '../../orchestrator/internals/errors'; +import PROGRESS_HOOKS from '../../progress-hook/progress-hook'; +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import type { AgenticGeneration } from '../deployments'; +import { AGENTIC_ERROR_CODE, AgenticError, AgenticRevertError } from '../errors'; +import type { AgenticRuntime } from '../runtime'; +import type { AgenticProgressHook } from '../agentic.types'; +import { Snapshot } from '../reads/snapshot'; +import { decodeAgenticRevert } from '../revert'; + +export function emitAgentic( + runtime: AgenticRuntime, + perCall: AgenticProgressHook | undefined, + id: PROGRESS_HOOK, + ...args: unknown[] +): void { + runtime.emit(PROGRESS_HOOKS[id](...args), perCall); +} + +export function assertCanSign(runtime: AgenticRuntime, operation: string): void { + if (runtime.isReadOnly) { + throw new AgenticError( + AGENTIC_ERROR_CODE.READ_ONLY, + `${operation} needs a signer; this client was initialized read-only` + ); + } +} + +/** Owner-only management writes: the connected signer's Push identity must be the stored owner. */ +export async function assertOwner( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + snap: Snapshot +): Promise
{ + const owner = getAddress(await snap.read
(wallet, gen.contracts.abis.wallet, 'owner')); + const signer = runtime.signerPushAccount(); + if (owner !== signer) { + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_WALLET_OWNER, + `${signer} is not the owner of ${wallet} (owner is ${owner})`, + { hint: 'Rule and label writes are owner-only. Initialize with the owner signer.' } + ); + } + return owner; +} + +/** + * Send owner calls through the signer's ordinary transaction path: one call + * as a plain transaction, several as the signer's batch (UEA multicall, + * EIP-7702 atomic batch, or the documented sequential fallback for a Push EOA + * without 7702 — reported through `atomic` / `transactionHashes`). + */ +export async function sendOwnerCalls( + runtime: AgenticRuntime, + calls: MultiCall[], + progressHook: AgenticProgressHook | undefined +): Promise { + if (calls.length === 0) throw new Error('sendOwnerCalls needs at least one call'); + const options = progressHook ? { progressHook } : undefined; + if (calls.length === 1) { + return runtime.execute({ to: calls[0].to, value: calls[0].value, data: calls[0].data }, options); + } + return runtime.execute({ to: calls[0].to, value: BigInt(0), data: calls }, options); +} + +/** + * Wait for every Push transaction the operation produced and return their + * logs in submission order. A reverted transaction raises AgenticRevertError + * carrying all hashes. + */ +export async function confirmedLogs( + runtime: AgenticRuntime, + tx: UniversalTxResponse +): Promise<{ logs: Log[]; hashes: Hex[] }> { + const hashes = (tx.transactionHashes ?? [tx.hash as Hex]) as Hex[]; + const unavailable = (cause: unknown) => + new AgenticError( + AGENTIC_ERROR_CODE.RECEIPT_UNAVAILABLE, + `submitted ${tx.hash} but could not read its receipt: ${cause instanceof Error ? cause.message : String(cause)}`, + { + hint: 'The transaction may still land. Check these hashes before retrying.', + details: { txHash: tx.hash, transactionHashes: hashes }, + cause, + } + ); + let receipt; + try { + receipt = await tx.wait(); + } catch (cause) { + throw unavailable(cause); + } + if (hashes.length === 1) { + if (receipt.status !== 1) { + throw new AgenticRevertError(`Push transaction ${tx.hash} reverted`, { + transactionHashes: hashes, + }); + } + return { logs: receipt.logs as Log[], hashes }; + } + const logs: Log[] = []; + for (const hash of hashes) { + let r; + try { + r = await runtime.reader.getTransactionReceipt({ hash }); + } catch (cause) { + throw unavailable(cause); + } + if (r.status !== 'success') { + throw new AgenticRevertError(`Push transaction ${hash} reverted`, { transactionHashes: hashes }); + } + logs.push(...(r.logs as Log[])); + } + return { logs, hashes }; +} + +/** Normalize a send failure: keep batch hashes and decoded revert data. */ +export function wrapSendError(err: unknown): unknown { + if (err instanceof AgenticError || err instanceof AgenticRevertError) return err; + const decoded = decodeAgenticRevert(err); + if (err instanceof PushChainBatchExecutionError) { + return new AgenticRevertError(err.message, { + decodedError: decoded ?? err.decodedError, + transactionHashes: err.transactionHashes, + pendingTransactionHash: err.pendingTransactionHash, + cause: err, + }); + } + if (decoded || err instanceof PushChainExecutionError) { + const e = err as { message?: string; gatewayTxHash?: string; decodedError?: DecodedErrorPayload }; + return new AgenticRevertError(e?.message ?? decoded?.name ?? 'AGW revert', { + decodedError: decoded ?? e?.decodedError, + gatewayTxHash: e?.gatewayTxHash, + agenticCode: decoded?.name, + cause: err, + }); + } + return err; +} diff --git a/packages/core/src/lib/agentic/management/create.ts b/packages/core/src/lib/agentic/management/create.ts new file mode 100644 index 000000000..3ed829509 --- /dev/null +++ b/packages/core/src/lib/agentic/management/create.ts @@ -0,0 +1,403 @@ +import { getAddress, type Address, type Hex, type Log } from 'viem'; +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import type { MultiCall } from '../../orchestrator/orchestrator.types'; +import { PushChainBatchExecutionError } from '../../orchestrator/internals/errors'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import { currentGeneration, verifyFactoryWiring } from '../deployments'; +import { deriveWallet, rulesId as computeRulesId } from '../codec/ids'; +import { prepareWalletRules } from './prepare'; +import { assertLabel } from './label'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { AgenticRuntime } from '../runtime'; +import type { CreateOptions, CreateResult } from '../agentic.types'; +import { Snapshot, scanLogs } from '../reads/snapshot'; +import { walletCount, walletInfo } from '../reads/wallets'; +import { + assertCanSign, + confirmedLogs, + emitAgentic, + sendOwnerCalls, + wrapSendError, +} from './common'; + +/** + * client.agentic.create(label, { rules }): deploy + grants only. Funding and + * approvals are separate ordinary sends. Every input is validated and every + * prediction re-read immediately before signing; IDs are returned from + * receipts in input order. + * + * Strategy follows the signer: an external owner's UEA batches atomically, a + * Push EOA uses an EIP-7702 batch when available and otherwise the documented + * sequential fallback. A sequential failure raises CREATE_PARTIAL with the + * deployed wallet, confirmed/pending hashes and granted IDs so the caller can + * finish with `wallet(w).rules.add(remaining)` — the SDK never deploys another + * wallet on its own. + */ +export async function createWallet( + runtime: AgenticRuntime, + label: string, + options: CreateOptions +): Promise { + const hook = options?.progressHook; + assertLabel(label); + if (!options || !Array.isArray(options.rules)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'options.rules is required (use [] for a bare wallet)' + ); + } + assertCanSign(runtime, 'agentic.create'); + const gen = currentGeneration(runtime.network); + requireCapability(gen.capabilities, AgenticCapability.WALLET_READS); + await verifyFactoryWiring(runtime.reader, gen); + + const owner = runtime.signerPushAccount(); + const snap = await Snapshot.at(runtime.reader); + const index = await walletCount(snap, gen, owner); + const [predicted, deployed] = await snap.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'predictWallet', + [owner, index] + ); + const wallet = getAddress(predicted); + const mirror = deriveWallet({ + factory: gen.addresses.factory, + walletImplementation: gen.addresses.walletImplementation, + owner, + index, + }); + if (wallet !== mirror || deployed || (await snap.code(wallet))) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INDEX_RACE, + `next wallet slot ${index} for ${owner} is not a fresh counterfactual address`, + { details: { wallet, mirror, deployed } } + ); + } + const prepared = await prepareWalletRules( + runtime, + gen, + owner, + wallet, + options.rules, + snap + ); + const selfTarget = prepared.findIndex( + (p) => p.nativeTerms?.target === wallet + ); + if (selfTarget >= 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `rules[${selfTarget}].target is the wallet being created; the contracts refuse it as a native target` + ); + } + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_101, + wallet, + Number(index), + false + ); + + // A fresh wallet's grant nonce starts at 0 and increments per grant. + const predictedIds = prepared.map((p, i) => + computeRulesId({ + validator: gen.addresses.sessionValidator, + agent: p.agent, + grantNonce: BigInt(i), + }) + ); + const calls: MultiCall[] = [ + // Index-bound: reverts (nothing committed) if another creation took this slot. + { + to: gen.addresses.factory, + value: BigInt(0), + data: gen.contracts.encodeDeployWalletAt(owner, index, wallet, label), + }, + ...prepared.map((p) => ({ + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeGrantRules(p.session), + })), + ]; + const strategy = + calls.length === 1 + ? 'single' + : runtime.signerIsPushNative() + ? 'push-eoa-batch' + : 'uea-multicall'; + emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_102, wallet, strategy); + + let tx; + try { + tx = await sendOwnerCalls(runtime, calls, hook); + } catch (err) { + const wrapped = await createFailure( + runtime, + gen, + wallet, + Number(index), + owner, + label, + err + ); + emitFailure(runtime, hook, wrapped); + throw wrapped; + } + + let logs; + try { + ({ logs } = await confirmedLogs(runtime, tx)); + } catch (err) { + const wrapped = await createFailure( + runtime, + gen, + wallet, + Number(index), + owner, + label, + err + ); + emitFailure(runtime, hook, wrapped); + throw wrapped; + } + const deployedEv = gen.contracts.parseWalletDeployed( + logs, + gen.addresses.factory + ); + const granted = gen.contracts + .parseRulesGranted(logs, wallet) + .map((g) => g.rulesId); + if ( + deployedEv.length !== 1 || + deployedEv[0].wallet !== wallet || + deployedEv[0].index !== index + ) { + const err = new AgenticError( + AGENTIC_ERROR_CODE.INDEX_RACE, + `deployWallet produced ${ + deployedEv.map((d) => `${d.wallet}#${d.index}`).join(', ') || + 'no wallet' + }, expected ${wallet}#${index}; grants targeted ${wallet}`, + { + hint: 'Another deployment by this owner raced this call. Inspect both wallets with client.agentic.list() before retrying.', + details: { + expected: wallet, + deployed: deployedEv, + grantedRulesIds: granted, + txHash: tx.hash, + }, + } + ); + emitFailure(runtime, hook, err); + throw err; + } + if (!sameIds(granted, predictedIds)) { + const err = new AgenticError( + AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, + 'RulesGranted events do not match the requested rules in order', + { details: { granted, predicted: predictedIds, txHash: tx.hash } } + ); + emitFailure(runtime, hook, err); + throw err; + } + if (granted.length > 0) + emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_104, granted); + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_01, + 'create', + tx.hash + ); + return { + wallet, + index: Number(index), + rulesIds: granted as `0x${string}`[], + tx, + }; +} + +function sameIds(a: readonly Hex[], b: readonly Hex[]): boolean { + return ( + a.length === b.length && + a.every((x, i) => x.toLowerCase() === b[i].toLowerCase()) + ); +} + +const RACE_ERRORS = new Set(['IndexMismatch', 'IntentWalletMismatch']); + +/** + * Turn a create failure into an exact report. What committed is derived from + * THIS operation's own receipts, never from a later state read; when those + * receipts cannot be fetched the outcome is reported as unknown so callers do + * not retry a creation that may have landed. + */ +async function createFailure( + runtime: AgenticRuntime, + gen: ReturnType, + wallet: Address, + index: number, + owner: Address, + label: string, + err: unknown +): Promise { + const wrapped = wrapSendError(err); + const batch = err instanceof PushChainBatchExecutionError ? err : undefined; + const confirmed = (batch?.transactionHashes ?? []) as Hex[]; + const pending = batch?.pendingTransactionHash; + const receiptUnavailable = + wrapped instanceof AgenticError && + wrapped.code === AGENTIC_ERROR_CODE.RECEIPT_UNAVAILABLE; + + // The index-bound deploy reverted: another creation took the slot first and + // nothing from this call committed. + const decodedName = (wrapped as { decodedError?: { name?: string } }) + .decodedError?.name; + if ( + confirmed.length === 0 && + !pending && + decodedName && + RACE_ERRORS.has(decodedName) + ) { + return new AgenticError( + AGENTIC_ERROR_CODE.INDEX_RACE, + `wallet slot ${index} (${wallet}) was taken by a concurrent creation; nothing was committed`, + { + hint: 'Retry agentic.create; it re-reads the next slot.', + details: { wallet, index, committed: false }, + cause: wrapped, + } + ); + } + if (confirmed.length === 0 && !pending && !receiptUnavailable) { + // Some send paths flatten the factory revert to a generic execution + // error, losing the IndexMismatch selector. If the owner's next index has + // moved and immutable deployment metadata proves another label owns the + // predicted slot, this call could not have deployed or granted. + try { + const snap = await Snapshot.at(runtime.reader); + const next = await walletCount(snap, gen, owner); + if (next > BigInt(index)) { + const competing = await walletInfo(runtime.reader, gen, wallet); + // label() is mutable in v5: only the immutable deploy event proves + // which creation occupied this slot. A later rename proves nothing. + const logs = await scanLogs( + runtime.reader, + { + address: gen.addresses.factory, + event: gen.contracts.events.walletDeployed, + args: { wallet }, + }, + gen.startBlock, + snap.blockNumber + ); + const deployed = gen.contracts + .parseWalletDeployed(logs, gen.addresses.factory) + .find((entry) => entry.wallet === wallet); + const competingLabel = deployed?.label; + if ( + competing.deployed && + competing.index === index && + getAddress(competing.owner) === owner && + competingLabel !== undefined && + competingLabel !== label + ) { + return new AgenticError( + AGENTIC_ERROR_CODE.INDEX_RACE, + 'wallet slot ' + index + ' was consumed by a concurrent deployment', + { + hint: + 'Slot ' + + index + + ' belongs to "' + + competingLabel + + '". No wallet or rules from this create call were committed; retry agentic.create to use the next slot.', + details: { + wallet, + index, + committed: false, + competingLabel, + }, + cause: wrapped, + } + ); + } + } + } catch { + // If the owner counter/event cannot be reconciled, preserve the original + // error; never infer that a retry is safe from an incomplete read. + } + // Nothing was confirmed (pre-broadcast failure or a reverted atomic batch). + return wrapped; + } + + let walletDeployed: boolean | 'unknown' = 'unknown'; + let grantedRulesIds: Hex[] | undefined; + let recoveryError: string | undefined; + if (!receiptUnavailable) { + try { + const logs: Log[] = []; + for (const hash of confirmed) { + const r = await runtime.reader.getTransactionReceipt({ hash }); + if (r.status === 'success') logs.push(...(r.logs as Log[])); + } + const deployed = gen.contracts + .parseWalletDeployed(logs, gen.addresses.factory) + .some((d) => d.wallet === wallet); + grantedRulesIds = gen.contracts + .parseRulesGranted(logs, wallet) + .map((g) => g.rulesId); + // A pending hash may itself be the deployment; only a confirmed deploy is certain. + walletDeployed = deployed ? true : pending ? 'unknown' : false; + } catch (e) { + recoveryError = e instanceof Error ? e.message : String(e); + } + } + const hint = + walletDeployed === true + ? `Wallet ${wallet} is deployed. Do not call create again; once any pending hash settles, finish with client.agentic.wallet('${wallet}').rules.add().` + : walletDeployed === false + ? 'No wallet was deployed by this call; it is safe to retry.' + : 'The outcome is unknown. Do not retry create until the listed hashes are reconciled (client.agentic.list() shows deployed wallets).'; + return new AgenticError( + AGENTIC_ERROR_CODE.CREATE_PARTIAL, + `agentic.create did not complete: ${ + (wrapped as Error)?.message ?? String(err) + }`, + { + hint, + details: { + wallet, + index, + walletDeployed, + grantedRulesIds, + confirmedHashes: confirmed, + pendingHash: pending, + ...(receiptUnavailable + ? { submitted: (wrapped as AgenticError).details } + : {}), + ...(recoveryError ? { recoveryError } : {}), + }, + cause: wrapped, + } + ); +} + +function emitFailure( + runtime: AgenticRuntime, + hook: CreateOptions['progressHook'], + err: unknown +): void { + const e = err as { code?: string; message?: string; decodedError?: unknown }; + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_02, + 'create', + e?.code ?? 'UNKNOWN', + e?.message ?? String(err), + e?.decodedError + ); +} diff --git a/packages/core/src/lib/agentic/management/label.ts b/packages/core/src/lib/agentic/management/label.ts new file mode 100644 index 000000000..a63e2b302 --- /dev/null +++ b/packages/core/src/lib/agentic/management/label.ts @@ -0,0 +1,21 @@ +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; + +/** AGW MAX_LABEL_BYTES. Empty resets the default; lengths count UTF-8 bytes. */ +export const MAX_LABEL_BYTES = 64; + +export function assertLabel(label: string): void { + if (typeof label !== 'string') { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'label must be a string' + ); + } + const length = new TextEncoder().encode(label).length; + if (length > MAX_LABEL_BYTES) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `label must be at most ${MAX_LABEL_BYTES} UTF-8 bytes (received ${length})`, + { details: { length, maxLength: MAX_LABEL_BYTES } } + ); + } +} diff --git a/packages/core/src/lib/agentic/management/prepare.ts b/packages/core/src/lib/agentic/management/prepare.ts new file mode 100644 index 000000000..d20196295 --- /dev/null +++ b/packages/core/src/lib/agentic/management/prepare.ts @@ -0,0 +1,144 @@ +import { preparePublicSvmRule } from './svm-public'; +import type { SvmTermsWire } from '../codec/svm-terms'; +import { getAddress, zeroAddress, type Address } from 'viem'; +import { CHAIN, VM } from '../../constants/enums'; +import { CHAIN_INFO } from '../../constants/chain'; +import { AGENTIC_DEFAULTS, withDefault } from '../codec/defaults'; +import { isUniversalRule, isSolanaRule, prepareRules } from '../codec/rules'; +import { + validateUniversalRuleShape, + type ResolvedUniversalRule, +} from '../codec/universal'; +import { + forbiddenTargets, + verifyPolicyVersion, + type AgenticGeneration, +} from '../deployments'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { Rule, UniversalRule, SolanaRule } from '../agentic.types'; +import type { AgenticRuntime } from '../runtime'; +import { Snapshot } from '../reads/snapshot'; + +import { + PRC20_SOURCE_ABI, + readGasPrc20, + readOriginToken, +} from '../contracts/prc20-metadata'; + +/** Resolve wallet-dependent wire inputs without signing or mutating authority. */ +export async function prepareWalletRules( + runtime: AgenticRuntime, + gen: AgenticGeneration, + owner: Address, + wallet: Address, + rules: readonly Rule[], + snap: Snapshot +) { + if (rules.length) await verifyPolicyVersion(runtime.reader, gen); + const svm = new Map(); + const universal = new Map(); + for (const rule of rules) { + if (rule && isSolanaRule(rule)) { + requireCapability( + gen.capabilities, + AgenticCapability.UNIVERSAL_SVM_RULES + ); + svm.set(rule, await preparePublicSvmRule(runtime, snap, wallet, rule)); + continue; + } + if (!isUniversalRule(rule)) continue; + if (rule.chainNamespace.startsWith('solana:')) + requireCapability( + gen.capabilities, + AgenticCapability.UNIVERSAL_SVM_RULES + ); + validateUniversalRuleShape(rule, runtime.nowSeconds()); + requireCapability( + gen.capabilities, + rule.chainNamespace.startsWith('solana:') + ? AgenticCapability.UNIVERSAL_SVM_RULES + : AgenticCapability.UNIVERSAL_EVM_RULES + ); + const chain = rule.chainNamespace as CHAIN; + if (CHAIN_INFO[chain]?.vm !== VM.EVM) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `unsupported EVM rule chain ${chain}` + ); + const { cea } = await runtime.resolveCEA(wallet, chain); + const assets = rule.assets.length + ? rule.assets.map((a) => ({ + token: + typeof a.token === 'string' && getAddress(a.token) === zeroAddress + ? undefined + : a.token, + maxPerCall: a.maxPerCall, + maxTotal: withDefault( + a.maxTotal, + AGENTIC_DEFAULTS.universal.assetMaxTotal + ), + })) + : [{ token: undefined, maxPerCall: BigInt(0), maxTotal: BigInt(0) }]; + const resolved = []; + for (const asset of assets) { + const token = + asset.token === undefined + ? await readGasPrc20(snap, chain) + : runtime.resolvePrc20(asset.token, chain); + if (token === zeroAddress) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `no gas PRC20 registered for ${chain}` + ); + const namespace = await snap.read( + token, + PRC20_SOURCE_ABI, + 'SOURCE_CHAIN_NAMESPACE' + ); + if (namespace !== chain) + throw new AgenticError( + AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, + `PRC20 ${token} belongs to ${namespace}, not ${chain}` + ); + if (asset.token !== undefined) { + const expected = + typeof asset.token === 'string' + ? getAddress(asset.token) + : getAddress(asset.token.address); + if (expected === zeroAddress) { + if ((await readGasPrc20(snap, chain)) !== getAddress(token)) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'resolved token is not the registered native gas token' + ); + } else { + const source = await readOriginToken(snap, token, chain); + if (source !== expected) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `PRC20 ${token} represents ${source}, not requested token ${expected}` + ); + } + } + resolved.push({ + token: getAddress(token), + maxPerCall: asset.maxPerCall, + maxTotal: asset.maxTotal, + }); + } + universal.set(rule, { expectedCEA: getAddress(cea), assets: resolved }); + } + return prepareRules(rules, { + owner, + pushChainNamespace: runtime.pushChainNamespace, + validator: gen.addresses.sessionValidator, + rulesPolicy: gen.addresses.rulesPolicy, + gateway: gen.addresses.gateway, + capabilities: gen.capabilities, + nowSeconds: runtime.nowSeconds(), + forbiddenTargets: forbiddenTargets(gen, wallet), + universal, + svm, + }); +} diff --git a/packages/core/src/lib/agentic/management/rules-write.ts b/packages/core/src/lib/agentic/management/rules-write.ts new file mode 100644 index 000000000..96d5fcb75 --- /dev/null +++ b/packages/core/src/lib/agentic/management/rules-write.ts @@ -0,0 +1,451 @@ +import type { Address, Hex } from 'viem'; +import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; +import type { + MultiCall, + UniversalTxResponse, +} from '../../orchestrator/orchestrator.types'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import { type AgenticGeneration } from '../deployments'; +import { rulesId as computeRulesId } from '../codec/ids'; +import { prepareWalletRules } from './prepare'; +import { assertLabel } from './label'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { AgenticRuntime } from '../runtime'; +import type { AgenticHex, AgenticProgressHook, Rule } from '../agentic.types'; +import { + readActiveRules, + readNativeSpend, + readUniversalSpend, +} from '../reads/rules'; +import { Snapshot } from '../reads/snapshot'; +import { + assertCanSign, + assertOwner, + confirmedLogs, + emitAgentic, + sendOwnerCalls, + wrapSendError, +} from './common'; + +const MODE_NATIVE = 1; + +async function finish( + runtime: AgenticRuntime, + hook: AgenticProgressHook | undefined, + operation: string, + run: () => Promise +): Promise { + try { + const result = await run(); + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_01, + operation, + result.tx.hash + ); + return result; + } catch (err) { + const wrapped = wrapSendError(err) as { + code?: string; + message?: string; + decodedError?: unknown; + }; + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_199_02, + operation, + wrapped?.code ?? 'UNKNOWN', + wrapped?.message ?? String(err), + wrapped?.decodedError + ); + throw wrapped; + } +} + +/** w.rules.add(rules): owner grants on an existing wallet, IDs in input order. */ +export async function addRules( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + rules: readonly Rule[], + hook?: AgenticProgressHook +): Promise<{ rulesIds: AgenticHex[]; tx: UniversalTxResponse }> { + assertCanSign(runtime, 'rules.add'); + if (!Array.isArray(rules) || rules.length === 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'rules.add needs at least one rule' + ); + } + const snap = await Snapshot.at(runtime.reader); + const owner = await assertOwner(runtime, gen, wallet, snap); + const prepared = await prepareWalletRules( + runtime, + gen, + owner, + wallet, + rules, + snap + ); + const nonce = BigInt( + await snap.read(wallet, gen.contracts.abis.wallet, 'grantNonce') + ); + const predicted = prepared.map((p, i) => + computeRulesId({ + validator: gen.addresses.sessionValidator, + agent: p.agent, + grantNonce: nonce + BigInt(i), + }) + ); + // One rule: a direct owner grant (one RULES_GRANTED tick). Several: one + // owner `execute` batch of self-calls, so the grants are atomic and need a + // single signature on every signer type (each grant then also ticks one + // OWNER_ACTION before its RULES_GRANTED). + const grants = prepared.map((p) => gen.contracts.encodeGrantRules(p.session)); + const calls: MultiCall[] = + grants.length === 1 + ? [{ to: wallet, value: BigInt(0), data: grants[0] }] + : [ + { + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeExecute( + grants.map((data) => ({ target: wallet, value: BigInt(0), data })) + ), + }, + ]; + return finish(runtime, hook, 'rules.add', async () => { + const tx = await sendOwnerCalls(runtime, calls, hook); + const { logs } = await confirmedLogs(runtime, tx); + const granted = gen.contracts + .parseRulesGranted(logs, wallet) + .map((g) => g.rulesId as AgenticHex); + if (!sameIds(granted, predicted)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, + 'RulesGranted events do not match the requested rules in order (a concurrent grant may have raced)', + { details: { granted, predicted, txHash: tx.hash } } + ); + } + emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_104, granted); + return { rulesIds: granted, tx }; + }); +} + +/** Bare revoke (no IDs, empty list, or anything but { all: true }) is refused. */ +export function assertRevokeTarget(target: unknown): { + all: boolean; + ids?: AgenticHex[]; +} { + const all = + !!target && + !Array.isArray(target) && + (target as { all?: unknown }).all === true; + const ids = Array.isArray(target) ? (target as AgenticHex[]) : undefined; + if (!all && (!ids || ids.length === 0)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET, + 'rules.revoke needs explicit rule IDs or { all: true }', + { + hint: 'A bare revoke that wipes every rule is too easy to make by mistake.', + } + ); + } + return { all, ids }; +} + +/** w.rules.revoke(ids) / w.rules.revoke({ all: true }). Bare revoke is refused. */ +export async function revokeRules( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + target: + | AgenticHex[] + | { all: true; progressHook?: AgenticProgressHook } + | undefined, + hook?: AgenticProgressHook +): Promise { + const { all, ids } = assertRevokeTarget(target); + if (all) + hook = + (target as { progressHook?: AgenticProgressHook }).progressHook ?? hook; + assertCanSign(runtime, 'rules.revoke'); + const snap = await Snapshot.at(runtime.reader); + await assertOwner(runtime, gen, wallet, snap); + + let calls: MultiCall[]; + if (all) { + calls = [ + { + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeRevokeAllRules(), + }, + ]; + } else { + const unique = new Set((ids as Hex[]).map((i) => i.toLowerCase())); + if (unique.size !== (ids as Hex[]).length) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'rules.revoke got the same rule ID twice' + ); + } + for (const id of ids as Hex[]) { + const enabled = await snap.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'isPermissionEnabled', + [id, wallet] + ); + if (!enabled) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_NOT_FOUND, + `rule ${id} is not enabled on ${wallet}` + ); + } + } + // Several IDs go through one owner `execute` batch so revocation is atomic + // regardless of signer batching support. + calls = + (ids as Hex[]).length === 1 + ? [ + { + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeRevokeRules((ids as Hex[])[0]), + }, + ] + : [ + { + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeExecute( + (ids as Hex[]).map((id) => ({ + target: wallet, + value: BigInt(0), + data: gen.contracts.encodeRevokeRules(id), + })) + ), + }, + ]; + } + const result = await finish(runtime, hook, 'rules.revoke', async () => { + const tx = await sendOwnerCalls(runtime, calls, hook); + const { logs } = await confirmedLogs(runtime, tx); + const revoked = gen.contracts + .parseRulesRevoked(logs, wallet) + .map((r) => r.rulesId); + emitAgentic( + runtime, + hook, + PROGRESS_HOOK.AGENTIC_TX_105, + all ? { all: true } : { rulesIds: revoked } + ); + return { tx }; + }); + return result.tx; +} + +/** + * w.rules.update({ rules: [{ rulesId, rule }] }): for every pair, inside ONE + * owner `execute` batch: assertSpent(expected) → revokeRules(old) → + * grantRules(new). Never falls back to sequential sends. An agent spending + * between the read and inclusion makes the whole batch revert; retry needs a + * fresh read and a new owner signature. New counters start at zero. + */ +export async function updateRules( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + params: { + rules: { rulesId: AgenticHex; rule: Rule }[]; + progressHook?: AgenticProgressHook; + } +): Promise<{ + rules: { rulesId: AgenticHex; replaced: AgenticHex }[]; + tx: UniversalTxResponse; +}> { + const hook = params?.progressHook; + assertCanSign(runtime, 'rules.update'); + if (!params || !Array.isArray(params.rules) || params.rules.length === 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'rules.update needs at least one { rulesId, rule } pair' + ); + } + requireCapability(gen.capabilities, AgenticCapability.OWNER_EXECUTE); + const oldIds = params.rules.map((p) => p.rulesId.toLowerCase()); + if (new Set(oldIds).size !== oldIds.length) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'rules.update names the same old rule twice' + ); + } + + const snap = await Snapshot.at(runtime.reader); + const owner = await assertOwner(runtime, gen, wallet, snap); + const active = await readActiveRules(snap, gen, wallet); + const byId = new Map(active.map((r) => [r.rulesId.toLowerCase(), r])); + + const assertions: { configId: Hex; data: Hex }[][] = []; + for (const pair of params.rules) { + const old = byId.get(pair.rulesId.toLowerCase()); + if (!old) + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_NOT_FOUND, + `rule ${pair.rulesId} is not enabled on ${wallet}` + ); + if (old.mode === MODE_NATIVE) { + requireCapability( + gen.capabilities, + AgenticCapability.ASSERT_SPENT_NATIVE + ); + const guards = []; + for (const action of old.actionIds) { + const spend = await readNativeSpend(snap, gen, wallet, old, action); + guards.push({ + configId: spend.configId, + data: gen.contracts.encodeAssertSpentNative( + spend.configId, + wallet, + spend + ), + }); + } + assertions.push(guards); + } else { + requireCapability( + gen.capabilities, + AgenticCapability.ASSERT_SPENT_UNIVERSAL_MULTI + ); + const spend = await readUniversalSpend(snap, gen, wallet, old); + assertions.push([ + { + configId: spend.configId, + data: gen.contracts.encodeAssertSpentUniversal( + spend.configId, + wallet, + spend.expectedSpent + ), + }, + ]); + } + } + const prepared = await prepareWalletRules( + runtime, + gen, + owner, + wallet, + params.rules.map((p) => p.rule), + snap + ); + const nonce = BigInt( + await snap.read(wallet, gen.contracts.abis.wallet, 'grantNonce') + ); + const predicted = prepared.map((p, i) => + computeRulesId({ + validator: gen.addresses.sessionValidator, + agent: p.agent, + grantNonce: nonce + BigInt(i), + }) + ); + + const batch = params.rules.flatMap((pair, i) => [ + ...assertions[i].map((a) => ({ + target: gen.addresses.rulesPolicy, + value: BigInt(0), + data: a.data, + })), + { + target: wallet, + value: BigInt(0), + data: gen.contracts.encodeRevokeRules(pair.rulesId), + }, + { + target: wallet, + value: BigInt(0), + data: gen.contracts.encodeGrantRules(prepared[i].session), + }, + ]); + const call: MultiCall = { + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeExecute(batch), + }; + + return finish(runtime, hook, 'rules.update', async () => { + const tx = await sendOwnerCalls(runtime, [call], hook); + const { logs } = await confirmedLogs(runtime, tx); + const granted = gen.contracts + .parseRulesGranted(logs, wallet) + .map((g) => g.rulesId as AgenticHex); + const revoked = gen.contracts + .parseRulesRevoked(logs, wallet) + .map((r) => r.rulesId.toLowerCase()); + if ( + !sameIds(granted, predicted) || + !sameIds(revoked as Hex[], oldIds as Hex[]) + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, + 'rules.update receipt does not show the expected revoke/grant pairs', + { details: { granted, predicted, revoked, txHash: tx.hash } } + ); + } + emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_105, { + rulesIds: params.rules.map((p) => p.rulesId), + }); + emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_104, granted); + return { + tx, + rules: granted.map((id, i) => ({ + rulesId: id, + replaced: params.rules[i].rulesId, + })), + }; + }); +} + +function sameIds(a: readonly Hex[], b: readonly Hex[]): boolean { + return ( + a.length === b.length && + a.every((x, i) => x.toLowerCase() === b[i].toLowerCase()) + ); +} + +/** Owner rename through the owner door; empty resets the default label. */ +export async function setLabel( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + label: string, + hook?: AgenticProgressHook +): Promise { + assertLabel(label); + assertCanSign(runtime, 'setLabel'); + requireCapability(gen.capabilities, AgenticCapability.SET_LABEL); + return finish(runtime, hook, 'setLabel', async () => { + await assertOwner(runtime, gen, wallet, await Snapshot.at(runtime.reader)); + const tx = await sendOwnerCalls( + runtime, + [ + { + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeExecute([ + { + target: wallet, + value: BigInt(0), + data: gen.contracts.encodeSetLabel(label), + }, + ]), + }, + ], + hook + ); + await confirmedLogs(runtime, tx); + return { tx }; + }).then(({ tx }) => tx); +} diff --git a/packages/core/src/lib/agentic/management/svm-context.ts b/packages/core/src/lib/agentic/management/svm-context.ts new file mode 100644 index 000000000..2187712c9 --- /dev/null +++ b/packages/core/src/lib/agentic/management/svm-context.ts @@ -0,0 +1,115 @@ +/** Internal resolved context; no public Rule schema or cluster assumptions. */ +import { getAddress, type Address, type Hex } from 'viem'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { + PRC20_SOURCE_ABI, + readGasPrc20, + readSvmSourceToken, +} from '../contracts/prc20-metadata'; +import { + svmKey, + resolveAgwSvmRuleContext, + type SvmTokenAccountInput, + type ResolvedSvmAsset, +} from '../codec/svm-accounts'; +import type { AssetCapWire } from '../codec/universal-terms'; +import type { Snapshot } from '../reads/snapshot'; + +export interface SvmMetadataProvider { + /** Must identify the requested full CAIP-2 cluster; no global gateway default. */ + gateway( + chainNamespace: `solana:${string}` + ): Promise<{ chainNamespace: string; program: string }>; + /** RPC-backed mint owner (legacy SPL or Token-2022), not an assumed default. */ + mint( + chainNamespace: `solana:${string}`, + address: Hex + ): Promise<{ mint: string; tokenProgram: string }>; +} + +const ZERO_KEY = `0x${'00'.repeat(32)}` as Hex; + +export async function resolveSvmAssets( + snap: Snapshot, + chain: `solana:${string}`, + caps: readonly AssetCapWire[], + metadata: SvmMetadataProvider, + network: PUSH_NETWORK = PUSH_NETWORK.TESTNET_DONUT +): Promise { + const gas = await readGasPrc20(snap, chain); + const out: ResolvedSvmAsset[] = []; + for (const cap of caps) { + const token = getAddress(cap.token); + const namespace = await snap.read( + token, + PRC20_SOURCE_ABI, + 'SOURCE_CHAIN_NAMESPACE' + ); + if (namespace !== chain) + throw new AgenticError( + AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, + `PRC20 ${token} belongs to ${namespace}, not ${chain}` + ); + const source = await readSvmSourceToken(snap, token, chain, network); + const native = source === ZERO_KEY; + if (native) { + if (token !== gas) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'native marker is not the registered gas PRC20' + ); + out.push({ kind: 'native', cap: { ...cap, token } }); + } else { + const key = svmKey(source); + const mint = await metadata.mint(chain, key); + if (svmKey(mint.mint) !== key) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'mint resolver returned a different mint' + ); + out.push({ kind: 'spl', cap: { ...cap, token }, ...mint }); + } + } + return out; +} + +export async function resolveSvmContext( + snap: Snapshot, + wallet: Address, + chain: `solana:${string}`, + caps: readonly AssetCapWire[], + metadata: SvmMetadataProvider, + outputs: readonly SvmTokenAccountInput[] = [], + network: PUSH_NETWORK = PUSH_NETWORK.TESTNET_DONUT +) { + const gateway = await metadata.gateway(chain); + if (gateway.chainNamespace !== chain) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'gateway registry cluster mismatch' + ); + const assets = await resolveSvmAssets(snap, chain, caps, metadata, network); + const resolvedOutputs = []; + for (const output of outputs) { + const key = svmKey(output.mint); + const resolved = await metadata.mint(chain, key); + if ( + svmKey(resolved.mint) !== key || + (output.tokenProgram && + svmKey(output.tokenProgram) !== svmKey(resolved.tokenProgram)) + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'output mint/token-program mismatch' + ); + resolvedOutputs.push(resolved); + } + // Derivation validates token programs and covers every mint/output. + return resolveAgwSvmRuleContext( + wallet, + gateway.program, + assets, + resolvedOutputs + ); +} diff --git a/packages/core/src/lib/agentic/management/svm-metadata.ts b/packages/core/src/lib/agentic/management/svm-metadata.ts new file mode 100644 index 000000000..82aa0befe --- /dev/null +++ b/packages/core/src/lib/agentic/management/svm-metadata.ts @@ -0,0 +1,75 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { svmKey } from '../codec/svm-accounts'; +import { svmInvalid } from '../codec/svm-terms'; +import type { SvmMetadataProvider } from './svm-context'; +import { AgenticError, AGENTIC_ERROR_CODE } from '../errors'; + +export interface SvmClusterConfig { + gatewayProgram: string; + rpcUrls: readonly string[]; +} +type MintRpc = Pick; +const SPL = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const TOKEN_2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; + +/** Explicit cluster configuration; HTTP reads only, no signing/subscriptions. */ +export function createSvmMetadataProvider( + clusters: Readonly>>, + connect: (url: string) => MintRpc = (url) => new Connection(url, 'confirmed') +): SvmMetadataProvider { + function config(chain: `solana:${string}`) { + const c = clusters[chain]; + if (!c || !c.rpcUrls.length) + throw svmInvalid(`no SVM registry/RPC configuration for ${chain}`); + svmKey(c.gatewayProgram); + return c; + } + async function read( + chain: `solana:${string}`, + fn: (rpc: MintRpc) => Promise + ): Promise { + const c = config(chain); + let last: unknown; + for (const url of c.rpcUrls) { + try { + const rpc = connect(url); + // CAIP-2 Solana references are the first 32 characters of genesisHash. + if (`solana:${(await rpc.getGenesisHash()).slice(0, 32)}` !== chain) + throw svmInvalid('SVM RPC genesis/cluster mismatch'); + return await fn(rpc); + } catch (error) { + last = error; + } + } + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `SVM metadata lookup failed for ${chain}`, + { cause: last } + ); + } + return { + gateway: async (chain) => + read(chain, async () => ({ + chainNamespace: chain, + program: config(chain).gatewayProgram, + })), + mint: async (chain, address) => + read(chain, async (rpc) => { + const mint = new PublicKey( + Buffer.from(svmKey(address).slice(2), 'hex') + ); + const info = await rpc.getAccountInfo(mint, 'confirmed'); + if (!info || info.executable) + throw svmInvalid('mint account missing or executable'); + const owner = info.owner.toBase58(), + data = info.data; + const legacy = owner === SPL && data.length === 82; + const extended = + owner === TOKEN_2022 && + (data.length === 82 || (data.length >= 166 && data[165] === 1)); + if ((!legacy && !extended) || data[45] !== 1) + throw svmInvalid('account is not an initialized supported SPL mint'); + return { mint: mint.toBase58(), tokenProgram: owner }; + }), + }; +} diff --git a/packages/core/src/lib/agentic/management/svm-public.ts b/packages/core/src/lib/agentic/management/svm-public.ts new file mode 100644 index 000000000..f74d996ba --- /dev/null +++ b/packages/core/src/lib/agentic/management/svm-public.ts @@ -0,0 +1,121 @@ +import { PublicKey } from '@solana/web3.js'; +import { getAddress, hexToBytes, zeroAddress } from 'viem'; +import type { Address, Hex } from 'viem'; +import { CHAIN, VM } from '../../constants/enums'; +import { CHAIN_INFO } from '../../constants/chain'; +import type { SolanaRule } from '../agentic.types'; +import type { AgenticRuntime } from '../runtime'; +import type { Snapshot } from '../reads/snapshot'; +import { readGasPrc20 } from '../contracts/prc20-metadata'; +import { resolveSvmContext } from './svm-context'; +import { createSvmMetadataProvider } from './svm-metadata'; +import { deriveAgwSvmAta, svmKey } from '../codec/svm-accounts'; +import { compileSvmIdlRule } from '../codec/svm-idl'; +import { svmInvalid } from '../codec/svm-terms'; +import { UINT256_MAX } from '../codec/defaults'; + +export function svmMetadata(runtime: AgenticRuntime) { + return ( + runtime.svmMetadata ?? + createSvmMetadataProvider({ + [CHAIN.SOLANA_DEVNET]: { + gatewayProgram: CHAIN_INFO[CHAIN.SOLANA_DEVNET].lockerContract ?? '', + rpcUrls: CHAIN_INFO[CHAIN.SOLANA_DEVNET].defaultRPC, + }, + }) + ); +} +export async function preparePublicSvmRule( + runtime: AgenticRuntime, + snap: Snapshot, + wallet: Address, + rule: SolanaRule +) { + const chain = rule.chainNamespace as CHAIN; + if (CHAIN_INFO[chain]?.vm !== VM.SVM) + throw svmInvalid('unknown Solana chain'); + if (!Array.isArray(rule.assets) || rule.assets.length > 8) + throw svmInvalid('assets must contain at most eight entries'); + if (rule.outputTokens !== undefined && !Array.isArray(rule.outputTokens)) + throw svmInvalid('outputTokens must be an array of mint addresses'); + const metadata = svmMetadata(runtime); + const requested = rule.assets.length + ? rule.assets + : [{ token: zeroAddress, maxPerCall: BigInt(0), maxTotal: BigInt(0) }]; + const caps: { token: Address; maxPerCall: bigint; maxTotal: bigint }[] = []; + const tokenAccounts = new Map(), + mints = new Set(); + const sources: { key?: Hex; token: Address }[] = []; + for (const asset of requested) { + const source = + typeof asset.token === 'string' ? asset.token : asset.token?.address; + if (typeof source !== 'string') + throw svmInvalid('asset token requires an address'); + if ( + typeof asset.token !== 'string' && + 'chain' in asset.token && + asset.token.chain !== chain + ) + throw svmInvalid('asset token belongs to another chain'); + const native = + source === zeroAddress || + /^0x0{64}$/.test(source) || + source === '11111111111111111111111111111111'; + const key = native ? undefined : svmKey(source); + const sourceToken = + typeof asset.token === 'string' && key + ? new PublicKey(hexToBytes(key)).toBase58() + : asset.token; + const token = native + ? await readGasPrc20(snap, chain) + : runtime.resolvePrc20(sourceToken, chain); + if (caps.some((c) => c.token === getAddress(token))) + throw svmInvalid('duplicate SVM asset'); + caps.push({ + token: getAddress(token), + maxPerCall: asset.maxPerCall, + maxTotal: asset.maxTotal ?? UINT256_MAX, + }); + sources.push({ key, token }); + if (key) mints.add(key); + } + const outputs = (rule.outputTokens ?? []).map((mint) => ({ mint })); + outputs.forEach((o) => mints.add(svmKey(o.mint))); + const ctx = await resolveSvmContext( + snap, + wallet, + rule.chainNamespace, + caps, + metadata, + outputs, + runtime.network + ); + // Validate the PRC20 actually represents the requested source mint, not merely its chain. + const { resolveSvmAssets } = await import('./svm-context'); + const resolved = await resolveSvmAssets( + snap, + rule.chainNamespace, + caps, + metadata, + runtime.network + ); + resolved.forEach((a, i) => { + if ( + sources[i].key + ? a.kind !== 'spl' || svmKey(a.mint) !== sources[i].key + : a.kind !== 'native' + ) + throw svmInvalid('PRC20 source does not match requested asset'); + }); + for (const mint of mints) { + const token = await metadata.mint(rule.chainNamespace, mint); + if (svmKey(token.mint) !== mint) + throw svmInvalid('mint resolver returned another mint'); + tokenAccounts.set(mint, deriveAgwSvmAta(ctx.expectedCEA, token)); + } + return compileSvmIdlRule( + rule, + { ...ctx, tokenAccounts }, + runtime.nowSeconds() + ); +} diff --git a/packages/core/src/lib/agentic/management/svm.ts b/packages/core/src/lib/agentic/management/svm.ts new file mode 100644 index 000000000..685b661a9 --- /dev/null +++ b/packages/core/src/lib/agentic/management/svm.ts @@ -0,0 +1,254 @@ +/** Internal explicit-wire operations. Not exported by the SDK entry point. */ +import { getAddress, type Address, type Hex } from 'viem'; +import type { AgenticRuntime } from '../runtime'; +import { + resolveWalletGeneration, + verifyPolicyVersion, + type AgenticGeneration, +} from '../deployments'; +import { Snapshot } from '../reads/snapshot'; +import { readSvmRule } from '../reads/svm'; +import { + assertCanSign, + assertOwner, + confirmedLogs, + wrapSendError, +} from './common'; +import { encodeSvmTerms, type SvmTermsWire } from '../codec/svm-terms'; +import { buildSession, encodeEnvelope } from '../codec/session'; +import { rulesId as predictRulesId } from '../codec/ids'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { deriveAgwSvmCea } from '../codec/svm-accounts'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { PRC20_SOURCE_ABI } from '../contracts/prc20-metadata'; + +async function verifyAssets(snap: Snapshot, input: SvmWireGrant) { + if (!/^solana:[1-9A-HJ-NP-Za-km-z]{32}$/.test(input.chainNamespace)) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'SVM chain must be a full Solana CAIP-2 namespace' + ); + for (const cap of input.terms.assets) { + if ( + (await snap.read( + cap.token, + PRC20_SOURCE_ABI, + 'SOURCE_CHAIN_NAMESPACE' + )) !== input.chainNamespace + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, + 'SVM asset source-chain mismatch' + ); + } +} + +export interface SvmWireGrant { + agent: Address; + chainNamespace: `solana:${string}`; + terms: SvmTermsWire; +} + +export async function verifySvmWallet( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address +) { + const actual = await resolveWalletGeneration( + runtime.reader, + runtime.network, + wallet + ); + if (actual.addresses.factory !== gen.addresses.factory) + throw new AgenticError( + AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + 'SVM wallet generation mismatch' + ); + await verifyPolicyVersion(runtime.reader, gen); +} + +function session( + gen: AgenticGeneration, + wallet: Address, + owner: Address, + input: SvmWireGrant, + now: number +) { + const agent = getAddress(input.agent); + if (agent === owner) + throw new AgenticError( + AGENTIC_ERROR_CODE.AGENT_IS_OWNER, + 'agent must differ from owner' + ); + const cea = deriveAgwSvmCea(wallet, input.terms.gatewayProgram).address; + if (cea.toLowerCase() !== input.terms.expectedCEA.toLowerCase()) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'SVM expectedCEA does not belong to this wallet/gateway' + ); + return buildSession({ + validator: gen.addresses.sessionValidator, + rulesPolicy: gen.addresses.rulesPolicy, + agent, + actions: [ + { + target: gen.addresses.gateway, + selector: SEND_OUTBOUND_SELECTOR, + initData: encodeEnvelope( + input.chainNamespace, + encodeSvmTerms(input.terms, now) + ), + }, + ], + }); +} + +export async function grantSvmWire( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + input: SvmWireGrant +) { + assertCanSign(runtime, 'internal SVM grant'); + await verifySvmWallet(runtime, gen, wallet); + const snap = await Snapshot.at(runtime.reader); + const owner = await assertOwner(runtime, gen, wallet, snap); + await verifyAssets(snap, input); + const s = session(gen, wallet, owner, input, runtime.nowSeconds()); + const nonce = await snap.read( + wallet, + gen.contracts.abis.wallet, + 'grantNonce' + ); + const expected = predictRulesId({ + validator: gen.addresses.sessionValidator, + agent: getAddress(input.agent), + grantNonce: nonce, + }); + let tx; + try { + tx = await runtime.execute({ + to: wallet, + value: BigInt(0), + data: gen.contracts.encodeGrantRules(s), + }); + } catch (error) { + throw wrapSendError(error); + } + const { logs } = await confirmedLogs(runtime, tx); + const granted = gen.contracts.parseRulesGranted(logs, wallet); + if ( + granted.length !== 1 || + granted[0].mode !== 0 || + granted[0].chainNamespace !== input.chainNamespace + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, + 'SVM grant receipt did not match this operation', + { details: { txHash: tx.hash, expected } } + ); + // Another owner grant can advance grantNonce between read and inclusion. + // The sole matching event from this receipt is the authoritative ID. + return { rulesId: granted[0].rulesId, tx }; +} + +/** Snapshot -> all-asset assertion -> revoke -> grant. No sequential fallback. */ +export async function prepareSvmReplacement( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + oldId: Hex, + input: SvmWireGrant +) { + assertCanSign(runtime, 'internal SVM replacement'); + await verifySvmWallet(runtime, gen, wallet); + const snap = await Snapshot.at(runtime.reader); + const owner = await assertOwner(runtime, gen, wallet, snap); + await verifyAssets(snap, input); + const old = await readSvmRule( + snap, + gen, + wallet, + oldId, + runtime.pushChainNamespace + ); + const s = session(gen, wallet, owner, input, runtime.nowSeconds()); + const nonce = await snap.read( + wallet, + gen.contracts.abis.wallet, + 'grantNonce' + ); + const rulesId = predictRulesId({ + validator: gen.addresses.sessionValidator, + agent: getAddress(input.agent), + grantNonce: nonce, + }); + const calls = [ + { + target: gen.addresses.rulesPolicy, + value: BigInt(0), + data: gen.contracts.encodeAssertSpentUniversal( + old.configId, + wallet, + old.config.assets.map((a) => a.spent) + ), + }, + { + target: wallet, + value: BigInt(0), + data: gen.contracts.encodeRevokeRules(oldId), + }, + { + target: wallet, + value: BigInt(0), + data: gen.contracts.encodeGrantRules(s), + }, + ]; + return { + rulesId, + oldId, + snapshotBlock: snap.blockNumber, + data: gen.contracts.encodeExecute(calls), + }; +} + +export async function replaceSvmWire( + runtime: AgenticRuntime, + gen: AgenticGeneration, + wallet: Address, + oldId: Hex, + input: SvmWireGrant +) { + const prepared = await prepareSvmReplacement( + runtime, + gen, + wallet, + oldId, + input + ); + let tx; + try { + tx = await runtime.execute({ + to: wallet, + value: BigInt(0), + data: prepared.data, + }); + } catch (error) { + throw wrapSendError(error); + } + const { logs } = await confirmedLogs(runtime, tx); + const grants = gen.contracts.parseRulesGranted(logs, wallet); + const revokes = gen.contracts.parseRulesRevoked(logs, wallet); + if ( + grants.length !== 1 || + grants[0].mode !== 0 || + grants[0].chainNamespace !== input.chainNamespace || + !revokes.some((r) => r.rulesId.toLowerCase() === oldId.toLowerCase()) + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, + 'SVM replacement receipt mismatch', + { details: { txHash: tx.hash } } + ); + return { rulesId: grants[0].rulesId, replaced: oldId, tx }; +} diff --git a/packages/core/src/lib/agentic/reads/checkpoints.ts b/packages/core/src/lib/agentic/reads/checkpoints.ts new file mode 100644 index 000000000..713819205 --- /dev/null +++ b/packages/core/src/lib/agentic/reads/checkpoints.ts @@ -0,0 +1,85 @@ +import type { Address } from 'viem'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import type { AgenticGeneration } from '../deployments'; +import type { ChainReader } from '../contracts/reader'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { Checkpoint } from '../agentic.types'; +import { Snapshot, scanLogs } from './snapshot'; + +const MAX_ATTEMPTS = 2; + +/** + * w.checkpoints(): Checkpointed events in (block, tx, log) order, checked + * against the wallet's checkpointCount() at the same snapshot block. + * `sinceBlock` is inclusive. Evaluators should compare counts — one block can + * hold several ticks, and allowance pulls change balances without any tick. + */ +export async function readCheckpoints( + reader: ChainReader, + gen: AgenticGeneration, + wallet: Address, + sinceBlock?: bigint +): Promise<{ checkpoints: Checkpoint[]; count: bigint; blockNumber: bigint }> { + requireCapability(gen.capabilities, AgenticCapability.CHECKPOINTS); + let lastError: AgenticError | undefined; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const snap = await Snapshot.at(reader); + const count = BigInt( + await snap.read(wallet, gen.contracts.abis.wallet, 'checkpointCount') + ); + const from = sinceBlock !== undefined && sinceBlock > gen.startBlock ? sinceBlock : gen.startBlock; + const logs = await scanLogs( + reader, + { address: wallet, event: gen.contracts.events.checkpointed }, + from, + snap.blockNumber + ); + const decoded = gen.contracts + .parseCheckpointed(logs, wallet) + .sort((a, b) => + a.blockNumber !== b.blockNumber + ? a.blockNumber < b.blockNumber + ? -1 + : 1 + : a.transactionIndex !== b.transactionIndex + ? a.transactionIndex - b.transactionIndex + : a.logIndex - b.logIndex + ); + const problem = validateSequence(decoded.map((c) => c.seq), count, sinceBlock === undefined || from === gen.startBlock); + if (!problem) { + return { + count, + blockNumber: snap.blockNumber, + checkpoints: decoded.map(({ seq, kind, ref, blockNumber, txHash }) => ({ + seq, + kind, + ref, + blockNumber, + txHash, + })), + }; + } + lastError = new AgenticError(AGENTIC_ERROR_CODE.INCONSISTENT_READ, problem, { + details: { wallet, count, blockNumber: snap.blockNumber }, + }); + } + throw lastError as AgenticError; +} + +/** Sequence must be contiguous, end at `count`, and start at 1 for a full scan. */ +export function validateSequence(seqs: number[], count: bigint, fullRange: boolean): string | null { + for (let i = 1; i < seqs.length; i++) { + if (seqs[i] !== seqs[i - 1] + 1) return `checkpoint sequence gap between ${seqs[i - 1]} and ${seqs[i]}`; + } + if (fullRange) { + if (BigInt(seqs.length) !== count) { + return `found ${seqs.length} Checkpointed events but checkpointCount() is ${count}`; + } + if (seqs.length > 0 && seqs[0] !== 1) return `first checkpoint seq is ${seqs[0]}, expected 1`; + return null; + } + if (seqs.length > 0 && BigInt(seqs[seqs.length - 1]) !== count) { + return `last checkpoint seq ${seqs[seqs.length - 1]} does not match checkpointCount() ${count}`; + } + return null; +} diff --git a/packages/core/src/lib/agentic/reads/rules.ts b/packages/core/src/lib/agentic/reads/rules.ts new file mode 100644 index 000000000..0c4dcc854 --- /dev/null +++ b/packages/core/src/lib/agentic/reads/rules.ts @@ -0,0 +1,398 @@ +import { readSvmConfig } from './svm'; +import { readGasPrc20, readSvmSourceToken } from '../contracts/prc20-metadata'; +import { getAddress, zeroAddress, type Address, type Hex } from 'viem'; +import { CHAIN } from '../../constants/enums'; +import { AgenticCapability, requireCapability } from '../capabilities'; +import type { AgenticGeneration } from '../deployments'; +import type { + NativeConfigRead, + ModeRead, + UniversalConfigRead, +} from '../contracts/v5'; +import { configId } from '../codec/ids'; +import { nativeTermsToRule } from '../codec/native'; +import { universalTermsToRule } from '../codec/universal'; +import { readOriginToken } from '../contracts/prc20-metadata'; +import { chainHash } from '../codec/rules'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { AgenticHex, RulesRecord } from '../agentic.types'; +import { Snapshot } from './snapshot'; + +const MODE_UNIVERSAL = 0; +const MODE_NATIVE = 1; + +export interface ActiveRule { + rulesId: Hex; + /** agentOf(rulesId); zero means the engine no longer resolves it. */ + agent: Address; + actionIds: readonly Hex[]; + mode: number; + vm: number; + chainHash: Hex; +} + +/** Enabled rule IDs with their agent and governed chain, all at one block. */ +export async function readActiveRules( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address +): Promise { + const ids = await snap.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'getPermissionIDs', + [wallet] + ); + return Promise.all(ids.map((id) => readActiveRule(snap, gen, wallet, id))); +} + +async function readActiveRule( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + rulesId: Hex +): Promise { + const [agent, actionIds] = await Promise.all([ + snap.read
(wallet, gen.contracts.abis.wallet, 'agentOf', [rulesId]), + snap.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'getEnabledActions', + [wallet, rulesId] + ), + ]); + if (actionIds.length === 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `enabled rule ${rulesId} has no enabled actions` + ); + } + const modes = await Promise.all( + actionIds.map((a) => + snap.read( + gen.addresses.rulesPolicy, + gen.contracts.abis.policy, + 'getMode', + [configId(wallet, rulesId, a), wallet] + ) + ) + ); + const first = modes[0]; + if ( + !modes.every( + (m) => + m.initialized && + m.chainHash === first.chainHash && + m.mode === first.mode + ) + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `rule ${rulesId} has uninitialized or mixed-chain policy configs` + ); + } + return { + rulesId, + agent: getAddress(agent), + actionIds, + mode: Number(first.mode), + vm: Number(first.vm), + chainHash: first.chainHash, + }; +} + +/** Map a stored chain hash back to its CAIP-2 string from known chains. */ +export function chainFromHash( + hash: Hex, + pushChainNamespace: string +): string | undefined { + const known = [pushChainNamespace, ...Object.values(CHAIN)]; + return known.find((c) => chainHash(c) === hash); +} + +/** + * Agent-door rule selection for one send (spec 2.a): enabled rules whose agent + * is the signer and whose chain is the destination, read uncached at one + * block. Expiry, caps and policy remain on-chain checks. + */ +export async function selectRuleForSend( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + agent: Address, + destinationChain: string +): Promise { + const active = await readActiveRules(snap, gen, wallet); + const want = chainHash(destinationChain); + const matches = active.filter( + (r) => r.agent === getAddress(agent) && r.chainHash === want + ); + if (matches.length === 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN, + `no enabled rule on ${wallet} names ${agent} for ${destinationChain}`, + { + details: { + wallet, + agent, + destinationChain, + blockNumber: snap.blockNumber, + }, + } + ); + } + if (matches.length > 1) { + throw new AgenticError( + AGENTIC_ERROR_CODE.AMBIGUOUS_RULE, + `${matches.length} enabled rules on ${wallet} name ${agent} for ${destinationChain}`, + { + hint: 'Multiple enabled rules match this agent and chain. Inspect details.rulesIds and ask the owner to resolve the overlap; no transaction was submitted.', + details: { rulesIds: matches.map((m) => m.rulesId) }, + } + ); + } + return matches[0]; +} + +/** Decode one active rule into the public record. */ +export async function decodeActiveRule( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + rule: ActiveRule, + pushChainNamespace: string +): Promise { + if (rule.mode === MODE_UNIVERSAL) { + if (rule.vm === 1) { + requireCapability( + gen.capabilities, + AgenticCapability.UNIVERSAL_SVM_RULES + ); + const stored = await readSvmConfig( + snap, + gen, + wallet, + rule, + pushChainNamespace + ); + const cfg = stored.config; + const gas = await readGasPrc20(snap, stored.chainNamespace); + const assets = await Promise.all( + cfg.assets.map(async (a) => ({ + token: + getAddress(a.token) === gas + ? zeroAddress + : await readSvmSourceToken( + snap, + a.token, + stored.chainNamespace, + gen.network + ), + maxPerCall: a.maxPerCall, + maxTotal: a.maxTotal, + })) + ); + return { + rulesId: rule.rulesId, + enabled: true, + agent: rule.agent, + chainNamespace: stored.chainNamespace, + validUntil: Number(cfg.validUntil), + rule: { + format: 'decoded', + agent: rule.agent, + chainNamespace: stored.chainNamespace, + validUntil: Number(cfg.validUntil), + expectedCEA: cfg.expectedCEA, + gatewayProgram: cfg.gatewayProgram, + maxGasPerCall: cfg.maxGasPerCall, + assets, + ceaAccounts: cfg.ceaAccounts, + programs: cfg.programs, + pins: cfg.pins, + dataPins: cfg.dataPins, + }, + }; + } + if (rule.vm !== 0) + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'unknown universal VM' + ); + requireCapability(gen.capabilities, AgenticCapability.UNIVERSAL_EVM_RULES); + const chain = chainFromHash(rule.chainHash, pushChainNamespace); + if (!chain || !chain.startsWith('eip155:')) + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'unknown EVM rule chain' + ); + const cfg = await snap.read( + gen.addresses.rulesPolicy, + gen.contracts.abis.policy, + 'getConfig', + [configId(wallet, rule.rulesId, rule.actionIds[0]), wallet] + ); + if (!cfg.initialized) + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + 'uninitialized universal rule' + ); + if ( + !cfg.assets.length || + cfg.assets.length > 8 || + !cfg.allowedCalls.length || + cfg.allowedCalls.length > 32 || + cfg.expectedCEA === zeroAddress + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + 'invalid stored universal rule shape' + ); + const tokens = await Promise.all( + cfg.assets.map((a) => readOriginToken(snap, a.token, chain)) + ); + return { + rulesId: rule.rulesId, + enabled: true, + chainNamespace: chain, + agent: rule.agent, + validUntil: Number(cfg.validUntil), + rule: universalTermsToRule( + cfg, + rule.agent, + chain as `eip155:${string}`, + tokens + ), + }; + } + if (rule.mode !== MODE_NATIVE) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + `rule ${rule.rulesId} has unknown mode ${rule.mode}` + ); + } + if (rule.actionIds.length !== 1) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + `rule ${rule.rulesId} has ${rule.actionIds.length} native actions; the public NativeRule is single-action (A02)` + ); + } + const cfg = await snap.read( + gen.addresses.rulesPolicy, + gen.contracts.abis.policy, + 'getNativeConfig', + [configId(wallet, rule.rulesId, rule.actionIds[0]), wallet] + ); + if (!cfg.initialized) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `native config for ${rule.rulesId} is not initialized` + ); + } + if (rule.agent === getAddress(`0x${'00'.repeat(20)}`)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `enabled rule ${rule.rulesId} does not resolve to an agent` + ); + } + return { + rulesId: rule.rulesId as AgenticHex, + enabled: true, + chainNamespace: pushChainNamespace, + agent: rule.agent, + validUntil: Number(cfg.validUntil), + rule: nativeTermsToRule(cfg, rule.agent), + }; +} + +/** Native spend counters for the update assertion. */ +export async function readNativeSpend( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + rule: ActiveRule, + action: Hex = rule.actionIds[0] +): Promise<{ + configId: Hex; + valueSpent: bigint; + amountSpent: bigint; + callsUsed: number; +}> { + const id = configId(wallet, rule.rulesId, action); + const cfg = await snap.read( + gen.addresses.rulesPolicy, + gen.contracts.abis.policy, + 'getNativeConfig', + [id, wallet] + ); + return { + configId: id, + valueSpent: cfg.valueSpent, + amountSpent: cfg.amountSpent, + callsUsed: Number(cfg.callsUsed), + }; +} + +export async function readUniversalSpend( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + rule: ActiveRule +): Promise<{ configId: Hex; expectedSpent: bigint[] }> { + const id = configId(wallet, rule.rulesId, rule.actionIds[0]); + const fn = rule.vm === 1 ? 'getSvmConfig' : 'getConfig'; + const cfg = await snap.read<{ + initialized: boolean; + assets: readonly { spent: bigint }[]; + }>(gen.addresses.rulesPolicy, gen.contracts.abis.policy, fn, [id, wallet]); + if (!cfg.initialized) + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + 'uninitialized universal spend config' + ); + return { configId: id, expectedSpent: cfg.assets.map((a) => a.spent) }; +} + +export async function listRules( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + pushChainNamespace: string +): Promise { + requireCapability(gen.capabilities, AgenticCapability.ACTIVE_RULE_READS); + const active = await readActiveRules(snap, gen, wallet); + const records: RulesRecord[] = []; + for (const r of active) + records.push( + await decodeActiveRule(snap, gen, wallet, r, pushChainNamespace) + ); + return records; +} + +/** + * rules.get returns enabled records only. Unknown/revoked IDs are deliberately + * indistinguishable without historical reconstruction, which is outside v1. + */ +export async function getRule( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + rulesId: Hex, + pushChainNamespace: string +): Promise { + requireCapability(gen.capabilities, AgenticCapability.ACTIVE_RULE_READS); + const enabled = await snap.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'isPermissionEnabled', + [rulesId, wallet] + ); + if (enabled) { + const active = await readActiveRule(snap, gen, wallet, rulesId); + return decodeActiveRule(snap, gen, wallet, active, pushChainNamespace); + } + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_NOT_FOUND, + `rule ${rulesId} is not enabled on ${wallet} (unknown or revoked; v1 has no rule history)` + ); +} diff --git a/packages/core/src/lib/agentic/reads/snapshot.ts b/packages/core/src/lib/agentic/reads/snapshot.ts new file mode 100644 index 000000000..a4fa62851 --- /dev/null +++ b/packages/core/src/lib/agentic/reads/snapshot.ts @@ -0,0 +1,112 @@ +import type { AbiEvent, Address, Log } from 'viem'; +import type { ChainReader } from '../contracts/reader'; +import { + AGENTIC_ERROR_CODE, + AgenticError, + type AgenticErrorCode, +} from '../errors'; + +/** + * All reads of one logical operation are pinned to a single block so dependent + * reads (enumerate IDs → per-ID config) observe one state. An RPC failure is + * surfaced as an error; it is never treated as "empty". + */ +export class Snapshot { + constructor( + readonly reader: ChainReader, + readonly blockNumber: bigint, + private readonly failCode: AgenticErrorCode = AGENTIC_ERROR_CODE.RULE_READ_FAILED + ) {} + + static async at( + reader: ChainReader, + failCode: AgenticErrorCode = AGENTIC_ERROR_CODE.RULE_READ_FAILED + ): Promise { + let block: bigint; + try { + block = await reader.getBlockNumber(); + } catch (cause) { + throw new AgenticError( + failCode, + 'could not read the current Push block number', + { cause } + ); + } + return new Snapshot(reader, block, failCode); + } + + async read( + address: Address, + abi: readonly unknown[], + functionName: string, + args: readonly unknown[] = [] + ): Promise { + try { + return (await this.reader.readContract({ + address, + abi, + functionName, + args, + blockNumber: this.blockNumber, + })) as T; + } catch (cause) { + throw new AgenticError( + this.failCode, + `${functionName} read on ${address} failed at block ${this.blockNumber}`, + { + cause, + details: { address, functionName, blockNumber: this.blockNumber }, + } + ); + } + } + + async code(address: Address): Promise { + try { + const code = await this.reader.getCode({ + address, + blockNumber: this.blockNumber, + }); + return !!code && code !== '0x'; + } catch (cause) { + throw new AgenticError(this.failCode, `getCode(${address}) failed`, { + cause, + }); + } + } +} + +/** Max block span per eth_getLogs request. */ +// Donut's public RPC limits eth_getLogs ranges to a distance of 1,000 blocks. +export const LOG_PAGE_BLOCKS = BigInt(1_000); + +/** + * Paginated log scan [fromBlock, toBlock] inclusive. A failed page fails the + * whole scan; partial results are never returned. + */ +export async function scanLogs( + reader: ChainReader, + args: { address: Address; event: AbiEvent; args?: Record }, + fromBlock: bigint, + toBlock: bigint, + page: bigint = LOG_PAGE_BLOCKS +): Promise { + const out: Log[] = []; + if (fromBlock > toBlock) return out; + for (let start = fromBlock; start <= toBlock; start += page) { + const end = + start + page - BigInt(1) > toBlock ? toBlock : start + page - BigInt(1); + try { + out.push( + ...(await reader.getLogs({ ...args, fromBlock: start, toBlock: end })) + ); + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + `log scan for ${args.event.name} failed for blocks ${start}-${end}`, + { cause } + ); + } + } + return out; +} diff --git a/packages/core/src/lib/agentic/reads/svm.ts b/packages/core/src/lib/agentic/reads/svm.ts new file mode 100644 index 000000000..56767cff0 --- /dev/null +++ b/packages/core/src/lib/agentic/reads/svm.ts @@ -0,0 +1,98 @@ +/** Internal lossless SVM state shared by public reads and execution. */ +import type { Address, Hex } from 'viem'; +import type { AgenticGeneration } from '../deployments'; +import type { SvmConfigRead } from '../contracts/v5'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { actionId, configId } from '../codec/ids'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { validateSvmTerms } from '../codec/svm-terms'; +import { chainFromHash, readActiveRules, type ActiveRule } from './rules'; +import type { Snapshot } from './snapshot'; + +export interface SvmRuleSnapshot { + wallet: Address; + rulesId: Hex; + agent: Address; + chainNamespace: `solana:${string}`; + configId: Hex; + config: SvmConfigRead; + blockNumber: bigint; +} + +export async function readSvmRule( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + rulesId: Hex, + pushChainNamespace: string +): Promise { + const active = (await readActiveRules(snap, gen, wallet)).find( + (r) => r.rulesId.toLowerCase() === rulesId.toLowerCase() + ); + if (!active) + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_NOT_FOUND, + `no enabled SVM rule ${rulesId} on ${wallet}` + ); + return readSvmConfig(snap, gen, wallet, active, pushChainNamespace); +} + +export async function readSvmConfig( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address, + active: ActiveRule, + pushChainNamespace: string +): Promise { + const chain = chainFromHash(active.chainHash, pushChainNamespace); + const expectedAction = actionId( + gen.addresses.gateway, + SEND_OUTBOUND_SELECTOR + ); + if ( + active.mode !== 0 || + active.vm !== 1 || + !chain?.startsWith('solana:') || + active.actionIds.length !== 1 || + active.actionIds[0].toLowerCase() !== expectedAction.toLowerCase() + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + 'rule is not a single gateway SVM action' + ); + const id = configId(wallet, active.rulesId, active.actionIds[0]); + const cfg = await snap.read( + gen.addresses.rulesPolicy, + gen.contracts.abis.policy, + 'getSvmConfig', + [id, wallet] + ); + try { + if (!cfg.initialized) throw new Error('uninitialized SVM config'); + // Stored expired rules remain readable. Grant-time validation still checks expiry. + validateSvmTerms(cfg, -1); + for (const cap of cfg.assets) { + if ( + typeof cap.spent !== 'bigint' || + cap.spent < BigInt(0) || + cap.spent > cap.maxTotal + ) + throw new Error('invalid stored asset counter'); + } + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + 'malformed stored SVM config', + { cause } + ); + } + return { + wallet, + rulesId: active.rulesId, + agent: active.agent, + chainNamespace: chain as `solana:${string}`, + configId: id, + config: cfg, + blockNumber: snap.blockNumber, + }; +} diff --git a/packages/core/src/lib/agentic/reads/wallets.ts b/packages/core/src/lib/agentic/reads/wallets.ts new file mode 100644 index 000000000..fd0c33f1e --- /dev/null +++ b/packages/core/src/lib/agentic/reads/wallets.ts @@ -0,0 +1,198 @@ +import { getAddress, type Address } from 'viem'; +import type { AgenticGeneration } from '../deployments'; +import type { ChainReader } from '../contracts/reader'; +import { deriveWallet } from '../codec/ids'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import type { WalletInfo, WalletSummary } from '../agentic.types'; +import { Snapshot } from './snapshot'; + +/** Factory-predicted address for (owner, index), cross-checked against the pure mirror. */ +async function predict( + snap: Snapshot, + gen: AgenticGeneration, + owner: Address, + index: bigint +): Promise<{ address: Address; deployed: boolean }> { + const [address, deployed] = await snap.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'predictWallet', + [owner, index] + ); + const mirror = deriveWallet({ + factory: gen.addresses.factory, + walletImplementation: gen.addresses.walletImplementation, + owner, + index, + }); + if (getAddress(address) !== mirror) { + // Derivation drift between the SDK mirror and the factory (obligation 13). + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `factory predicted ${address} for (${owner}, ${index}) but the SDK derives ${mirror}`, + { details: { generation: gen.id } } + ); + } + return { address: getAddress(address), deployed }; +} + +export async function walletCount( + snap: Snapshot, + gen: AgenticGeneration, + owner: Address +): Promise { + return BigInt( + await snap.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'walletCount', + [owner] + ) + ); +} + +/** client.agentic.derive: default index is the owner's next unused slot. */ +export async function deriveForOwner( + reader: ChainReader, + gen: AgenticGeneration, + owner: Address, + index?: number +): Promise<{ address: Address; index: number; deployed: boolean }> { + const snap = await Snapshot.at(reader); + const next = await walletCount(snap, gen, owner); + let target = next; + if (index !== undefined) { + if (!Number.isInteger(index) || index < 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'index must be a non-negative integer' + ); + } + target = BigInt(index); + if (target > next) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `index ${index} is beyond the owner's next unused index ${next}; the factory only predicts up to the next slot` + ); + } + } + const p = await predict(snap, gen, owner, target); + return { address: p.address, index: Number(target), deployed: p.deployed }; +} + +export async function rulesCount( + snap: Snapshot, + gen: AgenticGeneration, + wallet: Address +): Promise { + const ids = await snap.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'getPermissionIDs', + [wallet] + ); + return ids.length; +} + +/** + * client.agentic.list: every deployed slot plus the next undeployed slot + * (deployed=false, rulesCount=0), as in the spec example. + */ +export async function listForOwner( + reader: ChainReader, + gen: AgenticGeneration, + owner: Address +): Promise { + const snap = await Snapshot.at(reader); + const count = await walletCount(snap, gen, owner); + const out: WalletSummary[] = []; + for (let i = BigInt(0); i <= count; i++) { + const p = await predict(snap, gen, owner, i); + const deployed = i < count; + if (deployed !== p.deployed) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `factory reports slot ${i} deployed=${p.deployed} but walletCount is ${count}` + ); + } + const label = deployed + ? await snap.read(p.address, gen.contracts.abis.wallet, 'label') + : ''; + out.push({ + address: p.address, + index: Number(i), + label, + deployed, + rulesCount: deployed ? await rulesCount(snap, gen, p.address) : 0, + }); + } + return out; +} + +/** w.owner(): the owner as the wallet stores it. */ +export async function walletOwner( + reader: ChainReader, + gen: AgenticGeneration, + wallet: Address +): Promise
{ + const snap = await Snapshot.at(reader); + return getAddress( + await snap.read
(wallet, gen.contracts.abis.wallet, 'owner') + ); +} + +/** + * w.info(). A deployed wallet is read from chain. An undeployed address is + * only describable when it is `signerIdentity`'s next predicted slot. + */ +export async function walletInfo( + reader: ChainReader, + gen: AgenticGeneration, + wallet: Address, + signerIdentity?: Address +): Promise { + const snap = await Snapshot.at(reader); + const deployed = await snap.code(wallet); + if (!deployed) { + if (signerIdentity) { + const next = await walletCount(snap, gen, signerIdentity); + const p = await predict(snap, gen, signerIdentity, next); + if (p.address === getAddress(wallet)) { + return { + address: p.address, + label: '', + index: Number(next), + owner: getAddress(signerIdentity), + deployed: false, + rulesCount: 0, + }; + } + } + throw new AgenticError( + AGENTIC_ERROR_CODE.WALLET_NOT_DEPLOYED, + `${wallet} is not deployed and is not the connected owner's next wallet slot` + ); + } + const [owner, index] = await Promise.all([ + snap.read
(wallet, gen.contracts.abis.wallet, 'owner'), + snap.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'indexOf', + [wallet] + ), + ]); + const label = await snap.read( + wallet, + gen.contracts.abis.wallet, + 'label' + ); + return { + address: getAddress(wallet), + label, + index: Number(index), + owner: getAddress(owner), + deployed: true, + rulesCount: await rulesCount(snap, gen, wallet), + }; +} diff --git a/packages/core/src/lib/agentic/response.ts b/packages/core/src/lib/agentic/response.ts new file mode 100644 index 000000000..111fdf285 --- /dev/null +++ b/packages/core/src/lib/agentic/response.ts @@ -0,0 +1,432 @@ +import { Snapshot } from './reads/snapshot'; +import { readGasPrc20 } from './contracts/prc20-metadata'; +import { parseAgwSvmPayload } from './execution/svm-payload'; +import { deriveAgwSvmCea } from './codec/svm-accounts'; +import { CHAIN_INFO } from '../constants/chain'; +import { bytesToHex } from 'viem'; +import { + decodeAbiParameters, + decodeFunctionData, + getAddress, + type Address, + type Hex, +} from 'viem'; +import { CHAIN } from '../constants/enums'; +import { getBatchExecutorAddress } from '../constants/chain'; +import { getPushChainForNetwork } from '../orchestrator/internals/helpers'; +import { convertExecutorToOrigin } from '../universal/account/account'; +import { pushChainNamespaceFor } from './chain'; +import { UNIVERSAL_GATEWAY_PC } from '../constants/abi'; +import type { + TransactionRouteType, + UniversalTxResponse, +} from '../orchestrator/orchestrator.types'; +import { + v5, + SEND_OUTBOUND_SELECTOR, + UEA_MULTICALL_PREFIX, + type Call, +} from './contracts/v5'; +import { generationsFor, resolveWalletGeneration } from './deployments'; +import type { AgenticRuntime } from './runtime'; +import type { + AgenticAddress, + AgenticHex, + AgenticTxMetadata, +} from './agentic.types'; + +export interface LogicalCall { + to: string; + data: string; + value: bigint; +} + +/** + * Present an AGW send as the wallet's own transaction: `from` is the AGW and + * `to`/`data`/`value` are the logical call. The signer's `origin`, hashes, + * nonce, gas and the on-chain `raw` fields are kept untouched; the wrapped + * call the signer actually sent is recorded in `agentic.rawTo/rawData`. + * Mutates in place so `wait()` (which reads these fields) reports the same + * identity on the receipt. + */ +export function adaptAgenticResponse( + resp: UniversalTxResponse, + meta: Omit & { + logical: LogicalCall; + route?: TransactionRouteType; + chain?: CHAIN; + } +): UniversalTxResponse { + const rawTo = resp.to; + const rawData = resp.data; + if (!resp.raw) { + resp.raw = { + from: resp.from, + to: resp.to, + nonce: resp.nonce, + data: resp.data, + value: resp.value, + }; + } + resp.from = getAddress(meta.wallet); + resp.to = meta.logical.to; + resp.data = meta.logical.data; + resp.value = meta.logical.value; + if (meta.route) resp.route = meta.route; + if (meta.chain) { + resp.chain = meta.chain; + resp.chainNamespace = meta.chain; + } + resp.agentic = { + wallet: getAddress(meta.wallet) as AgenticAddress, + door: meta.door, + ...(meta.rulesId ? { rulesId: meta.rulesId } : {}), + ...(meta.chainNamespace ? { chainNamespace: meta.chainNamespace } : {}), + ...(meta.destinationAccount + ? { destinationAccount: meta.destinationAccount } + : {}), + ...(meta.destinationCalls + ? { destinationCalls: meta.destinationCalls.map((call) => ({ ...call })) } + : {}), + ...(meta.destinationInstruction + ? { destinationInstruction: meta.destinationInstruction } + : {}), + ...(meta.destinationTransfer + ? { destinationTransfer: { ...meta.destinationTransfer } } + : {}), + ...(meta.nativeCalls + ? { nativeCalls: meta.nativeCalls.map((call) => ({ ...call })) } + : {}), + rawTo, + rawData, + }; + return resp; +} + +/** + * trackTransaction replay: if the tracked Push tx called execute/executeAsAgent + * on a wallet of a registered, verified generation, restore the same logical + * identity a live send reports. Anything unrecognised is returned unchanged. + */ +export async function adaptTrackedResponse( + runtime: Pick, + resp: UniversalTxResponse +): Promise { + if (resp.agentic || generationsFor(runtime.network).length === 0) return resp; + const batch = await adaptNativeAgentBatch(runtime, resp); + if (batch) return batch; + const decoded = v5.decodeWalletCall(resp.data as Hex); + if ( + !decoded || + (decoded.kind !== 'execute' && decoded.kind !== 'executeAsAgent') + ) + return resp; + let wallet: Address; + try { + wallet = getAddress(resp.to); + } catch { + return resp; + } + let gen; + try { + gen = await resolveWalletGeneration( + runtime.reader, + runtime.network, + wallet + ); + } catch { + return resp; + } + const door = decoded.kind === 'execute' ? 'owner' : 'agent'; + const rulesId = + decoded.kind === 'executeAsAgent' + ? (decoded.rulesId as AgenticHex) + : undefined; + const calls = decoded.calls; + const outbound = calls.find( + (c) => + getAddress(c.target) === gen.addresses.gateway && + c.data.toLowerCase().startsWith(SEND_OUTBOUND_SELECTOR) + ); + if (outbound) { + const out = decodeOutbound(outbound.data); + const chain = await tokenChain(runtime, out.token); + if (chain?.startsWith('solana:')) { + if ( + door === 'owner' && + out.payload === '0x' && + out.amount > BigInt(0) && + /^0x[0-9a-fA-F]{64}$/.test(out.recipient) + ) { + try { + const gas = await readGasPrc20( + new Snapshot(runtime.reader, resp.blockNumber), + chain + ); + return adaptAgenticResponse(resp, { + wallet, + door, + chainNamespace: chain, + chain: chain as CHAIN, + route: 'UOA_TO_CEA', + destinationTransfer: { + recipient: out.recipient, + token: out.token, + amount: out.amount, + }, + logical: { + to: out.recipient, + data: '0x', + value: getAddress(gas) === out.token ? out.amount : BigInt(0), + }, + }); + } catch { + // Preserve the actual gateway summary when historical token metadata + // cannot be read; destination polling below still uses the known chain. + } + } + try { + const instruction = parseAgwSvmPayload(out.payload); + const destinationInstruction = { + program: instruction.program, + accounts: instruction.accounts.map(({ pubkey, isWritable }) => ({ + pubkey, + isWritable, + })), + data: bytesToHex(instruction.instructionData), + }; + return adaptAgenticResponse(resp, { + wallet, + door, + rulesId, + chainNamespace: chain, + chain: chain as CHAIN, + route: 'UOA_TO_CEA', + destinationInstruction, + destinationAccount: deriveAgwSvmCea( + wallet, + CHAIN_INFO[chain as CHAIN].lockerContract ?? '' + ).address, + logical: { + to: instruction.program, + data: destinationInstruction.data, + value: BigInt(0), + }, + }); + } catch { + // Historical owner calls may use another payload format. Preserve the + // actual gateway call below instead of inventing instruction metadata. + } + } + // The decoded wallet call is authoritative: core may have inferred a + // Push-only route from an incomplete Cosmos record, which would stop + // wait() from polling the destination leg. + return adaptAgenticResponse(resp, { + wallet, + door, + rulesId, + chainNamespace: chain, + logical: outboundResponseCall(out.calls, { + to: outbound.target, + data: outbound.data, + value: outbound.value, + }), + ...(out.calls.length > 0 ? { destinationCalls: out.calls } : {}), + route: 'UOA_TO_CEA', + chain: (chain as CHAIN | undefined) ?? resp.chain, + }); + } + const single = calls.length === 1 ? calls[0] : undefined; + return adaptAgenticResponse(resp, { + wallet, + door, + rulesId, + chainNamespace: undefined, + logical: single + ? { to: single.target, data: single.data, value: single.value } + : { to: wallet, data: resp.data, value: BigInt(0) }, + }); +} + +/** Decode only supported sender-account batches, never an arbitrary forwarding helper. */ +async function adaptNativeAgentBatch( + runtime: Pick, + resp: UniversalTxResponse +): Promise { + let outer: Call[]; + const isUea = resp.data.toLowerCase().startsWith(UEA_MULTICALL_PREFIX); + try { + if (isUea) { + const [entries] = decodeAbiParameters( + [ + { + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + `0x${resp.data.slice(10)}` + ); + outer = entries.map((c) => ({ + target: getAddress(c.to), + value: c.value, + data: c.data, + })); + } else { + const batch = v5.decodeWalletCall(resp.data as Hex); + if (batch?.kind !== 'execute' || batch.mode !== `0x01${'00'.repeat(31)}`) + return; + outer = batch.calls; + } + if ( + outer.length < 2 || + outer.some((c) => c.value !== BigInt(0) || c.target !== outer[0].target) + ) + return; + const inner = outer.map((c) => v5.decodeWalletCall(c.data)); + const first = inner[0]; + if (first?.kind !== 'executeAsAgent' || first.calls.length !== 1) return; + const nativeCalls: { to: Address; value: bigint; data: Hex }[] = []; + for (const c of inner) { + if ( + c?.kind !== 'executeAsAgent' || + c.calls.length !== 1 || + c.rulesId !== first.rulesId || + c.mode !== `0x${'00'.repeat(32)}` + ) + return; + const call = c.calls[0]; + nativeCalls.push({ to: call.target, value: call.value, data: call.data }); + } + const senderAccount = getAddress(resp.from); + if (isUea) { + if (getAddress(resp.raw?.to ?? resp.to) !== senderAccount) return; + const origin = await convertExecutorToOrigin(senderAccount, { + _internal: true, + }); + if (!origin.exists) return; + } else { + if (getAddress(resp.to) !== senderAccount) return; + const executor = getBatchExecutorAddress( + getPushChainForNetwork(runtime.network) + ); + if (!executor) return; + const code = await runtime.reader.getCode({ + address: senderAccount, + blockNumber: resp.blockNumber, + }); + if (code?.toLowerCase() !== `0xef0100${executor.slice(2).toLowerCase()}`) + return; + } + const wallet = outer[0].target; + const gen = await resolveWalletGeneration( + runtime.reader, + runtime.network, + wallet + ); + // This recognizer is only for native arrays; keep mixed/outbound batches raw. + if (nativeCalls.some((c) => c.to === gen.addresses.gateway)) return; + return adaptAgenticResponse(resp, { + wallet, + door: 'agent', + rulesId: first.rulesId as AgenticHex, + chainNamespace: pushChainNamespaceFor(runtime.network), + nativeCalls, + logical: { ...nativeCalls[0] }, + }); + } catch { + // Unsupported payload or unverified sender/wallet: preserve the original response. + return; + } +} + +/** + * Canonical outbound summary for both send and replay: the first actual + * destination call. The complete ordered calls live in agentic.destinationCalls. + * Identical calldata can come from explicit calls or generated transfers, so + * never infer the original input shape from a selector. For an unrecognized + * historical payload, retain the actual gateway call instead of inventing one. + */ +export function outboundResponseCall( + calls: readonly { to: Address; value: bigint; data: Hex }[], + fallback: LogicalCall +): LogicalCall { + const first = calls[0]; + return first + ? { to: first.to, data: first.data, value: first.value } + : fallback; +} + +function decodeOutbound(data: Hex): { + payload: Hex; + recipient: Hex; + amount: bigint; + token: Address; + calls: { to: Address; value: bigint; data: Hex }[]; +} { + const { args } = decodeFunctionData({ abi: UNIVERSAL_GATEWAY_PC, data }); + const req = args?.[0] as { + token: Address; + payload: Hex; + recipient: Hex; + amount: bigint; + }; + let calls: { to: Address; value: bigint; data: Hex }[] = []; + if (req.payload.toLowerCase().startsWith(UEA_MULTICALL_PREFIX)) { + try { + const [decodedCalls] = decodeAbiParameters( + [ + { + type: 'tuple[]', + components: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + `0x${req.payload.slice(10)}` + ); + calls = decodedCalls.map((c) => ({ + to: getAddress(c.to), + value: c.value, + data: c.data, + })); + } catch { + calls = []; + } + } + return { + token: getAddress(req.token), + payload: req.payload, + recipient: req.recipient, + amount: req.amount, + calls, + }; +} + +async function tokenChain( + runtime: Pick, + token: Address +): Promise { + try { + return (await runtime.reader.readContract({ + address: token, + abi: [ + { + type: 'function', + name: 'SOURCE_CHAIN_NAMESPACE', + inputs: [], + outputs: [{ type: 'string' }], + stateMutability: 'view', + }, + ], + functionName: 'SOURCE_CHAIN_NAMESPACE', + })) as string; + } catch { + return undefined; + } +} diff --git a/packages/core/src/lib/agentic/revert.ts b/packages/core/src/lib/agentic/revert.ts new file mode 100644 index 000000000..a6a07daae --- /dev/null +++ b/packages/core/src/lib/agentic/revert.ts @@ -0,0 +1,131 @@ +import { + decodeErrorResult, + toFunctionSelector, + type Abi, + type Hex, +} from 'viem'; +import { formatAbiItem } from 'viem/utils'; +import type { DecodedErrorPayload } from '../orchestrator/internals/errors'; +import { + AGW_ABI, + AGW_FACTORY_ABI, + SMART_SESSION_ABI, + UNIVERSAL_RULES_POLICY_ABI, +} from './contracts/abi/v5'; + +/** URP reverts reached through the engine are truncated to this wrapper. */ +export const POLICY_CHECK_REVERTED_SELECTOR = '0xf4270752'; + +const ERROR_ABIS: readonly Abi[] = [ + AGW_ABI as unknown as Abi, + UNIVERSAL_RULES_POLICY_ABI as unknown as Abi, + AGW_FACTORY_ABI as unknown as Abi, + SMART_SESSION_ABI as unknown as Abi, +]; + +/** selector → error name across the AGW/URP/factory/engine ABIs. */ +const ERROR_NAMES: ReadonlyMap = new Map( + ERROR_ABIS.flatMap((abi) => + abi + .filter((item) => item.type === 'error') + .map((item) => { + const err = item as { + type: 'error'; + name: string; + inputs: readonly { type: string }[]; + }; + return [ + toFunctionSelector(formatAbiItem(err as never)).toLowerCase(), + err.name, + ] as const; + }) + ) +); + +const HEX_DATA = /0x[0-9a-fA-F]{8,}/g; +const CUSTOM_ERROR_TEXT = /custom error (0x[0-9a-fA-F]{8}):?\s*([0-9a-fA-F]*)/g; + +/** Pull candidate revert data out of an error (viem chains, nested causes, messages). */ +function revertDataCandidates(err: unknown, depth = 0): Hex[] { + if (!err || depth > 6) return []; + const out: Hex[] = []; + const e = err as { + data?: unknown; + raw?: unknown; + cause?: unknown; + message?: unknown; + details?: unknown; + }; + if (typeof e.raw === 'string' && /^0x[0-9a-fA-F]{8,}$/.test(e.raw)) + out.push(e.raw as Hex); + if (typeof e.data === 'string' && /^0x[0-9a-fA-F]{8,}$/.test(e.data)) + out.push(e.data as Hex); + if ( + e.data && + typeof e.data === 'object' && + typeof (e.data as { data?: unknown }).data === 'string' + ) { + out.push((e.data as { data: Hex }).data); + } + for (const text of [e.message, e.details]) { + if (typeof text !== 'string') continue; + // viem renders unknown custom errors as "custom error 0x: ". + for (const m of text.matchAll(CUSTOM_ERROR_TEXT)) + out.push(`${m[1]}${m[2] ?? ''}` as Hex); + out.push(...((text.match(HEX_DATA) ?? []) as Hex[])); + } + out.push(...revertDataCandidates(e.cause, depth + 1)); + return out; +} + +/** Decode raw revert data against the AGW/URP/factory/engine error ABIs. */ +export function decodeAgwErrorData(data: Hex): DecodedErrorPayload | undefined { + const selector = data.slice(0, 10).toLowerCase(); + if (selector === POLICY_CHECK_REVERTED_SELECTOR && data.length >= 10 + 64) { + // bytes32 = 4-byte policy error selector ‖ 28 bytes of its first argument. + // The arguments are truncated, so the inner error is named by selector only. + const word = `0x${data.slice(10, 74)}` as Hex; + const innerName = ERROR_NAMES.get(word.slice(0, 10).toLowerCase()); + return { + name: innerName + ? `PolicyCheckReverted(${innerName})` + : 'PolicyCheckReverted', + selector, + decoded: word, + hint: 'URP rejected the action (truncated through the session engine). The inner selector names the gate; arguments are truncated to 28 bytes.', + }; + } + for (const abi of ERROR_ABIS) { + try { + const r = decodeErrorResult({ abi, data }); + return { + name: r.errorName, + selector, + decoded: r.args && r.args.length ? stringifyArgs(r.args) : undefined, + }; + } catch { + // try the next ABI + } + } + // Arguments lost (e.g. a node rendered them as text): name the error by its selector. + const name = ERROR_NAMES.get(selector); + return name + ? { name, selector, hint: 'Error arguments were not available.' } + : undefined; +} + +export function decodeAgenticRevert( + err: unknown +): DecodedErrorPayload | undefined { + for (const data of revertDataCandidates(err)) { + const decoded = decodeAgwErrorData(data); + if (decoded) return decoded; + } + return undefined; +} + +function stringifyArgs(args: readonly unknown[]): string { + return JSON.stringify(args, (_k, v) => + typeof v === 'bigint' ? v.toString() : v + ); +} diff --git a/packages/core/src/lib/agentic/runtime.ts b/packages/core/src/lib/agentic/runtime.ts new file mode 100644 index 000000000..0068f20ea --- /dev/null +++ b/packages/core/src/lib/agentic/runtime.ts @@ -0,0 +1,176 @@ +import { getAddress, type Address } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../constants/enums'; +import type { MoveableToken } from '../constants/tokens'; +import { AtomicBatchUnavailableError } from '../orchestrator/internals/errors'; +import { capabilityUnavailable } from './errors'; +import type { Orchestrator } from '../orchestrator/orchestrator'; +import type { + ExecuteParams, + TransactionExecutionOptions, + UniversalExecuteParams, + UniversalTxResponse, +} from '../orchestrator/orchestrator.types'; +import type { ProgressEvent } from '../progress-hook/progress-hook.types'; +import { getCEAAddress } from '../orchestrator/cea-utils'; +import type { OrchestratorContext } from '../orchestrator/internals/context'; +import { computeUEAOffchain } from '../orchestrator/internals/uea-manager'; +import { ensureSvmFinalizeGasBudgetQuote } from '../orchestrator/internals/svm-rent'; +import { queryOutboundGasFee } from '../orchestrator/internals/gas-calculator'; +import { + getNativePRC20ForChain, + isPushChain, +} from '../orchestrator/internals/helpers'; +import { pushChainNamespaceFor } from './chain'; +import { getPRC20Address } from '../universal/prc20-address'; +import { + chainReaderFromPublicClient, + type ChainReader, +} from './contracts/reader'; +import type { SvmMetadataProvider } from './management/svm-context'; +import { createSvmMetadataProvider } from './management/svm-metadata'; +import { CHAIN_INFO } from '../constants/chain'; +import type { AgenticProgressHook } from './agentic.types'; + +/** + * Everything the AGW module needs from the surrounding client. Tests supply a + * mock; PushChain builds the real one from its orchestrator. Keeping this + * explicit is what separates the signer's identity (gas, account status, + * ownership derivation) from the AGW execution account. + */ +export interface AgenticRuntime { + readonly network: PUSH_NETWORK; + /** 'eip155:' of the connected Push chain — the native rule chain. */ + readonly pushChainNamespace: string; + readonly reader: ChainReader; + readonly isReadOnly: boolean; + readonly svmMetadata?: SvmMetadataProvider; + /** Connected signer's Push identity: its EOA, or its UEA for its actual origin chain. */ + signerPushAccount(): Address; + /** Connected signer's origin account (universal.origin). */ + signerOrigin(): { chain: CHAIN; address: string }; + signerIsPushNative(): boolean; + /** The signer's ordinary transaction path (orchestrator.execute). */ + execute( + params: ExecuteParams | UniversalExecuteParams, + options?: TransactionExecutionOptions + ): Promise; + /** Sender-preserving UEA/7702 transport; native sequential fallback is forbidden. */ + executeAtomicBatch( + params: ExecuteParams, + options?: TransactionExecutionOptions + ): Promise; + /** Emit to the init-time hook and, if different, the per-call hook. */ + emit(event: ProgressEvent, perCallHook?: AgenticProgressHook): void; + /** Whether the signer's own UEA is deployed (always true for a Push EOA). */ + signerAccountDeployed(): Promise; + signerBalance(): Promise; + getGasPrice(): Promise; + quoteOutbound( + prc20: Address, + gasLimit: bigint, + chain: CHAIN, + svm?: { wallet: Address; splMintBase58?: string; burnAmount: bigint } + ): Promise<{ + protocolFee: bigint; + nativeValueForGas: bigint; + gasLimitUsed: bigint; + }>; + resolveCEA( + account: Address, + chain: CHAIN + ): Promise<{ cea: Address; isDeployed: boolean }>; + resolvePrc20( + token: MoveableToken | string | undefined, + chain: CHAIN + ): Address; + nowSeconds(): number; +} + +/** Build the runtime over an orchestrator (the orchestrator IS the internal ctx). */ +export function createAgenticRuntime( + orchestrator: Orchestrator, + isReadOnly: boolean +): AgenticRuntime { + const ctx = orchestrator as unknown as OrchestratorContext; + const network = orchestrator.getNetwork(); + return { + network, + pushChainNamespace: pushChainNamespaceFor(network), + get reader() { + return chainReaderFromPublicClient(ctx.pushClient.publicClient); + }, + isReadOnly, + svmMetadata: createSvmMetadataProvider({ + [CHAIN.SOLANA_DEVNET]: { + gatewayProgram: CHAIN_INFO[CHAIN.SOLANA_DEVNET].lockerContract ?? '', + rpcUrls: + orchestrator.getRpcUrls()[CHAIN.SOLANA_DEVNET] ?? + CHAIN_INFO[CHAIN.SOLANA_DEVNET].defaultRPC, + }, + }), + signerPushAccount: () => getAddress(computeUEAOffchain(ctx)), + signerOrigin: () => orchestrator.getUOA(), + signerIsPushNative: () => isPushChain(ctx.universalSigner.account.chain), + execute: (params, options) => orchestrator.execute(params, options), + executeAtomicBatch: async (params, options) => { + try { + return await orchestrator.executeAtomicBatch(params, options); + } catch (error) { + if (error instanceof AtomicBatchUnavailableError) { + throw capabilityUnavailable('atomicNativeAgentBatch', error.message); + } + throw error; + } + }, + emit: (event, perCallHook) => { + const base = orchestrator.getProgressHook(); + base?.(event); + if (perCallHook && perCallHook !== base) perCallHook(event); + }, + signerAccountDeployed: async () => { + if (isPushChain(ctx.universalSigner.account.chain)) return true; + const status = await orchestrator.getAccountStatus(); + return status.uea.deployed; + }, + signerBalance: () => ctx.pushClient.getBalance(computeUEAOffchain(ctx)), + getGasPrice: () => ctx.pushClient.getGasPrice(), + quoteOutbound: async (prc20, gasLimit, chain, svm) => { + let q = await queryOutboundGasFee(ctx, prc20, gasLimit, chain); + if (svm) { + q = await ensureSvmFinalizeGasBudgetQuote({ + ctx, + ueaAddress: svm.wallet, + targetChain: chain, + prc20Token: prc20, + quote: q, + splMintBase58: svm.splMintBase58, + burnAmount: svm.burnAmount, + pathTag: 'AGW_SVM', + }); + } + return { + protocolFee: q.protocolFee, + nativeValueForGas: q.nativeValueForGas, + gasLimitUsed: q.gasLimitUsed, + }; + }, + resolveCEA: async (account, chain) => { + const r = await getCEAAddress( + account, + chain, + orchestrator.getRpcUrls()[chain]?.[0] + ); + return { cea: getAddress(r.cea), isDeployed: r.isDeployed }; + }, + resolvePrc20: (token, chain) => + getAddress( + token + ? getPRC20Address( + typeof token === 'string' ? { chain, address: token } : token, + { network } + ).address + : getNativePRC20ForChain(chain, network) + ), + nowSeconds: () => Math.floor(Date.now() / 1000), + }; +} diff --git a/packages/core/src/lib/agentic/utils.ts b/packages/core/src/lib/agentic/utils.ts new file mode 100644 index 000000000..2c863bd22 --- /dev/null +++ b/packages/core/src/lib/agentic/utils.ts @@ -0,0 +1,249 @@ +import { decodeSvmTerms, validateSvmTerms } from './codec/svm-terms'; +import { svmKey } from './codec/svm-accounts'; +import { getAddress, isHex, zeroAddress, type Address, type Hex } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../constants/enums'; +import { MOVEABLE_TOKENS } from '../constants/tokens'; +import { getPRC20Address } from '../universal/prc20-address'; +import { AgenticCapability } from './capabilities'; +import { V5_CAPABILITIES } from './deployments'; +import { + actionId as actionIdRaw, + configId as configIdRaw, + deriveWallet as deriveWalletRaw, + rulesId as rulesIdRaw, +} from './codec/ids'; +import { decodeNativeTerms, nativeTermsToRule } from './codec/native'; +import { prepareRules } from './codec/rules'; +import { parseSelector } from './codec/selectors'; +import { decodeEnvelope, decodeSession, encodeSession } from './codec/session'; +import { + AGENTIC_ERROR_CODE, + AgenticError, + capabilityUnavailable, +} from './errors'; +import { pushChainNamespaceFor } from './chain'; +import { decodeUniversalTerms } from './codec/universal-terms'; +import { universalTermsToRule } from './codec/universal'; +import type { + AgenticHex, + Rule, + Selector, + DecodedSolanaRule, +} from './agentic.types'; +import type { UniversalRule } from './agentic.types'; +import type { ResolvedUniversalRule } from './codec/universal'; + +/** + * Internal generation context. Harsh H4.1 (October 4): deployment inputs + * are implementation details, not public helper parameters. + */ +export interface RulesEncodeContext { + /** Connected Push network, or an explicit 'eip155:' native chain. */ + network?: PUSH_NETWORK; + pushChainNamespace?: string; + validator: Address; + rulesPolicy: Address; + gateway?: Address; + universal?: ReadonlyMap; + /** Wallet owner, for the AGENT_IS_OWNER check. */ + owner?: Address; + /** Defaults to the current time. */ + nowSeconds?: number; +} + +function nativeNamespace(ctx: { + network?: PUSH_NETWORK; + pushChainNamespace?: string; +}): string { + if (ctx.pushChainNamespace) return ctx.pushChainNamespace; + return pushChainNamespaceFor(ctx.network ?? PUSH_NETWORK.TESTNET_DONUT); +} + +/** Internal codecs and identities — not exported from the package entry point. */ +export const internalAgenticUtils = { + /** The ID a grant with this grantNonce produces. Chain is not part of the hash. */ + rulesId( + agent: Address, + grantNonce: bigint | number, + ctx: { validator: Address } + ): AgenticHex { + return rulesIdRaw({ + validator: getAddress(ctx.validator), + agent: getAddress(agent), + grantNonce: BigInt(grantNonce), + }) as AgenticHex; + }, + + /** Hash of one allowed call: keccak256(target ‖ selector). */ + actionId(target: Address, selector: Selector | 'value-only'): AgenticHex { + return actionIdRaw( + getAddress(target), + parseSelector(selector).selector + ) as AgenticHex; + }, + + /** Key of one allowed call's stored config under one rule on one wallet. */ + configId(wallet: Address, rulesId: Hex, actionId: Hex): AgenticHex { + return configIdRaw(getAddress(wallet), rulesId, actionId) as AgenticHex; + }, + + /** Any-owner wallet address. */ + deriveWallet( + owner: Address, + index: bigint | number, + ctx: { factory: Address; walletImplementation: Address } + ): Address { + return deriveWalletRaw({ + factory: getAddress(ctx.factory), + walletImplementation: getAddress(ctx.walletImplementation), + owner: getAddress(owner), + index, + }); + }, + + /** + * Encode rules into the grant `Session` bytes, one per rule, in order. + * EVM universal rules require wallet/token resolution in the internal context. + */ + encodeRules(rules: readonly Rule[], ctx: RulesEncodeContext): AgenticHex[] { + return prepareRules(rules, { + owner: ctx.owner ? getAddress(ctx.owner) : undefined, + pushChainNamespace: nativeNamespace(ctx), + validator: getAddress(ctx.validator), + rulesPolicy: getAddress(ctx.rulesPolicy), + gateway: ctx.gateway, + universal: ctx.universal, + capabilities: V5_CAPABILITIES, + nowSeconds: ctx.nowSeconds ?? Math.floor(Date.now() / 1000), + }).map((p) => encodeSession(p.session) as AgenticHex); + }, + + /** Inverse of encodeRules for one Session encoding. */ + decodeRules( + bytes: Hex, + ctx: { network?: PUSH_NETWORK; pushChainNamespace?: string } = {} + ): Rule | DecodedSolanaRule { + if (!isHex(bytes)) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'decodeRules expects hex bytes' + ); + const session = decodeSession(bytes); + if ( + session.actions.length !== 1 || + session.actions[0].actionPolicies.length !== 1 + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'only single-action rules (one URP policy) are representable as a public Rule' + ); + } + const agent = getAddress( + `0x${session.sessionValidatorInitData.slice(26, 66)}` + ); + const { chainNamespace, body } = decodeEnvelope( + session.actions[0].actionPolicies[0].initData + ); + if (chainNamespace !== nativeNamespace(ctx)) { + if (chainNamespace.startsWith('solana:')) { + const terms = decodeSvmTerms(body); + validateSvmTerms(terms, -1); + const assets = terms.assets.map((a) => { + const chain = chainNamespace as CHAIN; + const token = (MOVEABLE_TOKENS[chain] ?? []).find((candidate) => { + try { + return ( + getAddress( + getPRC20Address( + { chain, address: candidate.address }, + { network: ctx.network ?? PUSH_NETWORK.TESTNET_DONUT } + ).address + ) === getAddress(a.token) + ); + } catch { + return false; + } + }); + if (!token) + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'SVM source token is absent from the static registry; use wallet.rules.get()' + ); + return { + token: + token.mechanism === 'native' + ? zeroAddress + : svmKey(token.address), + maxPerCall: a.maxPerCall, + maxTotal: a.maxTotal, + }; + }); + return { + ...terms, + format: 'decoded', + agent, + chainNamespace: chainNamespace as `solana:${string}`, + assets, + }; + } + if (!chainNamespace.startsWith('eip155:')) + throw capabilityUnavailable( + AgenticCapability.UNIVERSAL_SVM_RULES, + 'unsupported universal chain namespace' + ); + let terms; + try { + terms = decodeUniversalTerms(body); + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'malformed universal terms', + { cause } + ); + } + const chain = chainNamespace as CHAIN; + const network = ctx.network ?? PUSH_NETWORK.TESTNET_DONUT; + const tokens = terms.assets.map((asset) => { + const token = (MOVEABLE_TOKENS[chain] ?? []).find((candidate) => { + try { + return ( + getAddress( + getPRC20Address( + { chain, address: candidate.address }, + { network } + ).address + ) === getAddress(asset.token) + ); + } catch { + return false; + } + }); + if (!token) + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + `PRC20 ${asset.token} is not in the static ${chain} token registry`, + { + hint: 'Use wallet.rules.get() for on-chain source-token resolution.', + } + ); + return token.mechanism === 'native' + ? zeroAddress + : getAddress(token.address); + }); + return universalTermsToRule( + terms, + agent, + chainNamespace as `eip155:${string}`, + tokens + ); + } + return nativeTermsToRule(decodeNativeTerms(body), agent); + }, +}; + +/** Public helpers requiring no contract deployment context. */ +export const agenticUtils = { + actionId: internalAgenticUtils.actionId, + configId: internalAgenticUtils.configId, + decodeRules: internalAgenticUtils.decodeRules, +}; diff --git a/packages/core/src/lib/agentic/wallet.ts b/packages/core/src/lib/agentic/wallet.ts new file mode 100644 index 000000000..ddecd3a6d --- /dev/null +++ b/packages/core/src/lib/agentic/wallet.ts @@ -0,0 +1,196 @@ +import { getAddress, type Address } from 'viem'; +import { AgenticCapability, requireCapability } from './capabilities'; +import { resolveWalletGeneration, type AgenticGeneration } from './deployments'; +import { AGENTIC_ERROR_CODE, AgenticError } from './errors'; +import { + addRules, + assertRevokeTarget, + revokeRules, + setLabel, + updateRules, +} from './management/rules-write'; +import { readCheckpoints } from './reads/checkpoints'; +import { getRule, listRules } from './reads/rules'; +import { Snapshot } from './reads/snapshot'; +import { walletInfo, walletOwner } from './reads/wallets'; +import type { AgenticRuntime } from './runtime'; +import type { + AgenticAddress, + AgenticHex, + AgenticProgressHook, + AgenticWallet, + Checkpoint, + RulesRecord, + WalletInfo, +} from './agentic.types'; +import type { UniversalTxResponse } from '../orchestrator/orchestrator.types'; +import { currentGeneration } from './deployments'; +import { assertLabel } from './management/label'; + +/** + * Management handle for one wallet (spec 1.d). Reads work for any wallet; + * writes require the connected signer to be the owner. It never sends AS the + * wallet — that is PushChain.initialize(signer, { agenticWallet }). + */ +export class AgenticWalletHandle implements AgenticWallet { + readonly address: AgenticAddress; + readonly rules: AgenticWallet['rules']; + private generation?: Promise; + + constructor(private readonly runtime: AgenticRuntime, address: string) { + try { + this.address = getAddress(address) as AgenticAddress; + } catch (cause) { + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_AGENTIC_WALLET, + `"${address}" is not an address`, + { cause } + ); + } + this.rules = { + list: () => this.listRules(), + get: (rulesId) => this.getRule(rulesId), + add: async (rules, opts) => + addRules( + this.runtime, + await this.gen(), + this.address, + rules, + opts?.progressHook + ), + update: async (params) => + updateRules(this.runtime, await this.gen(), this.address, params), + revoke: (async ( + target: + | AgenticHex[] + | { all: true; progressHook?: AgenticProgressHook }, + opts?: { progressHook?: AgenticProgressHook } + ) => { + // Validate the target shape before any RPC so a bare revoke costs nothing. + assertRevokeTarget(target); + return revokeRules( + this.runtime, + await this.gen(), + this.address, + target, + opts?.progressHook + ); + }) as AgenticWallet['rules']['revoke'], + }; + } + + /** Deployed wallets resolve their own generation; undeployed ones use the network's current one. */ + private gen(): Promise { + if (!this.generation) { + this.generation = resolveWalletGeneration( + this.runtime.reader, + this.runtime.network, + this.address + ); + this.generation.catch(() => { + this.generation = undefined; + }); + } + return this.generation; + } + + async info(): Promise { + let gen: AgenticGeneration; + try { + gen = await this.gen(); + } catch (err) { + if ( + err instanceof AgenticError && + err.code === AGENTIC_ERROR_CODE.WALLET_NOT_DEPLOYED + ) { + gen = currentGeneration(this.runtime.network); + } else { + throw err; + } + } + requireCapability(gen.capabilities, AgenticCapability.WALLET_READS); + return walletInfo( + this.runtime.reader, + gen, + this.address, + this.signerIdentity() + ); + } + + async owner(): Promise<{ owner: AgenticAddress }> { + const gen = await this.gen(); + return { + owner: (await walletOwner( + this.runtime.reader, + gen, + this.address + )) as AgenticAddress, + }; + } + + async setLabel( + label: string, + opts?: { progressHook?: AgenticProgressHook } + ): Promise { + assertLabel(label); + return setLabel( + this.runtime, + await this.gen(), + this.address, + label, + opts?.progressHook + ); + } + + async checkpoints(opts?: { + sinceBlock?: bigint; + }): Promise<{ checkpoints: Checkpoint[] }> { + const gen = await this.gen(); + const { checkpoints } = await readCheckpoints( + this.runtime.reader, + gen, + this.address, + opts?.sinceBlock + ); + return { checkpoints }; + } + + private async listRules(): Promise<{ rules: RulesRecord[] }> { + const gen = await this.gen(); + const snap = await Snapshot.at(this.runtime.reader); + return { + rules: await listRules( + snap, + gen, + this.address, + this.runtime.pushChainNamespace + ), + }; + } + + private async getRule(rulesId: AgenticHex): Promise { + if (typeof rulesId !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(rulesId)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'rulesId must be a 32-byte hex string' + ); + } + const gen = await this.gen(); + const snap = await Snapshot.at(this.runtime.reader); + return getRule( + snap, + gen, + this.address, + rulesId, + this.runtime.pushChainNamespace + ); + } + + private signerIdentity(): Address | undefined { + try { + return this.runtime.signerPushAccount(); + } catch { + return undefined; + } + } +} diff --git a/packages/core/src/lib/constants/enums.ts b/packages/core/src/lib/constants/enums.ts index da52330a3..31ef1c3e1 100644 --- a/packages/core/src/lib/constants/enums.ts +++ b/packages/core/src/lib/constants/enums.ts @@ -34,12 +34,15 @@ export enum CHAIN { } /** - * Read-only destination exposed with the OG `CHAIN.WEB2` spelling without - * widening the blockchain enum type accepted by transaction APIs. + * Read-only Web2 source kept on `CHAIN` for compatibility without widening the + * blockchain enum type accepted by transaction APIs. + * + * @deprecated Use `PushChain.CONSTANTS.READ.CHAIN.WEB2`. Same value (`'web2'`); + * the legacy literal `'web2:https'` is still accepted as read input. */ // eslint-disable-next-line @typescript-eslint/no-namespace -- declaration merging keeps Web2 out of the transaction enum union export namespace CHAIN { - export const WEB2 = 'web2:https' as const; + export const WEB2 = 'web2' as const; } // Namespace properties are normally enumerable after TypeScript emits them. // Keep blockchain enumeration (`Object.values(CHAIN)`) blockchain-only. diff --git a/packages/core/src/lib/constants/index.ts b/packages/core/src/lib/constants/index.ts index dcc1523c4..a9ee42116 100644 --- a/packages/core/src/lib/constants/index.ts +++ b/packages/core/src/lib/constants/index.ts @@ -15,13 +15,27 @@ import { READ_NAMESPACE, WEB2_DEFAULT_TIMEOUT_MS, WEB2_MAX_EXTRACT_ENTRIES, + WEB2_READ_CHAIN, } from './read-state'; import { UNIVERSAL_READ_STATUS, READ_STATUS, READ_ERROR_CODE, READ_OUTCOME } from '../read-state/read-state.types'; +import { AGENTIC } from '../agentic/constants'; + +/** + * `PushChain.CONSTANTS.READ.CHAIN` — every read source: all of `CHAIN` (same + * string values) plus the enumerable Web2 source. A superset of CHAIN, so + * `read({ chain: CHAIN.X })` keeps compiling. + */ +export const READ_CHAIN = Object.freeze({ + ...CHAIN, + WEB2: WEB2_READ_CHAIN, +}) as Readonly<{ [K in keyof typeof CHAIN]: (typeof CHAIN)[K] }> & { readonly WEB2: typeof WEB2_READ_CHAIN }; /** `PushChain.CONSTANTS.READ` — cross-chain read state. Values verified against the deployed UniversalCallback. */ export const READ_CONSTANTS = { - /** Read-only Web2 destination. Equivalent to `CHAIN.WEB2`. */ - WEB2: CHAIN.WEB2, + /** Every read source, enumerable (spec page 5 §4). */ + CHAIN: READ_CHAIN, + /** @deprecated Use `READ.CHAIN.WEB2`. Same value (`'web2'`). */ + WEB2: WEB2_READ_CHAIN, NAMESPACE: READ_NAMESPACE, MAX_CALLBACK_GAS_LIMIT, MIN_CONFIRMATIONS_FLOOR, @@ -57,6 +71,8 @@ export const CONSTANTS = { MOVEABLE: { TOKEN: MOVEABLE_TOKEN_CONSTANTS }, PAYABLE: { TOKEN: PAYABLE_TOKEN_CONSTANTS }, READ: READ_CONSTANTS, + /** Checked v5 agentic wallet deployments by Push network (Donut only). */ + AGENTIC, }; export type { TypedDataDomain, TypedData }; diff --git a/packages/core/src/lib/constants/read-state.ts b/packages/core/src/lib/constants/read-state.ts index 03ac47b83..5e596644e 100644 --- a/packages/core/src/lib/constants/read-state.ts +++ b/packages/core/src/lib/constants/read-state.ts @@ -42,6 +42,19 @@ export const WEB2_MAX_TIMEOUT_MS = 15_000; * Web2 is not a blockchain and is not a `CHAIN` enum member. Reads target it via * an explicit destination; the node routes on namespace `web2`, id `https`. */ +/** Public Web2 read source (`READ.CHAIN.WEB2`). */ +export const WEB2_READ_CHAIN = 'web2' as const; +/** + * Legacy public spelling, and the unchanged CAIP-2 wire identity validators + * route on. Still accepted as read input. + */ +export const LEGACY_WEB2_READ_CHAIN = 'web2:https' as const; + +/** True for either public Web2 spelling. */ +export function isWeb2ReadChain(chain: unknown): chain is typeof WEB2_READ_CHAIN | typeof LEGACY_WEB2_READ_CHAIN { + return chain === WEB2_READ_CHAIN || chain === LEGACY_WEB2_READ_CHAIN; +} + export const WEB2_DESTINATION = { chainNamespace: 'web2', chainId: 'https', diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index e0906cfa2..940cf4c48 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -175,4 +175,49 @@ export { export { UNIVERSAL_CALLBACK_EVM } from './constants/abi/universalCallback.evm'; export { UNIVERSAL_READ_REGISTRY_EVM } from './constants/abi/universalReadRegistry.evm'; +// Structured execution errors (base class of AgenticRevertError) +export { + PushChainExecutionError, + PushChainBatchExecutionError, +} from './orchestrator/internals/errors'; +export type { DecodedErrorPayload } from './orchestrator/internals/errors'; + +// Agentic wallet (AGW) +export type { PushChainInitializeOptions } from './push-chain/push-chain'; +export { + AGENTIC_ERROR_CODE, + AgenticError, + AgenticRevertError, +} from './agentic'; +export type { + AgenticAddress, + AgenticDoor, + AgenticErrorCode, + AgenticHex, + AgenticNamespace, + AgenticNetworkConstants, + AgenticProgressHook, + AgenticTxMetadata, + AgenticWallet, + AllowedCall, + AssetCap, + Checkpoint, + CheckpointKind, + CreateOptions, + CreateResult, + ForeignChainNamespace, + NativeRule, + SolanaRule, + DecodedSolanaRule, + SvmInstructionRule, + SvmAccountRef, + SvmFieldConstraint, + Rule, + RulesRecord, + Selector, + UniversalRule, + WalletInfo, + WalletSummary, +} from './agentic'; + export { PushChain }; diff --git a/packages/core/src/lib/orchestrator/__tests__/outbound-svm-confirmation.spec.ts b/packages/core/src/lib/orchestrator/__tests__/outbound-svm-confirmation.spec.ts new file mode 100644 index 000000000..11f50cc0c --- /dev/null +++ b/packages/core/src/lib/orchestrator/__tests__/outbound-svm-confirmation.spec.ts @@ -0,0 +1,127 @@ +import { Connection } from '@solana/web3.js'; +import bs58 from 'bs58'; +import { CHAIN } from '../../constants/enums'; +import { + waitForOutboundTx, + OutboundFailedError, + OutboundTimeoutError, +} from '../internals/outbound-sync'; +import { OutboundStatus } from '../../generated/uexecutor/v2/types'; +import type { OrchestratorContext } from '../internals/context'; +jest.mock('@solana/web3.js', () => ({ + ...jest.requireActual('@solana/web3.js'), + Connection: jest.fn(), +})); +const bytes = Buffer.alloc(64, 12), + signature = bs58.encode(bytes), + hex = '0x' + bytes.toString('hex'); +const statuses = jest.fn(), + genesis = jest.fn(); +beforeEach(() => { + statuses.mockReset(); + genesis + .mockReset() + .mockResolvedValue(CHAIN.SOLANA_DEVNET.slice(7) + 'abcdefghijk'); + (Connection as unknown as jest.Mock).mockImplementation(() => ({ + getGenesisHash: genesis, + getSignatureStatuses: statuses, + })); +}); +function context(hash: string): OrchestratorContext { + return { + printTraces: false, + pushNetwork: 'TESTNET_DONUT', + rpcUrls: { [CHAIN.SOLANA_DEVNET]: ['http://fixture'] }, + pushClient: { + getCosmosTx: jest.fn().mockResolvedValue({ events: [] }), + getUniversalTxByIdV2: jest + .fn() + .mockResolvedValue({ + universalTx: { + universalStatus: 6, + pcTx: [], + outboundTx: [ + { + destinationChain: CHAIN.SOLANA_DEVNET, + outboundStatus: OutboundStatus.PENDING, + recipient: 'fixture', + amount: '0', + externalAssetAddr: '', + observedTx: { txHash: hash }, + }, + ], + }, + }), + }, + } as unknown as OrchestratorContext; +} +const opts = { initialWaitMs: 0, pollingIntervalMs: 1, timeout: 100 }; +it.each([signature, hex])( + 'confirms pending Cosmos signature %s through Solana RPC', + async (hash) => { + statuses.mockResolvedValue({ + value: [{ confirmationStatus: 'confirmed', err: null }], + }); + const out = await waitForOutboundTx(context(hash), '0xpush', opts); + expect(out.externalTxHash).toBe(hash); + expect(statuses).toHaveBeenCalledWith([signature], { + searchTransactionHistory: true, + }); + } +); +it('waits through processed status before finalization', async () => { + statuses + .mockResolvedValueOnce({ + value: [{ confirmationStatus: 'processed', err: null }], + }) + .mockResolvedValue({ + value: [{ confirmationStatus: 'finalized', err: null }], + }); + await waitForOutboundTx(context(signature), '0xpush', opts); + expect(statuses).toHaveBeenCalledTimes(2); +}); +it('reports a confirmed signature error as failure, preserving the hash', async () => { + statuses.mockResolvedValue({ + value: [ + { + confirmationStatus: 'confirmed', + err: { InstructionError: [0, 'Custom'] }, + }, + ], + }); + await expect( + waitForOutboundTx(context(hex), '0xpush', opts) + ).rejects.toMatchObject({ code: 'OUTBOUND_FAILED', externalTxHash: hex }); +}); +it.each([ + 'wrong-cluster', + 'missing', + 'processed-error', + 'rpc-error', + 'malformed-hash', +])('keeps %s pending until timeout', async (kind) => { + statuses.mockResolvedValue({ value: [null] }); + if (kind === 'wrong-cluster') genesis.mockResolvedValue('wrong'); + if (kind === 'processed-error') + statuses.mockResolvedValue({ + value: [{ confirmationStatus: 'processed', err: { failure: true } }], + }); + if (kind === 'rpc-error') + statuses.mockRejectedValue(new Error('RPC unavailable')); + const hash = kind === 'malformed-hash' ? '0x1234' : signature; + await expect( + waitForOutboundTx(context(hash), '0xpush', { ...opts, timeout: 20 }) + ).rejects.toBeInstanceOf(OutboundTimeoutError); + if (kind === 'wrong-cluster' || kind === 'malformed-hash') + expect(statuses).not.toHaveBeenCalled(); +}); +it('keeps an authoritative Cosmos revert ahead of the RPC tiebreaker', async () => { + const ctx = context(signature); + (ctx.pushClient.getUniversalTxByIdV2 as jest.Mock).mockResolvedValue({ + universalTx: { universalStatus: 8, pcTx: [], outboundTx: [] }, + }); + await expect(waitForOutboundTx(ctx, '0xpush', opts)).rejects.toBeInstanceOf( + OutboundFailedError + ); + expect(statuses).not.toHaveBeenCalled(); +}); diff --git a/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts b/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts index 626912dc4..c90d3082c 100644 --- a/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts +++ b/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts @@ -1,5 +1,6 @@ import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; import { construct, toUniversal } from '../../universal/signer/signer'; +import { AtomicBatchUnavailableError } from '../internals/errors'; import { EIP7702NotSupportedError } from '../../vm-client/evm-client'; import type { OrchestratorContext } from '../internals/context'; import { sendPushTx } from '../internals/push-chain-tx'; @@ -51,6 +52,7 @@ function makeContext(signer: OrchestratorContext['universalSigner']) { getTransactionCount: jest.fn().mockResolvedValue(41), waitForTransactionReceipt, call: jest.fn(), + getCode: jest.fn().mockResolvedValue('0x6000'), }, }; @@ -260,3 +262,45 @@ describe('sendPushTx native multicall routing', () => { expect(transformFn).not.toHaveBeenCalled(); }); }); + + +describe('atomic-only native batches (AGW sender-preserving arrays)', () => { + async function context(supportsAuthorization = true) { + const signer = await toUniversal(construct( + { chain: CHAIN.PUSH_TESTNET_DONUT, address: '0x3333333333333333333333333333333333333333' }, + { signMessage: async (data) => data, signAndSendTransaction: async (data) => data, + ...(supportsAuthorization ? { signAuthorization: jest.fn() } : {}) } + )); + return makeContext(signer); + } + + it('rejects missing signer capability without sending any sequential call', async () => { + const { ctx, pushClient } = await context(false); + await expect(sendPushTx(ctx, execute, [], transformFn, true)).rejects.toBeInstanceOf(AtomicBatchUnavailableError); + expect(pushClient.sendTransaction).not.toHaveBeenCalled(); + expect(pushClient.sendBatch7702).not.toHaveBeenCalled(); + }); + + it('refuses a missing executor before authorization or broadcast', async () => { + const { ctx, pushClient } = await context(); + pushClient.publicClient.getCode.mockResolvedValue('0x'); + await expect(sendPushTx(ctx, execute, [], transformFn, true)).rejects.toBeInstanceOf(AtomicBatchUnavailableError); + expect(pushClient.sendTransaction).not.toHaveBeenCalled(); + expect(pushClient.sendBatch7702).not.toHaveBeenCalled(); + }); + + it('does not fall back when authorization is exposed but unsupported at runtime', async () => { + const { ctx, pushClient } = await context(); + pushClient.sendBatch7702.mockRejectedValueOnce(new EIP7702NotSupportedError()); + await expect(sendPushTx(ctx, execute, [], transformFn, true)).rejects.toBeInstanceOf(AtomicBatchUnavailableError); + expect(pushClient.sendTransaction).not.toHaveBeenCalled(); + expect(pushClient.sendBatch7702).toHaveBeenCalledTimes(1); + }); + + it('uses the atomic transport when signer and executor are available', async () => { + const { ctx, pushClient } = await context(); + await sendPushTx(ctx, execute, [], transformFn, true); + expect(pushClient.sendBatch7702).toHaveBeenCalledTimes(1); + expect(pushClient.sendTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/lib/orchestrator/internals/errors.ts b/packages/core/src/lib/orchestrator/internals/errors.ts index 58f3ec78a..871ddda3d 100644 --- a/packages/core/src/lib/orchestrator/internals/errors.ts +++ b/packages/core/src/lib/orchestrator/internals/errors.ts @@ -43,14 +43,25 @@ export class PushChainExecutionError extends Error { readonly code = 'PUSH_CHAIN_EXECUTION_FAILED' as const; readonly gatewayTxHash?: string; readonly decodedError?: DecodedErrorPayload; + /** Underlying transport/RPC error, kept so raw revert data is not lost. */ + override readonly cause?: unknown; constructor( message: string, - opts: { gatewayTxHash?: string; decodedError?: DecodedErrorPayload } = {} + opts: { gatewayTxHash?: string; decodedError?: DecodedErrorPayload; cause?: unknown } = {} ) { super(normalizePublicErrorMessage(message)); this.name = 'PushChainExecutionError'; this.gatewayTxHash = opts.gatewayTxHash; this.decodedError = opts.decodedError; + if (opts.cause !== undefined) this.cause = opts.cause; + } +} + +/** @internal A caller required an atomic native batch; sequential fallback is forbidden. */ +export class AtomicBatchUnavailableError extends PushChainExecutionError { + constructor(message: string, cause?: unknown) { + super(message, { cause }); + this.name = 'AtomicBatchUnavailableError'; } } diff --git a/packages/core/src/lib/orchestrator/internals/execute-standard.ts b/packages/core/src/lib/orchestrator/internals/execute-standard.ts index a0d0a026f..9195afcff 100644 --- a/packages/core/src/lib/orchestrator/internals/execute-standard.ts +++ b/packages/core/src/lib/orchestrator/internals/execute-standard.ts @@ -136,7 +136,8 @@ export async function executeStandardPayload( ctx: OrchestratorContext, execute: ExecuteParams, eventBuffer: ProgressEvent[], - getResponseCallbacks: () => ResponseBuilderCallbacks + getResponseCallbacks: () => ResponseBuilderCallbacks, + policy?: { requireAtomicBatch: boolean } ): Promise { const transformFn = (tx: TxResponse, buf: ProgressEvent[] = []) => transformToUniversalTxResponse(ctx, tx, buf, getResponseCallbacks()); @@ -169,7 +170,7 @@ export async function executeStandardPayload( fireProgressHook(ctx, PROGRESS_HOOK.SEND_TX_107); let tx: UniversalTxResponse; try { - tx = await sendPushTx(ctx, execute, eventBuffer, transformFn); + tx = await sendPushTx(ctx, execute, eventBuffer, transformFn, policy?.requireAtomicBatch); } catch (err) { // Push Chain broadcast failed. Wallet rejection surfaces as 104-04; // everything else (RPC fail, on-chain revert) as 199-02. @@ -184,7 +185,7 @@ export async function executeStandardPayload( // outer orchestrator catch doesn't emit a second 199-02 on top. ctx._routeTerminalEmitted = true; if (!isUserDecline && !(err instanceof PushChainExecutionError)) { - throw new PushChainExecutionError(errMsg); + throw new PushChainExecutionError(errMsg, { cause: err }); } throw err; } diff --git a/packages/core/src/lib/orchestrator/internals/outbound-sync.ts b/packages/core/src/lib/orchestrator/internals/outbound-sync.ts index 56089d9bc..f9cb98314 100644 --- a/packages/core/src/lib/orchestrator/internals/outbound-sync.ts +++ b/packages/core/src/lib/orchestrator/internals/outbound-sync.ts @@ -7,6 +7,8 @@ import { resolvePC20WrapperForReceipt } from './pc20/tracking'; import { createPublicClient, fallback, http } from 'viem'; +import { Connection } from '@solana/web3.js'; +import bs58 from 'bs58'; import { CHAIN_INFO, VM_NAMESPACE } from '../../constants/chain'; import { CHAIN, VM } from '../../constants/enums'; import { UniversalTxStatus } from '../../generated/uexecutor/v1/types'; @@ -113,15 +115,15 @@ export class OutboundFailedError extends Error { * (preserves the silent-success bug fix); if no receipt yet or RPC failure, * we keep polling. * - * EVM-only — SVM tiebreaker would need a separate signature lookup path. + * EVM receipts and confirmed/finalized Solana signatures are supported. */ -async function verifyExternalEvmReceipt( +async function verifyExternalReceipt( ctx: OrchestratorContext, destinationChain: CHAIN, externalTxHash: string ): Promise<'success' | 'reverted' | 'pending' | 'unsupported'> { const chainInfo = CHAIN_INFO[destinationChain]; - if (!chainInfo || chainInfo.vm !== VM.EVM) { + if (!chainInfo || (chainInfo.vm !== VM.EVM && chainInfo.vm !== VM.SVM)) { return 'unsupported'; } // Guard against unit-test contexts that don't pass a real rpcUrls map — @@ -133,6 +135,52 @@ async function verifyExternalEvmReceipt( : chainInfo.defaultRPC; if (!rpcUrls?.length) return 'unsupported'; + if (chainInfo.vm === VM.SVM) { + let signature: string; + try { + const bytes = externalTxHash.startsWith('0x') + ? /^0x[0-9a-fA-F]{128}$/.test(externalTxHash) + ? Buffer.from(externalTxHash.slice(2), 'hex') + : new Uint8Array() + : bs58.decode(externalTxHash); + if (bytes.length !== 64) return 'pending'; + signature = bs58.encode(bytes); + } catch { + return 'pending'; + } + for (const url of rpcUrls) { + try { + const connection = new Connection(url, 'confirmed'); + if ( + `solana:${(await connection.getGenesisHash()).slice(0, 32)}` !== + destinationChain + ) + continue; + const result = await connection.getSignatureStatuses([signature], { + searchTransactionHistory: true, + }); + const status = result.value[0]; + // A processed vote can still disappear. Never report success/failure + // before the signature is confirmed or finalized on the right cluster. + if ( + !status || + (status.confirmationStatus !== 'confirmed' && + status.confirmationStatus !== 'finalized') + ) + return 'pending'; + return status.err ? 'reverted' : 'success'; + } catch (error) { + printLog( + ctx, + `[verifyExternalReceipt] SVM RPC unavailable: ${ + error instanceof Error ? error.name : 'unknown' + }` + ); + } + } + return 'pending'; + } + try { const client = createPublicClient({ transport: fallback(rpcUrls.map((url) => http(url))), @@ -153,7 +201,7 @@ async function verifyExternalEvmReceipt( } printLog( ctx, - `[verifyExternalEvmReceipt] RPC error for ${destinationChain} tx ${externalTxHash}: ${msg}` + `[verifyExternalReceipt] RPC error for ${destinationChain} tx ${externalTxHash}: ${msg}` ); return 'pending'; } @@ -397,7 +445,7 @@ export async function waitForOutboundTx( ob.outboundStatus === OutboundStatus.OBSERVED; if (!isCosmosObserved) { // Tiebreaker: ask the destination-chain RPC directly. - const verdict = await verifyExternalEvmReceipt( + const verdict = await verifyExternalReceipt( ctx, chain, ob.observedTx.txHash @@ -468,11 +516,10 @@ export async function waitForOutboundTx( // created, and a repeat export deploys no wrapper so nothing is // ever observed to backfill it. Resolve from UniversalCore instead, // keyed by the Push-native source token. - const pc20Wrapper = await resolvePC20WrapperForReceipt( - ctx, - ob, - { network: ctx.pushNetwork, rpcUrls: ctx.rpcUrls } - ); + const pc20Wrapper = await resolvePC20WrapperForReceipt(ctx, ob, { + network: ctx.pushNetwork, + rpcUrls: ctx.rpcUrls, + }); const details: OutboundTxDetails = { externalTxHash: ob.observedTx.txHash, destinationChain: chain, @@ -738,7 +785,7 @@ export async function waitForAllOutboundTxsV2( const isCosmosObserved = ob.outboundStatus === OutboundStatus.OBSERVED; if (!isCosmosObserved) { - const verdict = await verifyExternalEvmReceipt( + const verdict = await verifyExternalReceipt( ctx, chain, externalTxHash diff --git a/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts b/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts index 551ae237f..140054282 100644 --- a/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts +++ b/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts @@ -30,7 +30,7 @@ import type { import type { OrchestratorContext } from './context'; import { fireProgressHook, printLog } from './context'; import { decodeRevert, formatDecodedRevertForUser } from './error-decoder'; -import { PushChainExecutionError, PushChainBatchExecutionError } from './errors'; +import { PushChainExecutionError, PushChainBatchExecutionError, AtomicBatchUnavailableError } from './errors'; import { normalizePublicErrorMessage } from '../../formatters'; // ============================================================================ @@ -115,7 +115,8 @@ export async function sendPushTx( ctx: OrchestratorContext, execute: ExecuteParams, eventBuffer: ProgressEvent[], - transformFn: TransformToResponseFn + transformFn: TransformToResponseFn, + requireAtomicBatch = false ): Promise { if (Array.isArray(execute.data)) { const calls = execute.data as MultiCall[]; @@ -138,6 +139,15 @@ export async function sendPushTx( const executor = getBatchExecutorAddress( ctx.universalSigner.account.chain ); + if (requireAtomicBatch) { + if (!executor || !ctx.universalSigner.signAuthorization) { + throw new AtomicBatchUnavailableError('This operation requires a sender-preserving atomic batch; the signer/network does not support EIP-7702.'); + } + const code = await ctx.pushClient.publicClient.getCode({ address: executor }); + if (!code || code === '0x') { + throw new AtomicBatchUnavailableError(`Atomic batch executor ${executor} is not deployed on the connected network.`); + } + } if (executor && ctx.universalSigner.signAuthorization) { try { printLog( @@ -161,6 +171,9 @@ export async function sendPushTx( // to sequential execution cannot double-execute. Any other error // (e.g. a real revert) must surface. if (!(err instanceof EIP7702NotSupportedError)) throw err; + if (requireAtomicBatch) { + throw new AtomicBatchUnavailableError('The signer could not authorize EIP-7702; no sequential transactions were sent.', err); + } printLog( ctx, `sendPushTx — wallet cannot sign EIP-7702 authorization; falling back to sequential execution` diff --git a/packages/core/src/lib/orchestrator/orchestrator.ts b/packages/core/src/lib/orchestrator/orchestrator.ts index 0943e4295..f6084c316 100644 --- a/packages/core/src/lib/orchestrator/orchestrator.ts +++ b/packages/core/src/lib/orchestrator/orchestrator.ts @@ -247,6 +247,25 @@ export class Orchestrator { async execute( params: ExecuteParams | UniversalExecuteParams, options?: TransactionExecutionOptions + ): Promise { + return this.executeWithPolicy(params, options, false); + } + + /** @internal Sender-preserving batch. Never uses the native sequential fallback. */ + async executeAtomicBatch( + params: ExecuteParams, + options?: TransactionExecutionOptions + ): Promise { + if (typeof params.to !== 'string' || !Array.isArray(params.data) || params.data.length < 2 || params.funds) { + throw new Error('executeAtomicBatch requires multiple Push calls and no signer funds'); + } + return this.executeWithPolicy(params, options, true); + } + + private async executeWithPolicy( + params: ExecuteParams | UniversalExecuteParams, + options: TransactionExecutionOptions | undefined, + requireAtomicBatch: boolean ): Promise { const inlineProgressHook = params.progressHook; if (options || inlineProgressHook) { @@ -449,7 +468,9 @@ export class Orchestrator { ); } return attachPerCallHookForWait( - await _executeStandardPayload(this.ctx, execute, eventBuffer, rcb) + await (requireAtomicBatch + ? _executeStandardPayload(this.ctx, execute, eventBuffer, rcb, { requireAtomicBatch: true }) + : _executeStandardPayload(this.ctx, execute, eventBuffer, rcb)) ); } catch (err) { const errMessage = normalizePublicErrorMessage(err); diff --git a/packages/core/src/lib/orchestrator/orchestrator.types.ts b/packages/core/src/lib/orchestrator/orchestrator.types.ts index 86b190ec4..3a5b9f53b 100644 --- a/packages/core/src/lib/orchestrator/orchestrator.types.ts +++ b/packages/core/src/lib/orchestrator/orchestrator.types.ts @@ -471,6 +471,15 @@ export interface UniversalTxResponse { /** Transaction route (UOA_TO_PUSH, UOA_TO_CEA, CEA_TO_PUSH, CEA_TO_CEA) */ route?: TransactionRouteType; + /** + * Agentic wallet (AGW) context, present when the transaction executed + * through an AGW. `from` is then the wallet and `origin` stays the signer; + * `agentic.rawTo` / `rawData` hold the wrapped call the signer sent. + * Outbound to/data/value summarize the first encoded destination call; + * `agentic.destinationCalls` retains the complete ordered call list. + */ + agentic?: import('../agentic/agentic.types').AgenticTxMetadata; + // 12. Internal flags /** * @internal True when this response was produced by `trackTransaction` and diff --git a/packages/core/src/lib/orchestrator/route-detector.ts b/packages/core/src/lib/orchestrator/route-detector.ts index 21d543a87..2ed0b15cf 100644 --- a/packages/core/src/lib/orchestrator/route-detector.ts +++ b/packages/core/src/lib/orchestrator/route-detector.ts @@ -9,6 +9,7 @@ */ import { CHAIN } from '../constants/enums'; +import { isWeb2ReadChain } from '../constants/read-state'; import { MOVEABLE_TOKENS, type MoveableToken } from '../constants/tokens'; import type { UniversalExecuteParams, @@ -238,8 +239,8 @@ export function validateRouteParams( context?: { clientChain?: CHAIN } ): void { const toChain = isChainTarget(params.to) ? String(params.to.chain) : undefined; - if (String(params.from?.chain ?? '') === CHAIN.WEB2 || toChain === CHAIN.WEB2) { - throw new RouteValidationError('CHAIN.WEB2 is a read-only destination and cannot be used with transaction APIs'); + if (isWeb2ReadChain(String(params.from?.chain ?? '')) || isWeb2ReadChain(toChain)) { + throw new RouteValidationError('READ.CHAIN.WEB2 is a read-only destination and cannot be used with transaction APIs'); } const route = detectRoute(params); diff --git a/packages/core/src/lib/orchestrator/svm-idl/resolve.ts b/packages/core/src/lib/orchestrator/svm-idl/resolve.ts index 444b7760d..dbcc27926 100644 --- a/packages/core/src/lib/orchestrator/svm-idl/resolve.ts +++ b/packages/core/src/lib/orchestrator/svm-idl/resolve.ts @@ -43,6 +43,16 @@ const CEA_AUTHORITY_NAMES = new Set([ ]); export function resolveSvmCall(input: ResolveInput): ResolvedSvmCall { + return resolveCall(input); +} + +/** Internal AGW seam: use its registry-derived authority, not the signer's CEA. */ +export function resolveSvmCallForCea(input: ResolveInput, ceaAuthority: `0x${string}`): ResolvedSvmCall { + if (!/^0x[0-9a-fA-F]{64}$/.test(ceaAuthority)) throw new Error('SVM CEA authority must be 32 bytes'); + return resolveCall(input, ceaAuthority); +} + +function resolveCall(input: ResolveInput, authority?: `0x${string}`): ResolvedSvmCall { const idl = getIdl(input.programAddress); if (!idl) { throw new Error( @@ -74,11 +84,7 @@ export function resolveSvmCall(input: ResolveInput): ResolvedSvmCall { const programPk = new PublicKey( Buffer.from(input.programAddress.slice(2), 'hex') ); - const { address: ceaPdaHex } = deriveSvmCeaPda( - input.senderUea, - input.targetChain, - true - ); + const ceaPdaHex = authority ?? deriveSvmCeaPda(input.senderUea, input.targetChain, true).address; const accounts: SvmGatewayAccountMeta[] = []; for (const item of ix.accounts) { diff --git a/packages/core/src/lib/progress-hook/__tests__/response-invariant.spec.ts b/packages/core/src/lib/progress-hook/__tests__/response-invariant.spec.ts index b4d683eda..98feda7d3 100644 --- a/packages/core/src/lib/progress-hook/__tests__/response-invariant.spec.ts +++ b/packages/core/src/lib/progress-hook/__tests__/response-invariant.spec.ts @@ -170,6 +170,15 @@ const STUB_ARGS: Record = { [PROGRESS_HOOK.UEA_MIG_9901]: ['v2.0.0'], [PROGRESS_HOOK.UEA_MIG_9902]: [], [PROGRESS_HOOK.UEA_MIG_9903]: [], + // Agentic wallet + [PROGRESS_HOOK.AGENTIC_TX_101]: ['0xwallet', 0, false], + [PROGRESS_HOOK.AGENTIC_TX_102]: ['0xwallet', 'atomic'], + [PROGRESS_HOOK.AGENTIC_TX_104]: [['0xrule']], + [PROGRESS_HOOK.AGENTIC_TX_105]: [{ rulesIds: ['0xrule'] }], + [PROGRESS_HOOK.AGENTIC_TX_106]: ['label'], + [PROGRESS_HOOK.AGENTIC_TX_107]: ['0xrule', 'eip155:42101', 'agent'], + [PROGRESS_HOOK.AGENTIC_TX_199_01]: ['create', '0xhash'], + [PROGRESS_HOOK.AGENTIC_TX_199_02]: ['create', 'NO_RULES_FOR_CHAIN', 'no rule', { name: 'X' }], // Read state (READ-TX 101–199, 001/999) — positional args as in progress-hook.ts [PROGRESS_HOOK.READ_TX_101]: ['eip155:11155111', 'eip155', 0], [PROGRESS_HOOK.READ_TX_102_01]: ['eip155:11155111'], diff --git a/packages/core/src/lib/progress-hook/progress-hook.ts b/packages/core/src/lib/progress-hook/progress-hook.ts index ccc977fc6..821950fb8 100644 --- a/packages/core/src/lib/progress-hook/progress-hook.ts +++ b/packages/core/src/lib/progress-hook/progress-hook.ts @@ -8,6 +8,7 @@ import { formatPc, normalizePcInsufficientFundsError } from '../formatters'; import { Utils } from '../utils'; import { PROGRESS_HOOK, + PROGRESS_HOOK_AGENTIC, PROGRESS_HOOK_MIG, PROGRESS_HOOK_READ, PROGRESS_HOOK_MULTICHAIN, @@ -1593,6 +1594,79 @@ const RAW_HOOKS_READ: { }), }; +// ============================================================================= +// Agentic wallet (AGW) — AGENTIC-TX (spec 3.f, PROPOSED). No AGENTIC-TX-103. +// ============================================================================= + +type AgenticDecodedError = { name?: string; hint?: string; selector?: string; decoded?: string }; + +const RAW_HOOKS_AGENTIC: { + [K in PROGRESS_HOOK_AGENTIC]: ProgressEventFunctionWithoutTimestamp; +} = { + [PROGRESS_HOOK.AGENTIC_TX_101]: (address: string, index: number, deployed: boolean) => ({ + id: PROGRESS_HOOK.AGENTIC_TX_101, + title: 'Agentic Wallet Derived', + message: `Wallet ${address} at index ${index} (${deployed ? 'deployed' : 'not deployed'}).`, + response: { address, index, deployed }, + level: 'INFO', + }), + [PROGRESS_HOOK.AGENTIC_TX_102]: (address: string, strategy: string) => ({ + id: PROGRESS_HOOK.AGENTIC_TX_102, + title: 'Deploy Step Added', + message: `Deploying agentic wallet ${address} (${strategy}).`, + response: { address, strategy }, + level: 'INFO', + }), + [PROGRESS_HOOK.AGENTIC_TX_104]: (rulesIds: string[]) => ({ + id: PROGRESS_HOOK.AGENTIC_TX_104, + title: 'Rules Granted', + message: `Granted ${rulesIds.length} rule${rulesIds.length === 1 ? '' : 's'}.`, + response: { rulesIds }, + level: 'INFO', + }), + [PROGRESS_HOOK.AGENTIC_TX_105]: (target: { rulesIds: string[] } | { all: true }) => ({ + id: PROGRESS_HOOK.AGENTIC_TX_105, + title: 'Rules Revoked', + message: + 'all' in target + ? 'Revoked every rule on the wallet.' + : `Revoked ${target.rulesIds.length} rule${target.rulesIds.length === 1 ? '' : 's'}.`, + response: target, + level: 'INFO', + }), + [PROGRESS_HOOK.AGENTIC_TX_106]: (label: string) => ({ + id: PROGRESS_HOOK.AGENTIC_TX_106, + title: 'Label Set', + message: `Wallet label set to "${label}".`, + response: { label }, + level: 'INFO', + }), + [PROGRESS_HOOK.AGENTIC_TX_107]: (rulesId: string | undefined, chainNamespace: string, door: 'owner' | 'agent') => ({ + id: PROGRESS_HOOK.AGENTIC_TX_107, + title: 'Rule Resolved', + message: + door === 'owner' + ? `Owner door on ${chainNamespace}; no rule applies.` + : `Agent door on ${chainNamespace} under rule ${rulesId}.`, + response: { rulesId: rulesId ?? null, chainNamespace, door }, + level: 'INFO', + }), + [PROGRESS_HOOK.AGENTIC_TX_199_01]: (operation: string, txHash?: string) => ({ + id: PROGRESS_HOOK.AGENTIC_TX_199_01, + title: 'Agentic Operation Done', + message: `${operation} completed.`, + response: { operation, txHash: txHash ?? null }, + level: 'SUCCESS', + }), + [PROGRESS_HOOK.AGENTIC_TX_199_02]: (operation: string, code: string, message: string, decodedError?: AgenticDecodedError) => ({ + id: PROGRESS_HOOK.AGENTIC_TX_199_02, + title: 'Agentic Operation Failed', + message: `${operation} failed: ${message}`, + response: { operation, code, decodedError: decodedError ?? null }, + level: 'ERROR', + }), +}; + // Combine all routes into the master record const RAW_HOOKS: { [K in PROGRESS_HOOK]: ProgressEventFunctionWithoutTimestamp; @@ -1603,6 +1677,7 @@ const RAW_HOOKS: { ...RAW_HOOKS_MULTICHAIN, ...RAW_HOOKS_MIG, ...RAW_HOOKS_READ, + ...RAW_HOOKS_AGENTIC, }; // Build final hooks with timestamp injection diff --git a/packages/core/src/lib/progress-hook/progress-hook.types.ts b/packages/core/src/lib/progress-hook/progress-hook.types.ts index 94954b7e1..4935d1cd1 100644 --- a/packages/core/src/lib/progress-hook/progress-hook.types.ts +++ b/packages/core/src/lib/progress-hook/progress-hook.types.ts @@ -176,6 +176,17 @@ export enum PROGRESS_HOOK { UEA_MIG_9901 = 'UEA-MIG-9901', UEA_MIG_9902 = 'UEA-MIG-9902', UEA_MIG_9903 = 'UEA-MIG-9903', + + // Agentic wallet (AGW) — AGENTIC-TX (PROPOSED in the AGW SDK spec 3.f). + // AGENTIC-TX-103 was removed by the spec (create no longer funds). + AGENTIC_TX_101 = 'AGENTIC-TX-101', + AGENTIC_TX_102 = 'AGENTIC-TX-102', + AGENTIC_TX_104 = 'AGENTIC-TX-104', + AGENTIC_TX_105 = 'AGENTIC-TX-105', + AGENTIC_TX_106 = 'AGENTIC-TX-106', + AGENTIC_TX_107 = 'AGENTIC-TX-107', + AGENTIC_TX_199_01 = 'AGENTIC-TX-199-01', + AGENTIC_TX_199_02 = 'AGENTIC-TX-199-02', } // Route-scoped TYPE aliases — compile-time narrowing for new emission code. @@ -319,3 +330,13 @@ export type PROGRESS_HOOK_READ = | PROGRESS_HOOK.READ_TX_999_01 | PROGRESS_HOOK.READ_TX_999_02 | PROGRESS_HOOK.READ_TX_999_03; + +export type PROGRESS_HOOK_AGENTIC = + | PROGRESS_HOOK.AGENTIC_TX_101 + | PROGRESS_HOOK.AGENTIC_TX_102 + | PROGRESS_HOOK.AGENTIC_TX_104 + | PROGRESS_HOOK.AGENTIC_TX_105 + | PROGRESS_HOOK.AGENTIC_TX_106 + | PROGRESS_HOOK.AGENTIC_TX_107 + | PROGRESS_HOOK.AGENTIC_TX_199_01 + | PROGRESS_HOOK.AGENTIC_TX_199_02; diff --git a/packages/core/src/lib/push-chain/push-chain.ts b/packages/core/src/lib/push-chain/push-chain.ts index 9dc132089..5939f461e 100644 --- a/packages/core/src/lib/push-chain/push-chain.ts +++ b/packages/core/src/lib/push-chain/push-chain.ts @@ -47,7 +47,32 @@ import { type ReadTrackOptions, type ReadExecuteOptions, } from '../read-state/read-params'; -import { assertRequestEntrypoint, executeReads as executePreparedReads } from '../read-state/read-executor'; +import { assertRequestEntrypoint, executeReads as executePreparedReads, type ReadExecutorDeps } from '../read-state/read-executor'; +import { AgenticNamespaceImpl } from '../agentic/agentic'; +import { resolveAgenticContext, type AgenticExecutionContext } from '../agentic/context'; +import { agenticSend } from '../agentic/execution/send'; +import { notAllowedInAgenticMode } from '../agentic/execution/guards'; +import { adaptTrackedResponse } from '../agentic/response'; +import { createAgenticRuntime, type AgenticRuntime } from '../agentic/runtime'; +import type { AgenticNamespace } from '../agentic/agentic.types'; + +/** Options accepted by `PushChain.initialize` / `reinitialize`. */ +export interface PushChainInitializeOptions { + network: PUSH_NETWORK; + rpcUrls?: Partial>; + blockExplorers?: Partial>; + printTraces?: boolean; + progressHook?: (progress: ProgressEvent) => void; + /** + * Act as this agentic wallet (AGW). The signer decides the door: the owner + * sends through `execute`, an agent named in an enabled rule through + * `executeAsAgent`; anyone else fails with NOT_OWNER_OR_AGENT. + * `universal.account` becomes the wallet; `universal.origin`, + * `getAccountStatus()` and gas stay the signer's. Not inherited by + * `reinitialize` — pass it again to keep acting as the wallet. + */ + agenticWallet?: `0x${string}`; +} /** * @class PushChain @@ -258,6 +283,16 @@ export class PushChain { ) => Promise; }; + /** + * Agentic wallet management (AGW): derive, create, list and per-wallet + * handles. Ownership is the connected signer's Push identity. + */ + agentic: AgenticNamespace; + + /** @internal AGW execution context when initialized with `agenticWallet`. */ + private agenticContext?: AgenticExecutionContext; + private readonly agenticRuntime: AgenticRuntime; + /** * Account status including UEA deployment state and version info. * Initially unloaded — call getAccountStatus() to populate. @@ -293,12 +328,36 @@ export class PushChain { // Default — overwritten in createInstance() with the background fetch this.accountStatusReady = Promise.resolve(); + this.agenticRuntime = createAgenticRuntime(orchestrator, isReadMode); + this.agentic = new AgenticNamespaceImpl(this.agenticRuntime); + + // eslint-disable-next-line @typescript-eslint/no-this-alias -- getters below need the instance + const self = this; + // In agenticWallet mode, request transactions execute through the wallet + // and the wallet pays the request value, so its balance gates the read. + const readDeps = (): ReadExecutorDeps => { + const actx = self.agenticContext; + if (!actx) return orchestrator; + return { + execute: (params, options) => agenticSend(self.agenticRuntime, actx, params, options), + trackRead: orchestrator.trackRead.bind(orchestrator) as Orchestrator['trackRead'], + revalidateRead: orchestrator.revalidateRead.bind(orchestrator), + getProgressHook: orchestrator.getProgressHook.bind(orchestrator), + getReadBalance: () => self.agenticRuntime.reader.getBalance({ address: actx.wallet }), + }; + }; + // Unspent read budget returns to whoever paid it: the wallet in agentic mode. + const withReadRefund = (options: T): T => + self.agenticContext && options.refundTo === undefined + ? { ...options, refundTo: self.agenticContext.wallet } + : options; + this.universal = { get origin() { return orchestrator.getUOA(); }, get account() { - return orchestrator.computeUEAOffchain(); + return self.agenticContext?.wallet ?? orchestrator.computeUEAOffchain(); }, sendTransaction: (...args) => { if (this.isReadMode) { @@ -306,12 +365,16 @@ export class PushChain { 'Read only mode cannot call sendTransaction function' ); } + if (this.agenticContext) { + return agenticSend(this.agenticRuntime, this.agenticContext, ...args); + } return orchestrator.execute.bind(orchestrator)(...args); }, prepareTransaction: ( params: UniversalExecuteParams, options?: TransactionExecutionOptions ) => { + if (this.agenticContext) throw notAllowedInAgenticMode('prepareTransaction'); return orchestrator.prepareTransaction.bind(orchestrator)( params, options @@ -326,40 +389,44 @@ export class PushChain { 'Read only mode cannot call executeTransactions function' ); } + if (this.agenticContext) throw notAllowedInAgenticMode('executeTransactions'); return orchestrator.createCascadedBuilder(txs, options).send(); }, - trackTransaction: (txHash: string, options?: import('../orchestrator/orchestrator.types').TrackTransactionOptions) => { - return orchestrator.trackTransaction.bind(orchestrator)(txHash, options); + trackTransaction: async (txHash: string, options?: import('../orchestrator/orchestrator.types').TrackTransactionOptions) => { + const tracked = await orchestrator.trackTransaction.bind(orchestrator)(txHash, options); + return adaptTrackedResponse(this.agenticRuntime, tracked); }, migrateCEA: (chain: CHAIN) => { if (this.isReadMode) { throw new Error('Read only mode cannot call migrateCEA function'); } + if (this.agenticContext) throw notAllowedInAgenticMode('migrateCEA'); return orchestrator.migrateCEA.bind(orchestrator)(chain); }, rescueFunds: (params: RescueFundsParams) => { if (this.isReadMode) { throw new Error('Read only mode cannot call rescueFunds function'); } + if (this.agenticContext) throw notAllowedInAgenticMode('rescueFunds'); return orchestrator.rescueFunds.bind(orchestrator)(params); }, // Only read()/executeReads() send transactions; prepare/simulate/track remain read-only. read: async (subject: string, options: O) => { - const params = await resolveReadSpecParams(subject, options, orchestrator.getNetwork(), orchestrator.getRpcUrls()); + const params = await resolveReadSpecParams(subject, withReadRefund(options), orchestrator.getNetwork(), orchestrator.getRpcUrls()); assertRequestEntrypoint(params.callback, 'read'); if (this.isReadMode) throw new Error('Read only mode cannot call read function'); const prepared = await orchestrator.prepareRead(params, options.progressHook); - const { reads: [response] } = await executePreparedReads(orchestrator, [prepared] as const, options); + const { reads: [response] } = await executePreparedReads(readDeps(), [prepared] as const, options); return response as UniversalReadResponse>; }, prepareRead: async (subject: string, options: O & ValidateReadCall) => { - const params = await resolveReadSpecParams(subject, options, orchestrator.getNetwork(), orchestrator.getRpcUrls()); + const params = await resolveReadSpecParams(subject, withReadRefund(options), orchestrator.getNetwork(), orchestrator.getRpcUrls()); return orchestrator.prepareRead(params) as Promise>>; }, executeReads: (async (reads: readonly PreparedRead[], options?: ReadExecuteOptions) => { reads.forEach((r) => assertRequestEntrypoint(r.callback)); if (this.isReadMode) throw new Error('Read only mode cannot call executeReads function'); - return executePreparedReads(orchestrator, reads, options); + return executePreparedReads(readDeps(), reads, options); }) as PushChain['universal']['executeReads'], trackRead: ((ref: { txHash: Hex } | { requestId: Hex | bigint }, options?: ReadTrackOptions & { progressHook?: (e: ProgressEvent) => void }) => { const opts = { ...toLifecycleOptions(options), progressHook: options?.progressHook }; @@ -702,13 +769,7 @@ export class PushChain { */ private static async createInstance( universalSigner: UniversalSigner | UniversalAccount, - options?: { - network: PUSH_NETWORK; - rpcUrls?: Partial>; - blockExplorers?: Partial>; - printTraces?: boolean; - progressHook?: (progress: ProgressEvent) => void; - } + options?: PushChainInitializeOptions ): Promise { const isReadOnly = PushChain.isUniversalAccount(universalSigner); @@ -773,6 +834,16 @@ export class PushChain { // Let execute() await the background fetch instead of re-fetching orchestrator.accountStatusReadyPromise = instance.accountStatusReady; + // Agentic mode: validate the wallet, its generation and the signer's door + // before returning a client. A read-only client may hold the context (so + // universal.account shows the wallet) but every signing path stays closed. + if (options?.agenticWallet !== undefined) { + instance.agenticContext = await resolveAgenticContext( + instance.agenticRuntime, + options.agenticWallet + ); + } + return instance; } @@ -790,13 +861,7 @@ export class PushChain { */ static initialize = async ( universalSigner: UniversalSigner | UniversalAccount, - options?: { - network: PUSH_NETWORK; - rpcUrls?: Partial>; - blockExplorers?: Partial>; - printTraces?: boolean; - progressHook?: (progress: ProgressEvent) => void; - } + options?: PushChainInitializeOptions ): Promise => { return PushChain.createInstance(universalSigner, options); }; @@ -815,27 +880,20 @@ export class PushChain { */ reinitialize = async ( universalSigner: UniversalSigner | UniversalAccount, - options?: { - network: PUSH_NETWORK; - rpcUrls?: Partial>; - blockExplorers?: Partial>; - printTraces?: boolean; - progressHook?: (progress: ProgressEvent) => void; - } + options?: PushChainInitializeOptions ): Promise => { - const mergedOptions = { + // agenticWallet is NOT inherited: an omitted value yields an ordinary + // client, and a supplied one reruns every wallet/role check. + const mergedOptions: PushChainInitializeOptions = { network: options?.network ?? this.orchestrator.getNetwork(), rpcUrls: options?.rpcUrls ?? this.orchestrator.getRpcUrls(), blockExplorers: options?.blockExplorers ?? this.blockExplorers, printTraces: options?.printTraces ?? this.orchestrator.getPrintTraces(), progressHook: options?.progressHook ?? this.orchestrator.getProgressHook(), - } as { - network: PUSH_NETWORK; - rpcUrls?: Partial>; - blockExplorers?: Partial>; - printTraces?: boolean; - progressHook?: (progress: ProgressEvent) => void; + ...(options?.agenticWallet !== undefined + ? { agenticWallet: options.agenticWallet } + : {}), }; return PushChain.createInstance(universalSigner, mergedOptions); }; diff --git a/packages/core/src/lib/read-state/__tests__/public-surface.spec.ts b/packages/core/src/lib/read-state/__tests__/public-surface.spec.ts index d55a2c22e..1beef01ef 100644 --- a/packages/core/src/lib/read-state/__tests__/public-surface.spec.ts +++ b/packages/core/src/lib/read-state/__tests__/public-surface.spec.ts @@ -17,9 +17,10 @@ describe('read-state public surface', () => { ]) { expect(typeof (core as Record)[fn]).toBe('function'); } - expect(core.CHAIN.WEB2).toBe('web2:https'); - expect(PushChain.CONSTANTS.CHAIN.WEB2).toBe('web2:https'); - expect(Object.values(PushChain.CONSTANTS.CHAIN)).not.toContain('web2:https'); + // CHAIN.WEB2 is the deprecated alias of READ.CHAIN.WEB2 (AGW SDK page §4). + expect(core.CHAIN.WEB2).toBe('web2'); + expect(PushChain.CONSTANTS.CHAIN.WEB2).toBe(PushChain.CONSTANTS.READ.CHAIN.WEB2); + expect(Object.values(PushChain.CONSTANTS.CHAIN)).not.toContain('web2'); expect(core.UNIVERSAL_READ_STATUS.FULFILLED).toBe(3); expect(core.READ_STATUS.ERROR).toBe(2); expect(core.READ_ERROR_CODE.INVALID_QUERY).toBe(1); @@ -30,7 +31,8 @@ describe('read-state public surface', () => { it('PushChain.CONSTANTS.READ pins the contract-derived values', () => { const R = PushChain.CONSTANTS.READ; - expect(R.WEB2).toBe('web2:https'); + expect(R.WEB2).toBe('web2'); + expect(R.CHAIN.WEB2).toBe('web2'); expect(R.MAX_CALLBACK_GAS_LIMIT).toBe(1_000_000n); expect(R.MIN_CONFIRMATIONS_FLOOR).toBe(1); expect(R.DEFAULT_EXPIRY_BLOCKS).toBe(300n); diff --git a/packages/core/src/lib/read-state/__tests__/web2-compat.spec.ts b/packages/core/src/lib/read-state/__tests__/web2-compat.spec.ts new file mode 100644 index 000000000..e964bb7ea --- /dev/null +++ b/packages/core/src/lib/read-state/__tests__/web2-compat.spec.ts @@ -0,0 +1,69 @@ +/** + * READ.CHAIN / Web2 compatibility (AGW SDK page §4, confirmed 2026-09-28): + * new enumerable READ.CHAIN.WEB2 = 'web2'; CHAIN.WEB2 and READ.WEB2 stay as + * deprecated aliases; the legacy 'web2:https' literal is still accepted; the + * wire identity the node routes on stays web2:https. + */ +import { PushChain } from '../../index'; +import { CHAIN } from '../../constants/enums'; +import { validateRouteParams } from '../../orchestrator/route-detector'; +import { resolveDestination } from '../destination'; +import { toBuildReadSpecParams, toReadDestination, toReadQuery } from '../read-params'; +import type { ReadChain } from '../read-state.types'; + +const R = PushChain.CONSTANTS.READ; +const web2 = { web2: { extract: [{ path: '$.a', valueType: 'string' as const }] } }; + +describe('READ.CHAIN', () => { + it('is an enumerable superset of CHAIN with WEB2 = "web2"', () => { + const values = Object.values(R.CHAIN); + for (const c of Object.values(CHAIN)) expect(values).toContain(c); + expect(values).toContain('web2'); + expect(Object.keys(R.CHAIN)).toContain('WEB2'); + expect(R.CHAIN.ETHEREUM_SEPOLIA).toBe(CHAIN.ETHEREUM_SEPOLIA); + }); + + it('CHAIN itself still enumerates only blockchains', () => { + expect(Object.keys(CHAIN)).not.toContain('WEB2'); + expect(CHAIN.WEB2).toBe(R.CHAIN.WEB2); + expect(R.WEB2).toBe(R.CHAIN.WEB2); + }); +}); + +describe.each([ + ['READ.CHAIN.WEB2', R.CHAIN.WEB2], + ['deprecated CHAIN.WEB2', CHAIN.WEB2], + ['legacy literal', 'web2:https'], +] as const)('%s', (_name, chain) => { + it('normalizes to the unchanged wire identity', () => { + const dest = resolveDestination(toReadDestination(chain as ReadChain)); + expect(dest).toMatchObject({ chainNamespace: 'web2', chainId: 'https', caip2: 'web2:https', namespace: 'web2' }); + expect(resolveDestination({ chain: chain as ReadChain })).toMatchObject({ caip2: 'web2:https' }); + }); + + it('builds an http query and spec params', () => { + expect(toReadQuery('https://x.test/a', { chain: chain as ReadChain, ...web2 })).toMatchObject({ type: 'http', url: 'https://x.test/a' }); + expect(toBuildReadSpecParams('https://x.test/a', { chain: chain as ReadChain, ...web2 }).destination).toEqual({ + chainNamespace: 'web2', + chainId: 'https', + }); + }); + + it('is rejected by transaction routing', () => { + expect(() => validateRouteParams({ to: { address: '0x0000000000000000000000000000000000000001', chain: chain as never } })).toThrow( + /read-only destination/ + ); + }); +}); + +describe('type-level separation', () => { + it('compiles', () => { + const a: ReadChain = 'web2'; + const b: ReadChain = 'web2:https'; + const c: ReadChain = CHAIN.ETHEREUM_SEPOLIA; + const d: ReadChain = R.CHAIN.WEB2; + // @ts-expect-error — a transaction ChainTarget never accepts the Web2 read source + const t: import('../../orchestrator/orchestrator.types').ChainTarget = { address: '0x', chain: R.CHAIN.WEB2 }; + expect([a, b, c, d, t].length).toBe(5); + }); +}); diff --git a/packages/core/src/lib/read-state/destination.ts b/packages/core/src/lib/read-state/destination.ts index 51b3b7a2a..d7b9c2686 100644 --- a/packages/core/src/lib/read-state/destination.ts +++ b/packages/core/src/lib/read-state/destination.ts @@ -1,4 +1,4 @@ -import { READ_NAMESPACE, type ReadNamespace } from '../constants/read-state'; +import { READ_NAMESPACE, WEB2_DESTINATION, WEB2_READ_CHAIN, type ReadNamespace } from '../constants/read-state'; import { UnsupportedReadDestinationError } from './errors'; import type { ReadDestination, ResolvedDestination } from './read-state.types'; @@ -17,7 +17,11 @@ const KNOWN: ReadonlySet = new Set(Object.values(READ_NAMESPACE)); export function resolveDestination(dest: ReadDestination): ResolvedDestination { let chainNamespace: string; let chainId: string; - if ('chain' in dest) { + if ('chain' in dest && dest.chain === WEB2_READ_CHAIN) { + // The public 'web2' spelling has no ':'; its wire identity is web2:https. + chainNamespace = WEB2_DESTINATION.chainNamespace; + chainId = WEB2_DESTINATION.chainId; + } else if ('chain' in dest) { const idx = dest.chain.indexOf(':'); if (idx <= 0) { throw new UnsupportedReadDestinationError(`chain is not CAIP-2: ${dest.chain}`); diff --git a/packages/core/src/lib/read-state/read-params.ts b/packages/core/src/lib/read-state/read-params.ts index ca8e5b653..65b18ab3f 100644 --- a/packages/core/src/lib/read-state/read-params.ts +++ b/packages/core/src/lib/read-state/read-params.ts @@ -19,11 +19,11 @@ import { detectTokenProgram, resolveAccountName, validateIdl } from './svm-accou import { deriveIdlPda, isProgramSubject } from './svm-pda'; import { CHAIN, PUSH_NETWORK } from '../constants/enums'; import { computeReadQueryKey, resolveReadCallback } from './registry'; -import { READ_NAMESPACE, WEB2_DESTINATION } from '../constants/read-state'; +import { isWeb2ReadChain, READ_NAMESPACE, WEB2_DESTINATION } from '../constants/read-state'; import { resolveDestination } from './destination'; import { deriveAssociatedTokenAddress } from './envelopes/svm'; import { InvalidReadQueryError } from './errors'; -import type { BuildReadSpecParams, ReadChain, ReadDestination, ReadLifecycleOptions, ReadQuery, ReadResultShape, Web2Extract } from './read-state.types'; +import type { BuildReadSpecParams, ReadChain, ReadDestination, ReadLifecycleOptions, ReadQuery, ReadResultShape, Web2Extract, Web2ReadChain } from './read-state.types'; import type { ReadCallback } from './read-state.types'; import type { ProgressEvent } from '../progress-hook/progress-hook.types'; @@ -106,7 +106,7 @@ export type ReadQueryOptions = | ({ chain: SvmChain } & Query<'idl' | 'functionName' | 'args', { idl: Idl; functionName?: string; args?: readonly unknown[] }>) | ({ chain: EvmChain } & Query<'abi' | 'functionName' | 'args', { abi: Abi; functionName: string; args?: readonly unknown[] }>) | ({ chain: EvmChain } & Query<'storageSlot', { storageSlot: Hex | bigint }>) - | ({ chain: typeof CHAIN.WEB2 } & Query<'web2', { web2: ReadWeb2Options }>); + | ({ chain: Web2ReadChain } & Query<'web2', { web2: ReadWeb2Options }>); export type ReadCallbackOptions = { callback?: @@ -122,7 +122,7 @@ type FinalizedDestinationOptions = { blockNumber?: never; minConfirmations?: nev /** EVM reads expose pinning; SVM and Web2 choose their finalized/heightless references internally. */ export type ReadPrepareOptions = SharedPrepareOptions & ( | (Extract & EvmPinningOptions) - | (Extract & FinalizedDestinationOptions) + | (Extract & FinalizedDestinationOptions) ); export type ReadExecuteOptions = Pick; type WithExecutionOptions = T extends unknown @@ -196,7 +196,8 @@ function queryKind(o: ReadOptionsInput): (typeof QUERY_KEYS)[number] | 'native' } export function toReadDestination(chain: ReadChain): ReadDestination { - return chain === CHAIN.WEB2 ? WEB2_DESTINATION : { chain }; + // Both public spellings normalize to the unchanged wire identity web2:https. + return isWeb2ReadChain(chain) ? WEB2_DESTINATION : { chain }; } /** Build the internal query from the public grammar. Pure. */ @@ -209,7 +210,7 @@ export function toReadQuery(subject: string, o: ReadOptionsInput, resolvedTokenP if (!/^https:\/\//i.test(subject)) throw new InvalidReadQueryError('web2 subject must be an https:// URL'); return { type: 'http', url: subject, ...o.web2 }; } - if (kind === 'web2') throw new InvalidReadQueryError('`web2` is only valid with chain CHAIN.WEB2'); + if (kind === 'web2') throw new InvalidReadQueryError('`web2` is only valid with chain READ.CHAIN.WEB2'); if (dest.namespace === READ_NAMESPACE.EVM) { if (!isAddress(subject)) throw new InvalidReadQueryError(`EVM subject must be an address, got ${subject}`); diff --git a/packages/core/src/lib/read-state/read-state.types.ts b/packages/core/src/lib/read-state/read-state.types.ts index 6639c2754..4f7380854 100644 --- a/packages/core/src/lib/read-state/read-state.types.ts +++ b/packages/core/src/lib/read-state/read-state.types.ts @@ -11,7 +11,9 @@ import type { ReadNamespace } from '../constants/read-state'; * Where a read goes. `CHAIN` enum values are CAIP-2 and split cleanly; the * explicit form is the low-level escape hatch; the public API uses `CHAIN.WEB2`. */ -export type ReadChain = CHAIN | typeof CHAIN.WEB2; +/** Web2 read source: `READ.CHAIN.WEB2` (`'web2'`) or the legacy `'web2:https'` literal. */ +export type Web2ReadChain = 'web2' | 'web2:https'; +export type ReadChain = CHAIN | Web2ReadChain; export type ReadDestination = { chain: ReadChain } | { chainNamespace: string; chainId: string }; export type ResolvedDestination = { diff --git a/packages/core/src/lib/read-state/read-tracker.ts b/packages/core/src/lib/read-state/read-tracker.ts index 991c698d2..79f891c0d 100644 --- a/packages/core/src/lib/read-state/read-tracker.ts +++ b/packages/core/src/lib/read-state/read-tracker.ts @@ -26,6 +26,8 @@ import { READ_TRACK_MAX_TIMEOUT_MS, READ_TRACK_MIN_POLL_INTERVAL_MS, READ_TRACK_POLL_INTERVAL_MS, + WEB2_READ_CHAIN, + isWeb2ReadChain, } from '../constants/read-state'; import type { UniversalRead } from '../generated/ucallback/v1'; import { PROGRESS_HOOK } from '../progress-hook/progress-hook.types'; @@ -343,8 +345,8 @@ export function inferResultShape(namespace: string, query: Hex): ReadResultShape } } -function chainFromCaip2(caip2: string): CHAIN | typeof CHAIN.WEB2 | undefined { - if (caip2 === CHAIN.WEB2) return CHAIN.WEB2; +function chainFromCaip2(caip2: string): CHAIN | typeof WEB2_READ_CHAIN | undefined { + if (isWeb2ReadChain(caip2)) return WEB2_READ_CHAIN; return (Object.values(CHAIN) as string[]).includes(caip2) ? (caip2 as CHAIN) : undefined; } diff --git a/packages/core/src/lib/read-state/spec-builder.ts b/packages/core/src/lib/read-state/spec-builder.ts index 52641100a..99b04efd5 100644 --- a/packages/core/src/lib/read-state/spec-builder.ts +++ b/packages/core/src/lib/read-state/spec-builder.ts @@ -13,7 +13,7 @@ import { import { UNIVERSAL_CALLBACK_EVM } from '../constants/abi/universalCallback.evm'; import { UEA_FACTORY_ABI } from '../constants/abi/uea-factory'; import { UEA_FACTORY } from '../constants/chain'; -import { DEFAULT_EXPIRY_BLOCKS, MIN_CONFIRMATIONS_FLOOR, READ_NAMESPACE, READ_PREFLIGHT_STALE_MS } from '../constants/read-state'; +import { DEFAULT_EXPIRY_BLOCKS, MIN_CONFIRMATIONS_FLOOR, READ_NAMESPACE, READ_PREFLIGHT_STALE_MS, WEB2_READ_CHAIN } from '../constants/read-state'; import { sizeCallbackBudget } from './budget'; import { computeReadQueryKey } from './registry'; import { resolveDestination } from './destination'; @@ -137,7 +137,7 @@ export function buildReadSpecFromPreflight( return { callback: params.callback, queryKey: computeReadQueryKey(params.destination, params.query), - chain: dest.caip2 as ReadChain, + chain: (isWeb2 ? WEB2_READ_CHAIN : dest.caip2) as ReadChain, spec, specTuple: toTuple(spec), encodedSpec: encodeSpec(spec), diff --git a/packages/core/src/lib/universal/prc20-address.ts b/packages/core/src/lib/universal/prc20-address.ts new file mode 100644 index 000000000..693aa28e7 --- /dev/null +++ b/packages/core/src/lib/universal/prc20-address.ts @@ -0,0 +1,168 @@ +import { CHAIN, PUSH_NETWORK } from '../constants/enums'; +import { MOVEABLE_TOKENS, type MoveableToken } from '../constants/tokens'; +import { SYNTHETIC_PUSH_ERC20 } from '../constants/chain'; + +/** Shared pure origin-to-PRC20 mapping; no signer or network reads. */ +export function getPRC20Address( + token: MoveableToken | { chain: string; address: string }, + options?: { network?: PUSH_NETWORK } +): { + address: `0x${string}`; + chain: CHAIN; + symbol: string; + decimals: number; + network: PUSH_NETWORK; +} { + const network = options?.network ?? PUSH_NETWORK.TESTNET_DONUT; + + const assertDeployed = ( + addr: string | undefined, + ctx: string + ): `0x${string}` => { + if (!addr || addr === '0xTBD') { + throw new Error( + `PRC20 address not available for ${ctx} on network ${network}` + ); + } + return addr as `0x${string}`; + }; + + // PushChainMoveableToken (pETH, pSOL, pUSDT(BNB), …) carries its PRC-20 address + // and origin `sourceChain` directly — no registry lookup needed. + const pcToken = token as Partial<{ + prc20Address: `0x${string}`; + sourceChain: CHAIN; + symbol: string; + decimals: number; + }>; + if (pcToken.prc20Address) { + return { + address: assertDeployed( + pcToken.prc20Address, + `${pcToken.symbol ?? 'token'} (${pcToken.sourceChain ?? 'unknown'})` + ), + chain: pcToken.sourceChain as CHAIN, + symbol: pcToken.symbol as string, + decimals: pcToken.decimals as number, + network, + }; + } + + // Infer origin chain, symbol, decimals by matching against MOVEABLE_TOKENS + let originChain: CHAIN | undefined; + let tokenSymbol: string | undefined; + let tokenDecimals: number | undefined; + + if ('symbol' in token) { + // MoveableToken path: infer chain by symbol + address + for (const [key, list] of Object.entries(MOVEABLE_TOKENS)) { + const k = key as CHAIN; + const match = (list ?? []).find( + (t) => t.symbol === token.symbol && t.address === token.address + ); + if (match) { + originChain = k; + tokenSymbol = match.symbol; + tokenDecimals = match.decimals; + break; + } + } + } else { + // { chain, address } path: trust the provided chain and resolve symbol via registry + originChain = token.chain as CHAIN; + const list = MOVEABLE_TOKENS[originChain] ?? []; + const match = list.find((t) => t.address === token.address); + if (match) { + tokenSymbol = match.symbol; + tokenDecimals = match.decimals; + } + } + + if (!originChain || !tokenSymbol || tokenDecimals === undefined) { + throw new Error('Unable to infer origin chain or token symbol for token'); + } + + const map = SYNTHETIC_PUSH_ERC20[network]; + if (!map) { + throw new Error(`No PRC20 address map configured for network: ${network}`); + } + + // Map token → synthetic key by origin chain family + const isEthFamily = + originChain === CHAIN.ETHEREUM_MAINNET || + originChain === CHAIN.ETHEREUM_SEPOLIA; + const isArbFamily = originChain === CHAIN.ARBITRUM_SEPOLIA; + const isBaseFamily = originChain === CHAIN.BASE_SEPOLIA; + const isBnbFamily = originChain === CHAIN.BNB_TESTNET; + const isSolFamily = originChain === CHAIN.SOLANA_DEVNET; + + let key: + | 'pETH' + | 'pETH_ARB' + | 'pETH_BASE' + | 'pBNB' + | 'pSOL' + | 'USDT_ETH' + | 'USDT_ARB' + | 'USDT_SOL' + | 'USDT_BSC' + | 'USDT_BASE' + | 'USDC_ETH' + | 'USDC_ARB' + | 'USDC_SOL' + | 'USDC_BSC' + | 'USDC_BASE'; + + switch (tokenSymbol) { + case 'ETH': { + if (isEthFamily) key = 'pETH'; + else if (isArbFamily) key = 'pETH_ARB'; + else if (isBaseFamily) key = 'pETH_BASE'; + else + throw new Error('Unsupported ETH origin chain for synthetic mapping'); + break; + } + case 'SOL': { + if (!isSolFamily) + throw new Error('SOL token provided but origin is not Solana'); + key = 'pSOL'; + break; + } + case 'BNB': { + if (!isBnbFamily) + throw new Error('BNB token provided but origin is not BNB Testnet'); + key = 'pBNB'; + break; + } + case 'USDT': { + if (isEthFamily) key = 'USDT_ETH'; + else if (isArbFamily) key = 'USDT_ARB'; + else if (isBaseFamily) key = 'USDT_BASE'; + else if (isBnbFamily) key = 'USDT_BSC'; + else if (isSolFamily) key = 'USDT_SOL'; + else + throw new Error('Unsupported USDT origin chain for synthetic mapping'); + break; + } + case 'USDC': { + if (isEthFamily) key = 'USDC_ETH'; + else if (isArbFamily) key = 'USDC_ARB'; + else if (isBaseFamily) key = 'USDC_BASE'; + else if (isBnbFamily) key = 'USDC_BSC'; + else if (isSolFamily) key = 'USDC_SOL'; + else + throw new Error('Unsupported USDC origin chain for synthetic mapping'); + break; + } + default: + throw new Error(`Unsupported token symbol: ${tokenSymbol}`); + } + + return { + address: assertDeployed(map[key], `${tokenSymbol} on ${originChain}`), + chain: originChain, + symbol: tokenSymbol, + decimals: tokenDecimals, + network, + }; +} diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts index 7670f2c3a..3fa4d9798 100644 --- a/packages/core/src/lib/utils.ts +++ b/packages/core/src/lib/utils.ts @@ -18,7 +18,7 @@ import { PAYABLE_TOKENS, type MoveableToken, } from './constants/tokens'; -import { SYNTHETIC_PUSH_ERC20 } from './constants/chain'; +import { getPRC20Address } from './universal/prc20-address'; import { UniversalAccount } from './universal/universal.types'; import type { PushChain } from './push-chain/push-chain'; import { encodeFunctionData, formatUnits, getAddress } from 'viem'; @@ -31,6 +31,7 @@ import { import { PC20WrapperNotRegisteredError } from './orchestrator/internals/pc20/errors'; import { getPushChainForNetwork } from './orchestrator/internals/helpers'; import { Buffer } from 'buffer'; +import { agenticUtils } from './agentic/utils'; import type { Idl } from '@coral-xyz/anchor'; import { encodeAnchorIxData, @@ -309,7 +310,8 @@ export class Utils { } // SVM path: explicit idl, or legacy abi-as-IDL for backward compat. - const svmIdl: Idl | undefined = idl ?? (isAnchorIdl(abi) ? abi : undefined); + const svmIdl: Idl | undefined = + idl ?? (isAnchorIdl(abi) ? abi : undefined); if (svmIdl) { registerSvmIdl(svmIdl); const bytes = encodeAnchorIxData(svmIdl, functionName, args); @@ -771,179 +773,7 @@ export class Utils { * }); * ``` */ - getPRC20Address( - token: MoveableToken | { chain: string; address: string }, - options?: { network?: PUSH_NETWORK } - ): { - address: `0x${string}`; - chain: CHAIN; - symbol: string; - decimals: number; - network: PUSH_NETWORK; - } { - const network = options?.network ?? PUSH_NETWORK.TESTNET_DONUT; - - const assertDeployed = ( - addr: string | undefined, - ctx: string - ): `0x${string}` => { - if (!addr || addr === '0xTBD') { - throw new Error( - `PRC20 address not available for ${ctx} on network ${network}` - ); - } - return addr as `0x${string}`; - }; - - // PushChainMoveableToken (pETH, pSOL, pUSDT(BNB), …) carries its PRC-20 address - // and origin `sourceChain` directly — no registry lookup needed. - const pcToken = token as Partial<{ - prc20Address: `0x${string}`; - sourceChain: CHAIN; - symbol: string; - decimals: number; - }>; - if (pcToken.prc20Address) { - return { - address: assertDeployed( - pcToken.prc20Address, - `${pcToken.symbol ?? 'token'} (${pcToken.sourceChain ?? 'unknown'})` - ), - chain: pcToken.sourceChain as CHAIN, - symbol: pcToken.symbol as string, - decimals: pcToken.decimals as number, - network, - }; - } - - // Infer origin chain, symbol, decimals by matching against MOVEABLE_TOKENS - let originChain: CHAIN | undefined; - let tokenSymbol: string | undefined; - let tokenDecimals: number | undefined; - - if ('symbol' in token) { - // MoveableToken path: infer chain by symbol + address - for (const [key, list] of Object.entries(MOVEABLE_TOKENS)) { - const k = key as CHAIN; - const match = (list ?? []).find( - (t) => t.symbol === token.symbol && t.address === token.address - ); - if (match) { - originChain = k; - tokenSymbol = match.symbol; - tokenDecimals = match.decimals; - break; - } - } - } else { - // { chain, address } path: trust the provided chain and resolve symbol via registry - originChain = token.chain as CHAIN; - const list = MOVEABLE_TOKENS[originChain] ?? []; - const match = list.find((t) => t.address === token.address); - if (match) { - tokenSymbol = match.symbol; - tokenDecimals = match.decimals; - } - } - - if (!originChain || !tokenSymbol || tokenDecimals === undefined) { - throw new Error( - 'Unable to infer origin chain or token symbol for token' - ); - } - - const map = SYNTHETIC_PUSH_ERC20[network]; - if (!map) { - throw new Error( - `No PRC20 address map configured for network: ${network}` - ); - } - - // Map token → synthetic key by origin chain family - const isEthFamily = - originChain === CHAIN.ETHEREUM_MAINNET || - originChain === CHAIN.ETHEREUM_SEPOLIA; - const isArbFamily = originChain === CHAIN.ARBITRUM_SEPOLIA; - const isBaseFamily = originChain === CHAIN.BASE_SEPOLIA; - const isBnbFamily = originChain === CHAIN.BNB_TESTNET; - const isSolFamily = originChain === CHAIN.SOLANA_DEVNET; - - let key: - | 'pETH' - | 'pETH_ARB' - | 'pETH_BASE' - | 'pBNB' - | 'pSOL' - | 'USDT_ETH' - | 'USDT_ARB' - | 'USDT_SOL' - | 'USDT_BSC' - | 'USDT_BASE' - | 'USDC_ETH' - | 'USDC_ARB' - | 'USDC_SOL' - | 'USDC_BSC' - | 'USDC_BASE'; - - switch (tokenSymbol) { - case 'ETH': { - if (isEthFamily) key = 'pETH'; - else if (isArbFamily) key = 'pETH_ARB'; - else if (isBaseFamily) key = 'pETH_BASE'; - else - throw new Error( - 'Unsupported ETH origin chain for synthetic mapping' - ); - break; - } - case 'SOL': { - if (!isSolFamily) - throw new Error('SOL token provided but origin is not Solana'); - key = 'pSOL'; - break; - } - case 'BNB': { - if (!isBnbFamily) - throw new Error('BNB token provided but origin is not BNB Testnet'); - key = 'pBNB'; - break; - } - case 'USDT': { - if (isEthFamily) key = 'USDT_ETH'; - else if (isArbFamily) key = 'USDT_ARB'; - else if (isBaseFamily) key = 'USDT_BASE'; - else if (isBnbFamily) key = 'USDT_BSC'; - else if (isSolFamily) key = 'USDT_SOL'; - else - throw new Error( - 'Unsupported USDT origin chain for synthetic mapping' - ); - break; - } - case 'USDC': { - if (isEthFamily) key = 'USDC_ETH'; - else if (isArbFamily) key = 'USDC_ARB'; - else if (isBaseFamily) key = 'USDC_BASE'; - else if (isBnbFamily) key = 'USDC_BSC'; - else if (isSolFamily) key = 'USDC_SOL'; - else - throw new Error( - 'Unsupported USDC origin chain for synthetic mapping' - ); - break; - } - default: - throw new Error(`Unsupported token symbol: ${tokenSymbol}`); - } - - return { - address: assertDeployed(map[key], `${tokenSymbol} on ${originChain}`), - chain: originChain, - symbol: tokenSymbol, - decimals: tokenDecimals, - network, - }; - }, + getPRC20Address, /** * Resolve a PC20 token against UniversalCore's on-chain registry, and list @@ -1042,6 +872,13 @@ export class Utils { }, }; + /** + * Agentic wallet (AGW) pure helpers: rulesId, actionId, configId, + * deriveWallet, encodeRules, decodeRules, compileCard. Generation context is + * explicit (provisional signatures, A04); no RPC is performed. + */ + static agentic = agenticUtils; + /** * Internal: resolves a CHAIN enum from either a CHAIN value or a PushChain client instance. */ diff --git a/packages/core/src/lib/vm-client/evm-client.batch-gas.spec.ts b/packages/core/src/lib/vm-client/evm-client.batch-gas.spec.ts new file mode 100644 index 000000000..d117592eb --- /dev/null +++ b/packages/core/src/lib/vm-client/evm-client.batch-gas.spec.ts @@ -0,0 +1,75 @@ +import { bytesToHex, parseTransaction } from 'viem'; +import { EvmClient } from './evm-client'; +import { CHAIN } from '../constants/enums'; +import type { UniversalSigner } from '../universal/universal.types'; +const account = '0x3333333333333333333333333333333333333333', + executor = '0x4444444444444444444444444444444444444444'; +function fixture() { + const client = new EvmClient({ rpcUrls: ['http://localhost:8545'] }); + const estimateGas = jest.fn(), + send = jest + .fn, [Uint8Array]>() + .mockResolvedValue(new Uint8Array(32)); + client.publicClient = { + estimateGas, + getCode: jest.fn().mockResolvedValue('0xef0100' + executor.slice(2)), + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(10), + maxPriorityFeePerGas: BigInt(1), + }), + getChainId: jest.fn().mockResolvedValue(42101), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as never; + const signer: UniversalSigner = { + account: { chain: CHAIN.PUSH_TESTNET_DONUT, address: account }, + signMessage: async (x) => x, + signAuthorization: jest.fn().mockResolvedValue({ + address: executor, + chainId: 42101, + nonce: 6, + yParity: 0, + r: '0x' + '11'.repeat(32), + s: '0x' + '22'.repeat(32), + }), + signAndSendTransaction: send, + }; + const run = () => + client.sendBatch7702({ + executor, + signer, + calls: [ + { to: executor, value: BigInt(0), data: '0x12345678' }, + { to: executor, value: BigInt(0), data: '0x12345678' }, + ], + }); + return { client, estimateGas, send, run }; +} +describe('7702 estimation without guessed per-call ceilings', () => { + it('uses a real estimate for an already delegated account when authorization RPC support is absent', async () => { + const f = fixture(); + f.estimateGas + .mockRejectedValueOnce(new Error('unsupported auth list')) + .mockResolvedValueOnce(BigInt(1_400_000)); + await f.run(); + const tx = parseTransaction(bytesToHex(f.send.mock.calls[0][0])); + expect(tx.gas).toBe(BigInt(1_705_000)); + expect(f.estimateGas.mock.calls[1][0]).not.toHaveProperty('stateOverride'); + }); + it('uses a delegation override for a fresh account only when authorization estimation fails', async () => { + const f = fixture(); + (f.client.publicClient.getCode as jest.Mock).mockResolvedValue('0x'); + f.estimateGas + .mockRejectedValueOnce(new Error('unsupported')) + .mockResolvedValueOnce(BigInt(2_000_000)); + await f.run(); + expect(f.estimateGas.mock.calls[1][0].stateOverride[0].code).toBe( + '0xef0100' + executor.slice(2) + ); + }); + it('does not broadcast a guessed gas budget when all supported estimates fail', async () => { + const f = fixture(); + f.estimateGas.mockRejectedValue(new Error('execution rejected')); + await expect(f.run()).rejects.toThrow('execution rejected'); + expect(f.send).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/lib/vm-client/evm-client.dynamic-gas.spec.ts b/packages/core/src/lib/vm-client/evm-client.dynamic-gas.spec.ts index 97ef53aff..bfdf2191c 100644 --- a/packages/core/src/lib/vm-client/evm-client.dynamic-gas.spec.ts +++ b/packages/core/src/lib/vm-client/evm-client.dynamic-gas.spec.ts @@ -25,7 +25,7 @@ describe('EvmClient dynamic gas estimation', () => { .fn() .mockImplementation(async (unsignedTx: Uint8Array) => { const parsed = parseTransaction(bytesToHex(unsignedTx)); - expect(parsed.gas).toBe(estimatedGas); + expect(parsed.gas).toBe(BigInt(969_600)); expect(parsed.nonce).toBe(9); return Uint8Array.from(Buffer.from(TX_HASH.slice(2), 'hex')); }); @@ -57,9 +57,9 @@ describe('EvmClient dynamic gas estimation', () => { const estimatedGas = BigInt(21_062); const client = new EvmClient({ rpcUrls: ['http://localhost:8545'] }); client.publicClient = { - getCode: jest.fn().mockResolvedValue( - '0xef01000106bf2f9b02f32203a83a3bdad79fe8818f3796' - ), + getCode: jest + .fn() + .mockResolvedValue('0xef01000106bf2f9b02f32203a83a3bdad79fe8818f3796'), estimateGas: jest.fn().mockResolvedValue(estimatedGas), estimateFeesPerGas: jest.fn().mockResolvedValue({ maxFeePerGas: BigInt(10), @@ -73,7 +73,7 @@ describe('EvmClient dynamic gas estimation', () => { .fn() .mockImplementation(async (unsignedTx: Uint8Array) => { const parsed = parseTransaction(bytesToHex(unsignedTx)); - expect(parsed.gas).toBe(estimatedGas); + expect(parsed.gas).toBe(BigInt(25_275)); return Uint8Array.from(Buffer.from(TX_HASH.slice(2), 'hex')); }); const signer: UniversalSigner = { @@ -137,4 +137,45 @@ describe('EvmClient dynamic gas estimation', () => { expect(client.publicClient.estimateGas).not.toHaveBeenCalled(); }); + + it.each([ + { explicitGas: undefined, expectedGas: BigInt(579_802) }, + { explicitGas: BigInt(500_000), expectedGas: BigInt(500_000) }, + ])( + 'adds headroom for the observed AGW outbound estimate unless gas is explicit ($explicitGas)', + async ({ explicitGas, expectedGas }) => { + // At the same Donut block, this estimate reverted while 500,000 gas passed. + const client = new EvmClient({ rpcUrls: ['http://localhost:8545'] }); + client.publicClient = { + estimateGas: jest.fn().mockResolvedValue(BigInt(483_168)), + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(10), + maxPriorityFeePerGas: BigInt(1), + }), + getChainId: jest.fn().mockResolvedValue(42101), + } as unknown as typeof client.publicClient; + const signer: UniversalSigner = { + account: { chain: CHAIN.PUSH_TESTNET_DONUT, address: ACCOUNT }, + signMessage: async (data) => data, + signAndSendTransaction: jest.fn(async (unsignedTx: Uint8Array) => { + expect(parseTransaction(bytesToHex(unsignedTx)).gas).toBe( + expectedGas + ); + return Uint8Array.from(Buffer.from(TX_HASH.slice(2), 'hex')); + }), + }; + + await client.sendTransaction({ + to: TARGET, + data: '0x12345678', + signer, + nonce: 12, + gas: explicitGas, + }); + + expect(client.publicClient.estimateGas).toHaveBeenCalledTimes( + explicitGas ? 0 : 1 + ); + } + ); }); diff --git a/packages/core/src/lib/vm-client/evm-client.ts b/packages/core/src/lib/vm-client/evm-client.ts index 772fcbaa0..88e365eae 100644 --- a/packages/core/src/lib/vm-client/evm-client.ts +++ b/packages/core/src/lib/vm-client/evm-client.ts @@ -275,13 +275,19 @@ export class EvmClient { const resolveGas = async (): Promise => { if (explicitGas) return explicitGas; - const estimate = () => - this.publicClient.estimateGas({ + const estimate = async () => { + const estimatedGas = await this.publicClient.estimateGas({ account: signer.account.address as `0x${string}`, to, data, value, }); + // Leave headroom for nested calls and state changes before inclusion, + // as on the EIP-7702 batch path. Donut can return an estimate that + // reverts inside an AGW -> gateway -> token call even at the same state. + // This is the Push execution limit, separate from destination gas. + return (estimatedGas * BigInt(120) + BigInt(99)) / BigInt(100); + }; if (data !== '0x') return estimate(); @@ -422,29 +428,35 @@ export class EvmClient { throw new EIP7702NotSupportedError(err); } - // Estimate gas against the delegated account: override the EOA's code with - // the 7702 delegation designator (0xef0100 || executor) so estimation runs - // the executor. Fall back to a safe ceiling if the node rejects overrides. + // Prefer authorization-aware estimation of the actual type-4 transaction. + // Some RPCs reject that format or state overrides. An ordinary estimate is + // valid only if the account is already delegated to this exact executor. + // Never guess a per-call ceiling: contract deployment/grants vary in cost. + const delegation = ('0xef0100' + executor.slice(2)) as Hex; let gas: bigint; try { gas = await this.publicClient.estimateGas({ account, to: account, data, - stateOverride: [ - { - address: account, - code: ('0xef0100' + executor.slice(2)) as Hex, - }, - ], + authorizationList: [authorization as never], }); - gas = (gas * BigInt(120)) / BigInt(100); // headroom for delegation + auth } catch { - // Estimation unavailable (e.g. node rejects state overrides). Scale the - // ceiling with the batch size so large batches don't OOG — the legacy - // per-call loop budgeted ~500k gas per call; mirror that plus overhead. - gas = BigInt(500_000) * BigInt(calls.length) + BigInt(100_000); + const code = await this.publicClient.getCode({ address: account }); + if (code?.toLowerCase() === delegation.toLowerCase()) { + gas = await this.publicClient.estimateGas({ account, to: account, data }); + } else { + gas = await this.publicClient.estimateGas({ + account, + to: account, + data, + stateOverride: [{ address: account, code: delegation }], + }); + } } + // The fallback estimates omit authorization processing (25k intrinsic). + // Include it conservatively on every path, plus 20% execution headroom. + gas = (gas * BigInt(120) + BigInt(99)) / BigInt(100) + BigInt(25_000); const unsignedTx = serializeTransaction({ chainId, diff --git a/packages/core/tsconfig.agw-local.json b/packages/core/tsconfig.agw-local.json new file mode 100644 index 000000000..e547653d4 --- /dev/null +++ b/packages/core/tsconfig.agw-local.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["__agw-local__/**/*.ts", "src/**/*.d.ts"] +} From 8fe12895f73406ac49023388b2bc2c9c5cf9caab Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Mon, 12 Oct 2026 23:09:58 +0530 Subject: [PATCH 02/27] feat(core): align AGW rules and presets with October SDK feedback --- .gitignore | 1 + packages/core/AGW.md | 356 +++------- .../__agw-local__/agw-local-advanced.spec.ts | 133 ++-- .../__agw-local__/agw-local-batch.spec.ts | 29 +- .../__agw-local__/agw-local-races.spec.ts | 18 +- packages/core/__agw-local__/agw-local.spec.ts | 95 ++- packages/core/__agw-local__/labels.spec.ts | 4 +- .../__agw-local__/native-multi-action.spec.ts | 267 +++++++ .../core/__agw-local__/svm-backend.spec.ts | 8 +- .../core/__agw-local__/svm-public.spec.ts | 84 +-- .../core/__agw-local__/v5-multiasset.spec.ts | 77 +- packages/core/__agw-local__/vectors.spec.ts | 10 +- packages/core/__e2e__/agw/create.spec.ts | 16 +- .../core/__e2e__/agw/identity-gas.spec.ts | 26 +- packages/core/__e2e__/agw/labels.spec.ts | 54 +- packages/core/__e2e__/agw/lifecycle.spec.ts | 22 +- .../__e2e__/agw/management-extended.spec.ts | 91 +-- .../core/__e2e__/agw/native-batch.spec.ts | 26 +- packages/core/__e2e__/agw/native-exec.spec.ts | 98 ++- .../__e2e__/agw/native-multi-action.spec.ts | 663 ++++++++++++++++++ .../agw/native-policy-extended.spec.ts | 82 ++- .../core/__e2e__/agw/responses-hooks.spec.ts | 49 +- packages/core/__e2e__/agw/svm-public.spec.ts | 55 +- .../core/__e2e__/agw/universal-evm.spec.ts | 2 +- .../core/__e2e__/agw/v5-multiasset.spec.ts | 14 +- packages/core/__e2e__/ci/suite.ts | 84 ++- packages/core/__e2e__/shared/agw-state.ts | 31 + packages/core/scripts/check-agw-api.js | 10 +- packages/core/scripts/check-agw-package.js | 5 +- .../src/lib/agentic/__tests__/fake-chain.ts | 11 +- .../src/lib/agentic/__tests__/labels.spec.ts | 21 +- .../lib/agentic/__tests__/management.spec.ts | 19 +- .../__tests__/native-multi-action.spec.ts | 366 ++++++++++ .../src/lib/agentic/__tests__/presets.spec.ts | 251 +++++++ .../lib/agentic/__tests__/public-rule.spec.ts | 207 ++++++ .../agentic/__tests__/reads-context.spec.ts | 80 ++- .../__tests__/response-surface.spec.ts | 32 +- .../agentic/__tests__/rules-policy.spec.ts | 17 +- .../src/lib/agentic/__tests__/send.spec.ts | 32 + packages/core/src/lib/agentic/agentic.ts | 40 +- .../core/src/lib/agentic/agentic.types.ts | 146 +++- packages/core/src/lib/agentic/capabilities.ts | 2 +- .../core/src/lib/agentic/codec/defaults.ts | 6 +- packages/core/src/lib/agentic/codec/native.ts | 170 ++++- packages/core/src/lib/agentic/codec/rules.ts | 61 +- .../core/src/lib/agentic/codec/svm-idl.ts | 2 +- packages/core/src/lib/agentic/constants.ts | 30 +- packages/core/src/lib/agentic/deployments.ts | 14 +- packages/core/src/lib/agentic/errors.ts | 7 +- .../src/lib/agentic/execution/outbound.ts | 6 +- .../src/lib/agentic/execution/svm-outbound.ts | 2 +- packages/core/src/lib/agentic/index.ts | 26 +- .../core/src/lib/agentic/management/create.ts | 12 +- .../src/lib/agentic/management/duplicates.ts | 28 + .../src/lib/agentic/management/prepare.ts | 10 +- .../src/lib/agentic/management/rules-write.ts | 35 +- .../src/lib/agentic/management/svm-public.ts | 33 +- .../core/src/lib/agentic/management/svm.ts | 2 +- .../core/src/lib/agentic/presets/catalog.ts | 23 + .../core/src/lib/agentic/presets/index.ts | 240 +++++++ .../core/src/lib/agentic/presets/types.ts | 40 ++ packages/core/src/lib/agentic/public-rule.ts | 420 +++++++++++ .../core/src/lib/agentic/reads/checkpoints.ts | 102 ++- packages/core/src/lib/agentic/reads/rules.ts | 132 +++- packages/core/src/lib/agentic/utils.ts | 42 +- packages/core/src/lib/agentic/wallet.ts | 35 +- packages/core/src/lib/index.ts | 39 +- packages/core/src/lib/utils.ts | 10 +- 68 files changed, 4183 insertions(+), 978 deletions(-) create mode 100644 packages/core/__agw-local__/native-multi-action.spec.ts create mode 100644 packages/core/__e2e__/agw/native-multi-action.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/presets.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/public-rule.spec.ts create mode 100644 packages/core/src/lib/agentic/management/duplicates.ts create mode 100644 packages/core/src/lib/agentic/presets/catalog.ts create mode 100644 packages/core/src/lib/agentic/presets/index.ts create mode 100644 packages/core/src/lib/agentic/presets/types.ts create mode 100644 packages/core/src/lib/agentic/public-rule.ts diff --git a/.gitignore b/.gitignore index a3622c685..5cd47d8ec 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ e2e-logs/ .agw-validation/ .ci-contracts/ .agw-runtime-validation/ +/plan/agw/research/native-multi-action-e2e-2026-10-12/logs diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 8945327ba..b2d919980 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -1,30 +1,16 @@ # Agentic wallets on Push Chain -This guide describes AGW v5 in this SDK branch. Donut is the configured deployment. Native Push, EVM destination rules and named-IDL Solana devnet rules are supported. Solana-origin signers can already act through their Push UEA. - -An AGW holds assets. Its owner grants or revokes rules and can execute unrestricted owner calls. An agent executes through its own Push identity, and each agent call must pass the granted policy. A Push-native key's identity is its EOA; an external key's identity is its Push UEA for that origin chain. - -```mermaid -flowchart LR - Owner[Owner EOA or UEA] -->|create, grant, update, revoke| Wallet[AGW] - Owner -->|owner execution| Wallet - Agent[Agent EOA or UEA] -->|agent execution| Wallet - Wallet -->|agent calls checked| Policy[Engine and rules policy] - Policy --> Native[Push target] - Policy --> Gateway[Gateway and destination account] -``` +This branch implements the SDK-only October 12 feedback against the deployed Donut v5 contracts. Contract-dependent changes remain explicit below. -## Connect a Push-native signer +## Connect -Pass a key from your application's key management. The functions below do not load `.env` files or run automatically. They use `viem` and the SDK's public exports. From the SDK checkout, check all TypeScript blocks together with `node packages/core/scripts/check-agw-docs.js` (or `yarn docs:check:agw` from packages/core). +Use an application-managed key; examples do not load secrets or execute automatically. ```ts import { PushChain, AgenticError, - type NativeRule, - type UniversalRule, - type SolanaRule, + type Rule, } from '@pushchain/core'; import { createWalletClient, @@ -60,307 +46,149 @@ async function connectPush(key: Hex, wallet?: Address): Promise { } ``` -`connectPush(ownerKey)` creates an ordinary owner client for management and funding. `connectPush(key, wallet)` sends from that AGW. The SDK determines the owner or agent door from the connected signer; the caller does not select a door. - -## Create a wallet and grant a native rule +## Create and manage rules -This example permits ten value transfers to one recipient before expiry, with explicit per-call and total limits. Explicit limits avoid relying on native omission defaults that are still being finalized. +Every chain uses `Rule.allowedCalls`. Omit `chainNamespace` for Push. The agent is always its Push address: a native EOA or an external signer's UEA. The owner cannot be its own agent. ```ts -async function createNativeWallet( - owner: PushChain, - agent: Address, - recipient: Address, -) { - const rule: NativeRule = { +async function createNative(owner: PushChain, agent: Address, token: Address, recipient: Address) { + const rule: Rule = { agent, - target: recipient, - selector: 'value-only', validUntil: Math.floor(Date.now() / 1000) + 3600, - maxValuePerCall: parseEther('0.001'), - maxValueTotal: parseEther('0.01'), - maxCalls: 10, + allowedCalls: [{ + target: token, + selector: 'transfer(address,uint256)', + pins: [{ arg: 0, expected: recipient }, { arg: 1, max: 10n }], + }], }; - const prediction = await owner.agentic.derive(); - const created = await owner.agentic.create('native-transfers', { - rules: [rule], - }); - return { prediction, created, rule }; + return owner.agentic.create('operations', { rules: [rule] }); } -``` -Create confirms deployment/grant receipts and returns `wallet`, `index`, `rulesIds` and transaction metadata. It does not fund the wallet or agent. A concurrent owner creation can invalidate a predicted index; the index-bound deployment refuses that race instead of granting on another wallet. - -## Fund separately, then send as the agent - -The wallet pays the transfer value. The agent pays Push transaction gas from its own EOA/UEA. Cross-chain sends also need PC in the wallet for the outbound budget. These are separate balances. - -```ts -async function fundAndSendNative( - owner: PushChain, - wallet: Address, - agentKey: Hex, - recipient: Address, -) { - await (await owner.universal.sendTransaction({ - to: wallet, - value: parseEther('0.02'), - })).wait(); - // Fund the agent's Push identity separately if it needs transaction gas. - const agent = await connectPush(agentKey, wallet); - const response = await agent.universal.sendTransaction({ - to: recipient, - value: parseEther('0.001'), +async function manage(owner: PushChain, address: Address, id: Hex) { + const wallet = owner.agentic.wallet(address); + const record = await wallet.rules.get(id); + // Reads preserve the constraints needed by update. + const changed = await wallet.rules.update({ + rules: [{ rulesId: id, rule: { ...record.rule, validUntil: record.validUntil + 3600 } }], }); - return response.wait(); + await wallet.setLabel('renamed'); // direct cosmetic write; no checkpoint + await wallet.rules.delete([changed.rules[0].rulesId]); + // Delete all requires an explicit target: + // await wallet.rules.delete({ all: true }); + return wallet.info(); } ``` -For an external signer, grant to its derived Push UEA rather than its origin-chain address. Use `PushChain.utils.account.deriveExecutorAccount` with the signer’s actual origin chain. First-use external UEA gas handling uses that origin; changing the origin chain changes the UEA identity. +`derive({ index? })` returns the predicted address, index and deployment state. `list()` enumerates the connected owner's wallets. A wallet handle provides `info`, `owner`, `checkpoints`, `setLabel`, and `rules.list/get/add/update/delete`. Deleted rules are not returned in v1. Rule IDs may change during update. -Native agent arrays use an atomic UEA/EIP-7702 transport that preserves the agent sender. Each action is checked individually. An unavailable atomic transport fails explicitly; the SDK does not submit the agent batch sequentially. +`create` and `rules.add` reject a duplicate agent/chain pair with `DUPLICATE_RULE`. Put several permitted calls in the same rule. Existing overlapping grants can still cause `AMBIGUOUS_RULE` when sending; the owner must resolve the overlap. The contract validates every actual agent call. -## Read, replace and revoke rules +On deployed v5, native rules allow at most **eight** calls, eight equality pins and one maximum pin per call. Public native lifetime budgets, `maxCalls`, `amount`, single-call shorthand and `actions[]` are retired. Native counters still accumulate for reporting and atomic update assertions. -A management handle is `owner.agentic.wallet(wallet)`. It reads and writes rules but does not switch the owner's transaction execution identity to the wallet. - -```ts -async function inspectWallet(owner: PushChain, wallet: Address) { - const handle = owner.agentic.wallet(wallet); - const [info, ownerRecord, rules] = await Promise.all([ - handle.info(), handle.owner(), handle.rules.list(), - ]); - return { info, owner: ownerRecord.owner, rules: rules.rules }; -} - -async function replaceNativeRule( - owner: PushChain, - wallet: Address, - rulesId: Hex, - replacement: NativeRule, -) { - return owner.agentic.wallet(wallet).rules.update({ - rules: [{ rulesId, rule: replacement }], - }); -} - -async function revokeRule(owner: PushChain, wallet: Address, rulesId: Hex) { - return (await owner.agentic.wallet(wallet).rules.revoke([rulesId])).wait(); -} -``` +## Pins and zero defaults -Replacement asserts the old spend, revokes and grants in one owner transaction. Intervening agent spending makes the whole replacement revert. The new rule gets a new ID and fresh counters. A single replacement records five checkpoints. Failed transactions unwind their checkpoints; agent calls do not tick the owner checkpoint count. +An equality pin fixes an argument; a maximum pin bounds it. For EVM signatures, `arg` is a zero-based static argument position. With a raw four-byte selector, it is a 32-byte ABI head-word position. Reads return a raw selector and these word positions. Unsupported dynamic/compound layouts fail explicitly; no guessed tuple-field indexes are produced. -`rules.list()` returns enabled rules, including enabled rules whose expiry has passed. Expiry is enforced on execution. Revoked-history reconstruction is outside v1. Rule IDs are wallet-scoped; do not treat an ID alone as a globally unique permission. +`PushChain.CONSTANTS.AGENTIC.WALLET` resolves to the AGW on Push, its CEA on an EVM destination, or its CEA/selected token ATA on Solana. Deployment addresses and encoding helpers are internal. -## Wallet labels +Omitted `maxValue`, `assets[].maxTotal`, and `maxGasPerCall` default to **zero**, which permits no corresponding movement. Specify positive caps for sends that need value, tokens or destination gas. Native value is limited per call; there is no public native lifetime-total cap. -Donut uses the v5 factory `0x8137F96A50EBF41d904e3678c84c391a0D1BCcc5`. Wallet labels are stored on-chain; `info()` and `list()` read the current value. An empty deployment label defaults to `AGW `. +## EVM destinations ```ts -async function renameWallet(owner: PushChain, wallet: Address) { - const handle = owner.agentic.wallet(wallet); - const renamed = await handle.setLabel('Trading'); - const info = await handle.info(); - const reset = await handle.setLabel(''); - return { renamed, info, reset }; -} -``` - -Create and rename labels accept at most **64 UTF-8 bytes**, including spaces; 16 four-byte emoji fit. Empty rename resets the default. The SDK renames through the owner door for EOA and UEA owners, emits progress through the supplied hook and records one owner-action checkpoint. The contract's direct `setLabel` call itself records no checkpoint. Agent and read-only clients cannot rename. - -New wallets use v5 only. Existing v4 wallets do not acquire label functions and are unsupported by this SDK adapter. Wallet predictions and EIP-712 intent domains use the new factory; do not reuse old predicted addresses or signatures. On relayed signed deployment, the contract does not include the cosmetic label in `OwnerIntent`; the owner can correct it afterward. - -## Grant an EVM destination rule - -The destination token identifies an asset on that destination chain. The SDK resolves the mapped PRC20 held by the AGW. Limits use the token's smallest units; `maxGasPerCall` uses PC wei. A rule may list several assets, but each outbound request moves one listed token. - -The example below bridges at most 0.000001 ETH per call and permits a counter's `increment()` call. The explicit zero-value call leaves the bridged ETH in the wallet's CEA. Use a target that actually implements that selector. - -```ts -async function createSepoliaWallet( - owner: PushChain, - agent: Address, - counter: Address, -) { - const rule: UniversalRule = { +function evmRule(agent: Address, target: Address): Rule { + return { agent, chainNamespace: SEPOLIA, - assets: [{ - token: ETH, - maxPerCall: BigInt(10) ** BigInt(12), - maxTotal: BigInt(10) ** BigInt(13), - }], - maxGasPerCall: parseEther('20'), validUntil: Math.floor(Date.now() / 1000) + 3600, - allowedCalls: [{ target: counter, selector: 'increment()', maxValue: BigInt(0) }], + assets: [{ token: ETH, maxPerCall: 1_000n, maxTotal: 10_000n }], + maxGasPerCall: parseEther('20'), + allowedCalls: [{ + target, + selector: 'deposit(address,uint256)', + pins: [{ arg: 0, expected: PushChain.CONSTANTS.AGENTIC.WALLET }], + }], }; - return owner.agentic.create('sepolia-counter', { rules: [rule] }); } ``` -An omitted universal asset total uses uint256 maximum; explicit zero forbids movement. `assets: []` is a call-only rule, represented internally by a zero-cap destination gas token. The SDK derives `expectedCEA` from the wallet, not the agent. - -## Fund the PRC20 and set a bounded allowance - -The gateway pulls the selected PRC20 from the AGW. Set the allowance through the owner door as a separate, explicit action. Neither owner nor agent outbound sends change allowances automatically. - -```ts -async function prepareSepoliaWallet( - owner: PushChain, - ownerKey: Hex, - wallet: Address, - amount: bigint, -) { - const prc20 = PushChain.utils.tokens.getPRC20Address(ETH, { network: NETWORK }); - // The ordinary owner account must already hold the mapped PRC20. - await (await owner.universal.sendTransaction({ - to: wallet, value: parseEther('21'), - })).wait(); - await (await owner.universal.sendTransaction({ - to: prc20.address, - data: encodeFunctionData({ - abi: erc20Abi, functionName: 'transfer', args: [wallet, amount], - }), - })).wait(); - const ownerDoor = await connectPush(ownerKey, wallet); - const gateway = '0x00000000000000000000000000000000000000C1' as Address; - await (await ownerDoor.universal.sendTransaction({ - to: prc20.address, - data: encodeFunctionData({ - abi: erc20Abi, functionName: 'approve', args: [gateway, amount], - }), - })).wait(); -} +The deployed policy supports one `WALLET` equality pin per EVM destination call. General destination equality/maximum pins need the new contract deployment and currently fail with `CAPABILITY_UNAVAILABLE` before signing. -async function sendToSepolia(agent: PushChain, counter: Address, amount: bigint) { - return agent.universal.sendTransaction({ - to: { address: counter, chain: SEPOLIA }, - data: [{ to: counter, value: BigInt(0), data: '0xd09de08a' }], - funds: { amount, token: ETH }, - gasLimit: BigInt(2_000_000), // includes fresh CEA finalization in this example - }); -} -``` +When the caller omits destination `gasLimit`, a fresh EVM CEA gets a 2,000,000 gas default to cover first-use deployment. An existing CEA uses the normal quote default. An explicit caller value is preserved. The rule's gas/value caps still apply. -The current core default of 500,000 destination gas was insufficient for fresh Sepolia CEA finalization in our live test (~1.3 million estimated). The example supplies a bounded two-million-gas limit; choose a budget appropriate for your destination calls. The 20/21-PC values are example budgets, not fee estimates. Fees and gas are quoted for the current request. Approval safety is the application's responsibility; the SDK validates structure and contract limits rather than deciding whether a grant is appropriate. +## Solana destinations -## Wait, replay and recover - -`from` is the AGW and `origin` is the signer. For EVM outbounds, `to/data/value` describe the first destination call; `agentic.destinationCalls` preserves all destination calls. `agentic.rawTo/rawData` retain the wrapped Push call. +Rules use fixed-width signatures and numeric positions, without an IDL: ```ts -async function waitForDelivery(response: WalletTransaction) { - const receipt = await response.wait({ outboundTimeoutMs: 600_000 }); +function solanaRule(agent: Address, program: string): Rule { return { - pushSucceeded: receipt.status === 1, - destinationStatus: receipt.externalStatus, - externalTxHash: receipt.externalTxHash, + agent, + chainNamespace: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: parseEther('20'), + allowedCalls: [{ + target: program, + selector: 'receive_sol(u64)', + pins: [ + { account: 2, expected: PushChain.CONSTANTS.AGENTIC.WALLET }, + { arg: 0, max: 1_000n }, + ], + }], }; } +``` -async function replay(owner: PushChain, hash: string) { - const response = await owner.universal.trackTransaction(hash); - return response.wait({ outboundTimeoutMs: 600_000 }); -} +Use the program's actual account positions. `{ account, expected: WALLET, token: mint }` pins the wallet's ATA, using the verified mint/token-program context. Fixed integer, bool, pubkey and fixed-array layouts are supported; dynamic or undefined layouts fail before signing. Raw eight-byte discriminators support account pins; argument pins also need argument types. A nonzero per-call EVM `maxValue` has no Solana encoding. -function recoveryDetails(error: unknown) { - if (!(error instanceof AgenticError)) throw error; - return { code: error.code, hint: error.hint, details: error.details }; -} -``` +Solana reads return `format: 'decoded'` with exact stored discriminators, offsets and constraints. This raw form is accepted by `rules.update`, preserving restrictions without inventing an IDL. Reusing it for another wallet/gateway is rejected. Send-data encoding still uses the existing `utils.helpers.encodeTxData` IDL helper; rule authoring and transaction encoding are separate operations. -A successful Push receipt does not mean destination success. Check `externalStatus`. A timeout can mean the relay is still processing; track the same hash rather than assuming the transaction failed. Reverted Push roots do not start destination polling. Wrapped outbounds are recognized from their calldata even if the indexer initially reports only the Push leg. +## Spend, checkpoints and execution -| Result | Application action | -| --- | --- | -| `INDEX_RACE` | This create committed nothing; derive/retry the next slot. | -| `CREATE_PARTIAL` | Inspect `details.walletDeployed`, `grantedRulesIds`, `confirmedHashes` and `pendingHash`. Reconcile unknown/pending outcomes. If deployed, finish missing grants with `rules.add`; do not create a replacement wallet blindly. | -| `NO_RULES_FOR_CHAIN` | Grant a suitable enabled rule before the agent sends. | -| `AMBIGUOUS_RULE` | Multiple enabled rules match the agent and chain. No transaction is submitted. Inspect `details.rulesIds` and have the owner resolve the overlap; the SDK does not pick or revoke a rule arbitrarily. | -| `GATEWAY_ALLOWANCE_INSUFFICIENT` | The owner must explicitly establish a sufficient allowance. | -| `CAPABILITY_UNAVAILABLE` / `GENERATION_UNSUPPORTED` | Inspect the capability/deployment details; no compatible operation was submitted. | +`rules.get/list` return `spent`. Universal spend contains one entry per asset in stored order. A single native call has scalar value/amount/call counters. Several native calls return `spent.actions`, with each entry identified by target and selector; unrelated token amounts are not summed. -Ordinary returned funds do not lower policy spend. `creditRevert` still needs platform executor support. Revoking rules stops agent authorization but does not remove ERC20 allowances; the owner manages those separately. +Native historical terms with lifetime limits or differing per-action expiries that the new public shape cannot preserve return an explicit capability error rather than silently dropping restrictions. -## Solana rules using named IDL inputs +Connect with `PushChain.initialize(signer, { agenticWallet: address })` to send from the wallet. Signer identity determines the owner or agent door. Native agent arrays are atomic and every call passes policy checks. `from` cannot be overridden. Responses identify the wallet as `from` and signer as `origin`, including wait/replay results. -Use `SolanaRule` with an Anchor IDL, instruction name, named account restrictions and field constraints. Addresses accept base58 or 32-byte hex. `walletCEA` and `walletATA` references are derived from the AGW and configured cluster; declare output mints in `outputTokens` when their wallet ATAs also need protection. +Updates assert existing spend, delete the old rule and grant its replacement atomically. Native updates assert each action separately: a rule with N native actions records N+4 checkpoints on current v5. Agent execution records no owner checkpoint. Direct `setLabel` records none. -```ts -import type { Idl } from '@coral-xyz/anchor'; +## Deployment-dependent behavior -async function createSolanaWallet( - owner: PushChain, agent: Address, program: string, - idl: Idl, recipient: string, counter: string, -) { - const rule: SolanaRule = { - agent, - chainNamespace: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET, - validUntil: Math.floor(Date.now() / 1000) + 3600, - assets: [{ token: '0x0000000000000000000000000000000000000000', maxPerCall: BigInt(10_000), maxTotal: BigInt(20_000) }], - maxGasPerCall: parseEther('20'), - allowedInstructions: [{ - program, - instruction: { idl, name: 'receive_sol' }, - accounts: [ - { name: 'counter', expected: { kind: 'address', address: counter } }, - { name: 'recipient', expected: { kind: 'address', address: recipient } }, - { name: 'cea_authority', expected: { kind: 'walletCEA' } }, - { name: 'system_program', expected: { kind: 'address', address: '11111111111111111111111111111111' } }, - ], - fields: [{ name: 'amount', max: BigInt(10_000) }], - }], - }; - return owner.agentic.create('solana-wallet', { rules: [rule] }); -} +The October 12 contract feedback is not deployed. Until it is verified: -async function sendSolanaInstruction(agent: PushChain, program: string, idl: Idl) { - const data = PushChain.utils.helpers.encodeTxData({ - abi: idl, functionName: 'receive_sol', args: [BigInt(10_000)], - }); - return agent.universal.sendTransaction({ - to: { address: program, chain: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET }, - data, value: BigInt(10_000), - }); -} -``` +- Outbound sends on both doors require a separately owner-set gateway allowance; the SDK never silently creates or restores one. Insufficient allowance fails before signing. +- Creation uses the existing index-bound deploy/grant flow. If a signer cannot execute it atomically, `CREATE_PARTIAL` preserves committed receipts and recovery details. Do not blindly retry creation. +- General EVM destination pins and the new atomic factory `createWallet` are unavailable. -These examples assume the supplied IDL describes `receive_sol` with those accounts and a `u64` amount. The send resolver needs matching account addresses/PDA definitions in its IDL. Fund the wallet and set a separate bounded gateway allowance before sending. Native SOL amounts are lamports; the wallet burns its corresponding PRC20. `assets: []` creates a zero-amount gas-token routing cap. +`rules.delete` is the public name; on-chain `revokeRules` names/events remain unchanged. Unsupported capabilities use `CAPABILITY_UNAVAILABLE`. On-chain errors retain revert details through `AgenticRevertError`/`PushChainExecutionError`. -Supported rule layouts have an eight-byte discriminator, top-level non-optional accounts, and fixed-width primitive/fixed-array arguments. Field comparisons support exact bytes (at most 32 bytes), unsigned integer min/max (up to `u64`), and same-width unsigned ratio floors. Variable-length or nested/defined argument layouts and optional/nested accounts are rejected before signing. An IDL describes encoding; it does not prove what the program does with those accounts. +## Preset discovery and templates (section 7) -Agent SVM sends require one instruction encoded with `encodeTxData` (which registers the IDL), or data matching an already registered IDL. Instruction arrays remain unsupported. Owners can also send funds without any IDL or instruction: +`PushChain.utils.agentic` contains only `presets.list/get`. The enumerable categories are `AGENTIC.PRESETS.CATEGORY.DEXES`, `LENDING` and `PREDICTION`. All three currently have empty `chains` lists because the app/address catalog is not approved yet; hide them in a UI. No production DEX/lending addresses ship in this framework. ```ts -async function transferSolanaFunds(ownerWallet: PushChain, recipient: string) { - return ownerWallet.universal.sendTransaction({ - to: { address: recipient, chain: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET }, - value: BigInt(10_000), // native SOL, in lamports - }); -} - -async function transferSolanaUsdt(ownerWallet: PushChain, recipient: string) { - return ownerWallet.universal.sendTransaction({ - to: { address: recipient, chain: PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET }, - funds: { - token: PushChain.CONSTANTS.MOVEABLE.TOKEN.SOLANA_DEVNET.USDT, - amount: BigInt(1), // 0.000001 USDT at six decimals - }, - }); +const { presets } = PushChain.utils.agentic.presets.list({ type: 'CATEGORY' }); +const availablePresets = presets.filter(preset => preset.chains.length > 0); +const { rules: reviewTemplates } = PushChain.utils.agentic.presets.get( + PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, +); +// Currently [], since there is no approved catalog. Never treat an empty result as an active preset. + +function filledPreset(agent: Address) { + return PushChain.utils.agentic.presets.get( + PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, + { agent, validUntil: Math.floor(Date.now() / 1000) + 3600 }, + ).rules; } ``` -`ownerWallet` is initialized with the owner signer and `agenticWallet`. As with instruction sends, fund the wallet with PC/PRC20 and establish a separate bounded gateway allowance. Transfers use an empty payload and the requested recipient; the SDK applies core’s Solana finalization/rent budget using the wallet identity. Send either `value` for SOL or `funds` for a token, not both. A zero-amount funds-only send is rejected. - -Funds-only responses expose `agentic.destinationTransfer` with the recipient, burned Push PRC20 token, and amount. They have no `destinationInstruction`; replay uses the same representation. Native SOL transfers show their amount in `value`, while SPL transfers carry the amount in transfer metadata and show zero native value. - -Structural/context/funding failures are SDK `AgenticError`s. Granted permissions, expiry and spend limits are enforced by the contract. Refusals obtained during contract simulation or execution are mapped to `AgenticRevertError` with `decodedError`; simulation failure does not broadcast a transaction. Reads return `DecodedSolanaRule` with `format: 'decoded'` and exact stored program/account/data constraints. They cannot reconstruct an IDL; use a named `SolanaRule` input to add or replace it. Native/EVM decoded rules remain reusable in raw-offset form. +With catalog entries, `get` groups calls into one rule per chain. With agent and expiry supplied, its return type is `Rule[]`; otherwise it is `PresetRuleTemplate[]`, requiring those fields before `create`. Assets must name their chain unless a single chain filter is supplied. Missing `maxPerCall` or `maxTotal` defaults to zero (user decision, October 12), so missing caps do not grant token movement. -Responses expose `agentic.destinationInstruction` and the wallet’s destination CEA. Check `externalStatus` and replay the same transaction hash after a timeout. +Reviewed EVM catalog spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Native assets need no approval. Conflicting spenders for the same token are rejected until the policy representation is reviewed; the framework never replaces them with an unrestricted approval. General EVM approval pins still require the pending contract upgrade, and normal `create/add` validation enforces that boundary. Presets do not sign, fetch app ABIs or modify wallets. -## Current API boundaries +The remaining catalog work includes selecting verified apps/functions/chains, struct-field indexing and checking approval combinations against the delivered policy. App ABIs remain with the UI. -Rule `ref` is removed from public inputs and records; supplying it is rejected. Checkpoint `ref` is a separate existing event field and is preserved. Editable labels are implemented against the checked v5 deployment. Contract permission enforcement is already present: unauthorized actions under the supplied rule are rejected, and the SDK maps the errors. Revoked history, public spend records, public generation context and `compileCard` are outside standalone v1. Omitted native PC value limits are zero. Native argument-index and raw-offset inputs are both supported; decoded native/EVM rules preserve exact offsets. +## Validation -Use `PushChain.CONSTANTS.READ.CHAIN.WEB2` for Web2 reads. Its value is `web2`; the old literal `web2:https` is still accepted and normalizes to the same wire identity. Literal comparisons against the older spelling need updating. Web2 is a read source, not a transaction destination. +Run `node packages/core/scripts/check-agw-docs.js` to typecheck these examples. Unit tests use the normal core Jest configuration. `jest.agw-local.config.ts` uses pinned real AGW contracts and a stub gateway. Live E2Es are opt-in under `__e2e__/agw`; preflight verifies the deployment and funding limits. Local tests do not prove destination delivery, and historical live results do not count as validation of a changed public API. diff --git a/packages/core/__agw-local__/agw-local-advanced.spec.ts b/packages/core/__agw-local__/agw-local-advanced.spec.ts index 83817f1bb..9e801d6d7 100644 --- a/packages/core/__agw-local__/agw-local-advanced.spec.ts +++ b/packages/core/__agw-local__/agw-local-advanced.spec.ts @@ -24,7 +24,6 @@ import type { AgenticExecutionContext } from '../src/lib/agentic/context'; import { buildSession, encodeEnvelope } from '../src/lib/agentic/codec/session'; import { encodeUniversalTerms } from '../src/lib/agentic/codec/universal-terms'; import { startHarness, type Harness } from './harness'; - const TARGET_ABI = parseAbi([ 'function increment()', 'function deposit(address beneficiary, uint256 amount) payable', @@ -46,13 +45,11 @@ const GATEWAY_ABI = parseAbi([ const CORE_ABI = parseAbi([ 'function getOutboundTxGasAndFees(address,uint256) view returns (address,uint256,uint256,uint256,string,uint256)', ]); - type Internals = { agenticRuntime: AgenticRuntime; agenticContext?: AgenticExecutionContext; }; const internals = (c: PushChain) => c as unknown as Internals; - async function chainNow(h: Harness): Promise { return Number((await h.publicClient.getBlock()).timestamp); } @@ -73,12 +70,10 @@ const fund = (h: Harness, to: Address, value: bigint) => account: h.wallets[4].account!, chain: h.wallets[4].chain, }); - describe('AGW advanced paths against real v5 contracts', () => { let h: Harness; let owner: PushChain; let agentAddr: Address; - beforeAll(async () => { h = await startHarness(18547); owner = await h.client(0); @@ -87,26 +82,29 @@ describe('AGW advanced paths against real v5 contracts', () => { afterAll(async () => { await h?.stop(); }); - describe('native pins, amount and value caps', () => { let wallet: Address; let agent: PushChain; const beneficiary = getAddress( '0x00000000000000000000000000000000000b0b0b' ); - beforeAll(async () => { const created = await owner.agentic.create('pins', { rules: [ { agent: agentAddr, - target: h.addresses.target, - selector: 'deposit(address,uint256)', validUntil: (await chainNow(h)) + 3600, - pins: [{ arg: 0, expected: beneficiary }], - amount: { arg: 1, maxPerCall: BigInt(10), maxTotal: BigInt(15) }, - maxValuePerCall: BigInt(1000), - maxValueTotal: BigInt(1000), + allowedCalls: [ + { + target: h.addresses.target, + selector: 'deposit(address,uint256)', + maxValue: BigInt(1000), + pins: [ + { arg: 0, expected: beneficiary }, + { arg: 1, max: BigInt(10) }, + ], + }, + ], }, ], }); @@ -116,7 +114,6 @@ describe('AGW advanced paths against real v5 contracts', () => { }); agent = await h.client(1, { agenticWallet: wallet }); }); - const deposit = (to: Address, amount: bigint, value = BigInt(0)) => agent.universal.sendTransaction({ to: h.addresses.target, @@ -127,7 +124,6 @@ describe('AGW advanced paths against real v5 contracts', () => { args: [to, amount], }), }); - it('a pinned beneficiary and an in-cap amount pass', async () => { await (await deposit(beneficiary, BigInt(5), BigInt(10))).wait(); expect( @@ -138,7 +134,6 @@ describe('AGW advanced paths against real v5 contracts', () => { }) ).toBe(beneficiary); }); - it('a wrong beneficiary is rejected by URP with a decoded error and no state change', async () => { const err = await deposit(agentAddr, BigInt(5)).catch((e) => e); expect(err).toBeInstanceOf(AgenticRevertError); @@ -146,7 +141,11 @@ describe('AGW advanced paths against real v5 contracts', () => { 'PolicyCheckReverted(ArgPinMismatch)' ); const rec = await owner.agentic.wallet(wallet).rules.list(); - expect(rec.rules[0]).not.toHaveProperty('spent'); + expect(rec.rules[0].spent).toMatchObject({ + kind: 'native', + amountSpent: BigInt(5), + callsUsed: 1, + }); expect( await readNativeCounters( h.publicClient, @@ -156,43 +155,46 @@ describe('AGW advanced paths against real v5 contracts', () => { ) ).toMatchObject({ amountSpent: BigInt(5), callsUsed: 1 }); }); - - it('per-call and lifetime amount caps are enforced on-chain', async () => { + it('per-call amounts are enforced and repeated valid calls remain usable', async () => { await expect(deposit(beneficiary, BigInt(11))).rejects.toBeInstanceOf( AgenticRevertError ); await (await deposit(beneficiary, BigInt(10))).wait(); - await expect(deposit(beneficiary, BigInt(1))).rejects.toBeInstanceOf( - AgenticRevertError - ); + await (await deposit(beneficiary, BigInt(1))).wait(); }); - it('the decoded native terms carry the pins and caps as stored', async () => { const { rules } = await owner.agentic.wallet(wallet).rules.list(); expect(rules[0].rule).toMatchObject({ - pins: [ + allowedCalls: [ { - offset: 4, - expected: `0x${'0'.repeat(24)}${beneficiary - .slice(2) - .toLowerCase()}`, + pins: [ + { + arg: 0, + expected: `0x${'0'.repeat(24)}${beneficiary + .slice(2) + .toLowerCase()}`, + }, + { arg: 1, max: BigInt(10) }, + ], + maxValue: BigInt(1000), }, ], - amount: { offset: 36, maxPerCall: BigInt(10), maxTotal: BigInt(15) }, - maxValuePerCall: BigInt(1000), }); }); }); - describe('stale-spend replacement race', () => { it('an agent spend between the read and inclusion reverts the whole replacement atomically', async () => { const created = await owner.agentic.create('race', { rules: [ { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: (await chainNow(h)) + 3600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ], }); @@ -228,9 +230,9 @@ describe('AGW advanced paths against real v5 contracts', () => { rulesId: oldId, rule: { agent: agentAddr, + validUntil: (await chainNow(h)) + 3600, target: h.addresses.target, selector: 'increment()', - validUntil: (await chainNow(h)) + 3600, }, }, ], @@ -248,16 +250,19 @@ describe('AGW advanced paths against real v5 contracts', () => { ).toBe(cpBefore); }); }); - describe('expiry', () => { it('an expired-but-enabled rule still identifies the agent; the send fails on-chain', async () => { const created = await owner.agentic.create('expiring', { rules: [ { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: (await chainNow(h)) + 600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ], }); @@ -276,7 +281,6 @@ describe('AGW advanced paths against real v5 contracts', () => { expect(err.decodedError?.name).toBe('PolicyCheckReverted(RulesExpired)'); }); }); - describe('sequential create partial failure and recovery', () => { it('reports CREATE_PARTIAL with the deployed wallet and granted IDs; rules.add finishes it', async () => { // Chain time runs ahead of the local clock: the SDK accepts validUntil, the policy does not. @@ -287,15 +291,23 @@ describe('AGW advanced paths against real v5 contracts', () => { rules: [ { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: (await chainNow(h)) + 3600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, { agent: h.wallets[3].account!.address, - target: h.addresses.target, - selector: 'increment()', validUntil: skewedValidUntil, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ], }) @@ -309,16 +321,19 @@ describe('AGW advanced paths against real v5 contracts', () => { const added = await owner.agentic.wallet(wallet).rules.add([ { agent: h.wallets[3].account!.address, - target: h.addresses.target, - selector: 'increment()', validUntil: (await chainNow(h)) + 3600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ]); expect(added.rulesIds).toHaveLength(1); const { rules } = await owner.agentic.wallet(wallet).rules.list(); expect(rules).toHaveLength(2); }); - it('rules.add with several rules is one atomic owner batch: OWNER_ACTION + RULES_GRANTED per rule', async () => { const created = await owner.agentic.create('multi-add', { rules: [] }); const before = await h.publicClient.readContract({ @@ -329,15 +344,23 @@ describe('AGW advanced paths against real v5 contracts', () => { const added = await owner.agentic.wallet(created.wallet).rules.add([ { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: (await chainNow(h)) + 3600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, { agent: h.wallets[3].account!.address, - target: h.addresses.target, - selector: 'increment()', validUntil: (await chainNow(h)) + 3600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ]); expect(added.rulesIds).toHaveLength(2); @@ -355,7 +378,6 @@ describe('AGW advanced paths against real v5 contracts', () => { expect(before).toBe(BigInt(0)); }); }); - describe('agent EVM outbound (v5 multi-asset terms, stub gateway)', () => { const SEPOLIA = CHAIN.ETHEREUM_SEPOLIA; const CEA = getAddress('0x000000000000000000000000000000000000cea1'); @@ -366,7 +388,6 @@ describe('AGW advanced paths against real v5 contracts', () => { let runtime: AgenticRuntime; let ctx: AgenticExecutionContext; let ownerAgw: PushChain; - beforeAll(async () => { token = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [ SEPOLIA, @@ -374,7 +395,7 @@ describe('AGW advanced paths against real v5 contracts', () => { ]); const created = await owner.agentic.create('outbound', { rules: [] }); wallet = created.wallet; - await h.write(0, token, TOKEN_ABI, 'mint', [wallet, BigInt(1_000_000)]); + await h.write(0, token, TOKEN_ABI, 'mint', [wallet, BigInt(1000000)]); await h.publicClient.waitForTransactionReceipt({ hash: await fund(h, wallet, BigInt(10) ** BigInt(17)), }); @@ -421,7 +442,6 @@ describe('AGW advanced paths against real v5 contracts', () => { functionName: 'getPermissionIDs', args: [wallet], })) as Hex[]; - const agent = await h.client(1, { agenticWallet: wallet }); ctx = internals(agent).agenticContext!; const base = internals(agent).agenticRuntime; @@ -443,7 +463,6 @@ describe('AGW advanced paths against real v5 contracts', () => { }; ownerAgw = await h.client(0, { agenticWallet: wallet }); }); - // Explicit call array: the funds land in the wallet's CEA and the listed // call runs as given (a single `data` would also prepend transfer(target)). const send = (amount: bigint) => @@ -460,13 +479,11 @@ describe('AGW advanced paths against real v5 contracts', () => { }, }, }); - it('fails before signing when the owner has not set a gateway allowance', async () => { await expect(send(BigInt(100))).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, }); }); - it('owner sets a bounded allowance with an ordinary owner-door send; the agent outbound passes URP, pulls and burns', async () => { await ( await ownerAgw.universal.sendTransaction({ @@ -546,7 +563,7 @@ describe('AGW advanced paths against real v5 contracts', () => { recipient: '0x', token, amount: BigInt(100), - gasLimit: BigInt(200_000), + gasLimit: BigInt(2000000), gasPrice: BigInt(0), maxPCForGas: BigInt(10) ** BigInt(15), payload, @@ -572,7 +589,6 @@ describe('AGW advanced paths against real v5 contracts', () => { }); expect(keccak256(toBytes(SEPOLIA))).toBeTruthy(); }); - it('owner can remove the allowance; the agent is stopped before signing again', async () => { await ( await ownerAgw.universal.sendTransaction({ @@ -588,7 +604,6 @@ describe('AGW advanced paths against real v5 contracts', () => { code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, }); }); - it('a call outside the allow-list is rejected by URP on-chain', async () => { await ( await ownerAgw.universal.sendTransaction({ diff --git a/packages/core/__agw-local__/agw-local-batch.spec.ts b/packages/core/__agw-local__/agw-local-batch.spec.ts index ae5071df2..fdb5713ce 100644 --- a/packages/core/__agw-local__/agw-local-batch.spec.ts +++ b/packages/core/__agw-local__/agw-local-batch.spec.ts @@ -12,7 +12,6 @@ import { PUSH_BATCH_EXECUTOR_ADDRESS } from '../src/lib/constants/chain'; import { startHarness, type Harness } from './harness'; import { EvmClient } from '../src/lib/vm-client/evm-client'; import { readNativeCounters } from '../__e2e__/shared/agw-state'; - const ABI = parseAbi([ 'function increment()', 'function counter() view returns(uint256)', @@ -20,7 +19,6 @@ const ABI = parseAbi([ let h: Harness; let owner: PushChain; let previous: Address | undefined; - beforeAll(async () => { h = await startHarness(18552); previous = PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET]; @@ -37,16 +35,18 @@ afterAll(async () => { else delete PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET]; await h?.stop(); }); - -async function walletFor(maxCalls: number) { +async function walletFor(_maxCalls: number) { const result = await owner.agentic.create('native-batch', { rules: [ { agent: h.wallets[1].account!.address, - target: h.addresses.target, - selector: 'increment()', validUntil: Number((await h.publicClient.getBlock()).timestamp) + 3600, - maxCalls, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ], }); @@ -65,7 +65,6 @@ const counter = () => abi: ABI, functionName: 'counter', }); - it('atomic native agent array preserves the agent sender and meters both actions', async () => { const { wallet, rulesIds, agent } = await walletFor(3); const before = await counter(); @@ -91,7 +90,6 @@ it('atomic native agent array preserves the agent sender and meters both actions expect(getAddress(raw.from)).toBe(h.wallets[1].account!.address); expect(getAddress(raw.to as Address)).toBe(h.wallets[1].account!.address); }); - it('failure of a later native action rolls back the earlier call and its counters', async () => { const { wallet, rulesIds, agent } = await walletFor(1); const before = await counter(); @@ -107,7 +105,7 @@ it('failure of a later native action rolls back the earlier call and its counter .mockImplementation(async function (this: EvmClient, params) { const estimate = jest .spyOn(this.publicClient, 'estimateGas') - .mockResolvedValue(BigInt(2_000_000)); + .mockResolvedValue(BigInt(2000000)); try { return await original.call(this, params); } finally { @@ -116,7 +114,10 @@ it('failure of a later native action rolls back the earlier call and its counter }); try { await expect( - agent.universal.sendTransaction({ to: h.addresses.target, data: calls() }) + agent.universal.sendTransaction({ + to: h.addresses.target, + data: [calls()[0], { ...calls()[1], data: '0xffffffff' }], + }) ).rejects.toBeInstanceOf(AgenticRevertError); } finally { send.mockRestore(); @@ -137,14 +138,16 @@ it('failure of a later native action rolls back the earlier call and its counter ) ).toMatchObject({ callsUsed: 0 }); }); - it('normal estimation refuses an invalid native batch before broadcasting', async () => { const { agent } = await walletFor(1); const nonce = await h.publicClient.getTransactionCount({ address: h.wallets[1].account!.address, }); await expect( - agent.universal.sendTransaction({ to: h.addresses.target, data: calls() }) + agent.universal.sendTransaction({ + to: h.addresses.target, + data: [calls()[0], { ...calls()[1], data: '0xffffffff' }], + }) ).rejects.toBeInstanceOf(AgenticRevertError); expect( await h.publicClient.getTransactionCount({ diff --git a/packages/core/__agw-local__/agw-local-races.spec.ts b/packages/core/__agw-local__/agw-local-races.spec.ts index dd106d641..efe87bae7 100644 --- a/packages/core/__agw-local__/agw-local-races.spec.ts +++ b/packages/core/__agw-local__/agw-local-races.spec.ts @@ -12,15 +12,17 @@ import type { AgenticRuntime } from '../src/lib/agentic/runtime'; import { v5 } from '../src/lib/agentic/contracts/v5'; import { AGENTIC_ERROR_CODE } from '../src/lib/agentic/errors'; import { startHarness, type Harness } from './harness'; - const TOKEN = parseAbi([ 'function mint(address,uint256)', 'function approve(address,uint256) returns (bool)', 'function allowance(address,address) view returns (uint256)', ]); const internalsOf = (c: PushChain) => - (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; - + ( + c as unknown as { + agenticRuntime: AgenticRuntime; + } + ).agenticRuntime; describe('AGW race regressions (real v5 contracts)', () => { let h: Harness; let owner: PushChain; @@ -31,7 +33,6 @@ describe('AGW race regressions (real v5 contracts)', () => { afterAll(async () => { await h?.stop(); }); - it('R1 a concurrent creation makes create() fail with nothing granted on either wallet', async () => { const expected = await owner.agentic.derive(); const rt = internalsOf(owner); @@ -52,10 +53,14 @@ describe('AGW race regressions (real v5 contracts)', () => { rules: [ { agent: h.wallets[1].account!.address, - target: h.addresses.target, - selector: 'increment()', validUntil: Number((await h.publicClient.getBlock()).timestamp) + 3600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ], }) @@ -82,7 +87,6 @@ describe('AGW race regressions (real v5 contracts)', () => { const retried = await owner.agentic.create('intended', { rules: [] }); expect(retried.index).toBe(next.index); }); - it('R4 an allowance revoked after the SDK read it stays revoked; the outbound fails on-chain', async () => { const { wallet } = await owner.agentic.create('allowance-race', { rules: [], diff --git a/packages/core/__agw-local__/agw-local.spec.ts b/packages/core/__agw-local__/agw-local.spec.ts index 93f0a3db7..d3ae46d2a 100644 --- a/packages/core/__agw-local__/agw-local.spec.ts +++ b/packages/core/__agw-local__/agw-local.spec.ts @@ -20,7 +20,6 @@ import { } from '../src'; import { AGENTIC_ERROR_CODE } from '../src/lib/agentic/errors'; import { startHarness, type Harness } from './harness'; - const TARGET_ABI = parseAbi([ 'function increment()', 'function counter() view returns (uint256)', @@ -32,15 +31,12 @@ const WALLET_ABI = parseAbi([ 'function agentOf(bytes32) view returns (address)', 'function owner() view returns (address)', ]); - const inAnHour = () => Math.floor(Date.now() / 1000) + 3600; - describe('AGW SDK against real v5 contracts (local anvil)', () => { let h: Harness; let owner: PushChain; let ownerAddr: Address; let agentAddr: Address; - beforeAll(async () => { h = await startHarness(); owner = await h.client(0); @@ -50,7 +46,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { afterAll(async () => { await h?.stop(); }); - const counter = () => h.publicClient.readContract({ address: h.addresses.target, @@ -63,7 +58,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { abi: WALLET_ABI, functionName: 'checkpointCount', }); - it('derive() predicts slot 0 from the signer identity and matches the factory', async () => { const d = await owner.agentic.derive(); expect(d.index).toBe(0); @@ -76,7 +70,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { })) as readonly [Address, boolean]; expect(d.address).toBe(getAddress(predicted)); }); - let bare: Address; it('create(label, { rules: [] }) deploys a bare wallet without funding it', async () => { const created = await owner.agentic.create('scratch', { rules: [] }); @@ -93,7 +86,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { rulesCount: 0, }); }); - let wallet: Address; let rule1: Hex; it('create with a native rule returns the receipt-confirmed ID; grant order and IDs match the contract', async () => { @@ -101,10 +93,13 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { rules: [ { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: inAnHour(), - maxCalls: 2, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ], }); @@ -122,7 +117,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { // Sequential fallback on a 7702-less localnet is reported honestly. expect(typeof created.tx.atomic).toBe('boolean'); }); - it('list() returns both deployed wallets plus the next undeployed slot', async () => { const { wallets } = await owner.agentic.list(); expect( @@ -133,16 +127,13 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { [2, '', false, 0], ]); }); - - it('rules.get / rules.list decode active terms without exposing internal spend', async () => { + it('rules.get / rules.list decode active terms including spend', async () => { const rec = await owner.agentic.wallet(wallet).rules.get(rule1); expect(rec.agent).toBe(agentAddr); expect(rec.rule).toMatchObject({ - target: h.addresses.target, - maxCalls: 2, - maxValuePerCall: BigInt(0), + allowedCalls: [{ target: h.addresses.target, maxValue: BigInt(0) }], }); - expect(rec).not.toHaveProperty('spent'); + expect(rec).toHaveProperty('spent'); expect( await readNativeCounters( h.publicClient, @@ -154,7 +145,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { const { rules } = await owner.agentic.wallet(wallet).rules.list(); expect(rules.map((r) => r.rulesId)).toEqual([rule1]); }); - let agent: PushChain; it('agent client: universal.account is the wallet, origin stays the signer, sends go through executeAsAgent', async () => { agent = await h.client(1, { agenticWallet: wallet }); @@ -176,8 +166,7 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { expect(tx.agentic).toMatchObject({ wallet, door: 'agent', rulesId: rule1 }); expect(tx.agentic?.rawTo?.toLowerCase()).toBe(wallet.toLowerCase()); }); - - it('agent sends do not tick checkpoints; the policy enforces maxCalls on-chain', async () => { + it('agent sends do not tick checkpoints; counters increase without the retired maxCalls limit', async () => { const cpBefore = await checkpointCount(wallet); await ( await agent.universal.sendTransaction({ @@ -190,7 +179,7 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { ).wait(); expect(await checkpointCount(wallet)).toBe(cpBefore); const rec = await owner.agentic.wallet(wallet).rules.get(rule1); - expect(rec).not.toHaveProperty('spent'); + expect(rec).toHaveProperty('spent'); expect( await readNativeCounters( h.publicClient, @@ -199,27 +188,24 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { rule1 ) ).toMatchObject({ callsUsed: 2 }); - const err = await agent.universal - .sendTransaction({ + await ( + await agent.universal.sendTransaction({ to: h.addresses.target, data: encodeFunctionData({ abi: TARGET_ABI, functionName: 'increment', }), }) - .catch((e) => e); - expect(err).toBeInstanceOf(AgenticRevertError); - expect(err.decodedError?.name).toBe( - 'PolicyCheckReverted(CallLimitReached)' - ); + ).wait(); + expect( + (await owner.agentic.wallet(wallet).rules.get(rule1)).spent + ).toMatchObject({ kind: 'native', callsUsed: 3 }); }); - it('a stranger cannot initialize against the wallet', async () => { await expect(h.client(2, { agenticWallet: wallet })).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NOT_OWNER_OR_AGENT, }); }); - it('owner client goes through execute and ticks one OWNER_ACTION checkpoint per call', async () => { const ownerAgw = await h.client(0, { agenticWallet: wallet }); const before = await checkpointCount(wallet); @@ -245,7 +231,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { 'OWNER_ACTION', ]); }); - it('native agent arrays require an available atomic executor instead of sequential fallback', async () => { const data = encodeFunctionData({ abi: TARGET_ABI, @@ -263,7 +248,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { code: AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, }); }); - let rule2: Hex; it('rules.update replaces atomically: five checkpoints, old disabled, new usable with reset counters', async () => { const before = await checkpointCount(wallet); @@ -273,10 +257,13 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { rulesId: rule1, rule: { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: inAnHour(), - maxCalls: 1, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, }, ], @@ -295,7 +282,7 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { 'RULES_GRANTED', ]); const rec = await owner.agentic.wallet(wallet).rules.get(rule2); - expect(rec).not.toHaveProperty('spent'); + expect(rec).toHaveProperty('spent'); expect( await readNativeCounters( h.publicClient, @@ -315,7 +302,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { }); expect(tx.agentic?.rulesId).toBe(rule2); }); - it('full checkpoint history is contiguous and matches checkpointCount()', async () => { const { checkpoints } = await owner.agentic.wallet(wallet).checkpoints(); expect(checkpoints.map((c) => c.seq)).toEqual( @@ -324,18 +310,17 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { expect(BigInt(checkpoints.length)).toBe(await checkpointCount(wallet)); expect(checkpoints[0].kind).toBe('RULES_GRANTED'); }); - it('bare revoke is refused; revoke({ all: true }) disables every rule', async () => { await expect( - owner.agentic.wallet(wallet).rules.revoke(undefined as unknown as Hex[]) - ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET }); + owner.agentic.wallet(wallet).rules.delete(undefined as unknown as Hex[]) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.DELETE_NEEDS_TARGET }); await expect( - owner.agentic.wallet(wallet).rules.revoke([]) + owner.agentic.wallet(wallet).rules.delete([]) ).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET, + code: AGENTIC_ERROR_CODE.DELETE_NEEDS_TARGET, }); await ( - await owner.agentic.wallet(wallet).rules.revoke({ all: true }) + await owner.agentic.wallet(wallet).rules.delete({ all: true }) ).wait(); const { rules } = await owner.agentic.wallet(wallet).rules.list(); expect(rules).toEqual([]); @@ -349,21 +334,23 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { }) ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN }); }); - it('a non-owner cannot write rules (fails before signing)', async () => { const stranger = await h.client(2); await expect( stranger.agentic.wallet(wallet).rules.add([ { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: inAnHour(), + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ]) ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NOT_WALLET_OWNER }); }); - it('a read-only client can read but never sign, even with the owner address', async () => { const ro = await h.readOnlyClient(0, { agenticWallet: wallet }); expect(ro.universal.account).toBe(wallet); @@ -372,9 +359,13 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { ro.agentic.wallet(wallet).rules.add([ { agent: agentAddr, - target: h.addresses.target, - selector: 'increment()', validUntil: inAnHour(), + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + ], }, ]) ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.READ_ONLY }); @@ -382,7 +373,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { ro.universal.sendTransaction({ to: h.addresses.target, data: '0x' }) ).toThrow(/Read only/); }); - it('reinitialize without agenticWallet returns an ordinary client', async () => { const ownerAgw = await h.client(0, { agenticWallet: wallet }); const plain = await ownerAgw.reinitialize( @@ -395,7 +385,6 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { ); expect(plain.universal.account.toLowerCase()).toBe(ownerAddr.toLowerCase()); }); - it('signer-level and cascade entry points are closed in agentic mode', async () => { const ownerAgw = await h.client(0, { agenticWallet: wallet }); const code = AGENTIC_ERROR_CODE.NOT_ALLOWED_IN_AGENTIC_MODE; @@ -431,14 +420,12 @@ describe('AGW SDK against real v5 contracts (local anvil)', () => { }) ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.FROM_NOT_ALLOWED }); }); - it('getAccountStatus stays signer-scoped in agentic mode', async () => { const ownerAgw = await h.client(0, { agenticWallet: wallet }); const status = await ownerAgw.getAccountStatus(); expect(status.mode).toBe('signer'); expect(ownerAgw.universal.account).toBe(wallet); }); - it('errors from the agentic surface are typed', () => { expect( new AgenticError(AGENTIC_ERROR_CODE.AMBIGUOUS_RULE, 'x') diff --git a/packages/core/__agw-local__/labels.spec.ts b/packages/core/__agw-local__/labels.spec.ts index 86786d7ef..1f377f13d 100644 --- a/packages/core/__agw-local__/labels.spec.ts +++ b/packages/core/__agw-local__/labels.spec.ts @@ -66,13 +66,13 @@ describe('v5 label SDK against real contracts', () => { (x) => x.address === made.wallet )?.label ).toBe('after'); - expect(await count(made.wallet)).toBe(before + BigInt(1)); + expect(await count(made.wallet)).toBe(before); expect(hook).toHaveBeenCalledWith( expect.objectContaining({ id: 'AGENTIC-TX-199-01' }) ); await w.setLabel(''); expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); - expect(await count(made.wallet)).toBe(before + BigInt(2)); + expect(await count(made.wallet)).toBe(before); }); it('direct contract rename changes no checkpoint; subsequent SDK reads show it', async () => { diff --git a/packages/core/__agw-local__/native-multi-action.spec.ts b/packages/core/__agw-local__/native-multi-action.spec.ts new file mode 100644 index 000000000..ecd23af52 --- /dev/null +++ b/packages/core/__agw-local__/native-multi-action.spec.ts @@ -0,0 +1,267 @@ +/** + * Multi-action native rules against the real pinned v5 contracts: one rule ID + * allows several Push functions, each metered by its own stored terms. + * Unified section-6 public shape; the contract support + * (AGW MAX_NATIVE_ACTIONS = 8) is what these tests exercise. + */ +import { + encodeFunctionData, + getAddress, + parseAbi, + type Address, + type Hex, +} from 'viem'; +import { AgenticRevertError, CHAIN, type Rule, type PushChain } from '../src'; +import { PUSH_BATCH_EXECUTOR_ADDRESS } from '../src/lib/constants/chain'; +import { startHarness, type Harness } from './harness'; +const ABI = parseAbi([ + 'function increment()', + 'function counter() view returns (uint256)', + 'function deposit(address beneficiary, uint256 amount) payable', + 'function lastBeneficiary() view returns (address)', + 'function received() view returns (uint256)', +]); +const WALLET = parseAbi(['function checkpointCount() view returns (uint64)']); +const beneficiary = getAddress('0x00000000000000000000000000000000000b0b0b'); +let h: Harness; +let owner: PushChain; +let previousExecutor: Address | undefined; +beforeAll(async () => { + h = await startHarness(18553); + previousExecutor = PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET]; + PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET] = await h.deploy( + 0, + 'HarnessAtomicExecutor', + 'HarnessFixtures.sol' + ); + owner = await h.client(0); +}); +afterAll(async () => { + if (previousExecutor) + PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET] = previousExecutor; + else delete PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_LOCALNET]; + await h?.stop(); +}); +const now = async () => Number((await h.publicClient.getBlock()).timestamp); +const counter = () => + h.publicClient.readContract({ + address: h.addresses.target, + abi: ABI, + functionName: 'counter', + }); +const increment = () => + encodeFunctionData({ abi: ABI, functionName: 'increment' }); +const deposit = (to: Address, amount: bigint) => + encodeFunctionData({ abi: ABI, functionName: 'deposit', args: [to, amount] }); +async function rule(): Promise { + return { + agent: h.wallets[1].account!.address, + validUntil: (await now()) + 3600, + allowedCalls: [ + { + target: h.addresses.target, + selector: 'increment()', + }, + { + target: h.addresses.target, + selector: 'deposit(address,uint256)', + pins: [ + { arg: 0, expected: beneficiary }, + { arg: 1, max: BigInt(10) }, + ], + }, + ], + }; +} +describe('multi-action native rules (real v5 contracts)', () => { + let wallet: Address; + let rulesId: Hex; + let agent: PushChain; + beforeAll(async () => { + const created = await owner.agentic.create('multi-action', { + rules: [await rule()], + }); + wallet = created.wallet; + rulesId = created.rulesIds[0]; + agent = await h.client(1, { agenticWallet: wallet }); + }); + it('grants both functions under ONE rule ID and reads them back in order', async () => { + const { rules } = await owner.agentic.wallet(wallet).rules.list(); + expect(rules.map((r) => r.rulesId)).toEqual([rulesId]); + const decoded = rules[0].rule as Rule; + expect(decoded.allowedCalls.map((a) => a.selector)).toEqual([ + '0xd09de08a', + '0x47e7ef24', + ]); + expect(rules[0].spent).toMatchObject({ + kind: 'native', + actions: [ + expect.objectContaining({ callsUsed: 0 }), + expect.objectContaining({ callsUsed: 0 }), + ], + }); + expect(decoded.allowedCalls[1]).toMatchObject({ + pins: [ + { + arg: 0, + expected: `0x${'0'.repeat(24)}${beneficiary.slice(2).toLowerCase()}`, + }, + { arg: 1, max: BigInt(10) }, + ], + }); + }); + it('the agent can call each allowed function; each is checked against its own terms', async () => { + const before = await counter(); + const t1 = await agent.universal.sendTransaction({ + to: h.addresses.target, + data: increment(), + }); + expect((await t1.wait()).status).toBe(1); + expect(t1.agentic?.rulesId).toBe(rulesId); + const t2 = await agent.universal.sendTransaction({ + to: h.addresses.target, + data: deposit(beneficiary, BigInt(5)), + }); + expect((await t2.wait()).status).toBe(1); + expect(t2.agentic?.rulesId).toBe(rulesId); + expect(await counter()).toBe(before + BigInt(2)); + expect( + await h.publicClient.readContract({ + address: h.addresses.target, + abi: ABI, + functionName: 'lastBeneficiary', + }) + ).toBe(beneficiary); + }); + it('per-action limits still apply: a wrong pin and an over-cap amount are rejected on-chain', async () => { + const wrongPin = await agent.universal + .sendTransaction({ + to: h.addresses.target, + data: deposit(h.wallets[3].account!.address, BigInt(5)), + }) + .catch((e) => e); + expect(wrongPin).toBeInstanceOf(AgenticRevertError); + expect(wrongPin.decodedError?.name).toBe( + 'PolicyCheckReverted(ArgPinMismatch)' + ); + const overCap = await agent.universal + .sendTransaction({ + to: h.addresses.target, + data: deposit(beneficiary, BigInt(11)), + }) + .catch((e) => e); + expect(overCap).toBeInstanceOf(AgenticRevertError); + }); + it('a function outside the rule’s actions is rejected on-chain', async () => { + const err = await agent.universal + .sendTransaction({ + to: h.addresses.target, + data: encodeFunctionData({ abi: ABI, functionName: 'received' }), + }) + .catch((e) => e); + expect(err).toBeInstanceOf(AgenticRevertError); + }); + it('per-action spend is independent across repeated calls', async () => { + await ( + await agent.universal.sendTransaction({ + to: h.addresses.target, + data: increment(), + }) + ).wait(); + await ( + await agent.universal.sendTransaction({ + to: h.addresses.target, + data: increment(), + }) + ).wait(); + await ( + await agent.universal.sendTransaction({ + to: h.addresses.target, + data: deposit(beneficiary, BigInt(5)), + }) + ).wait(); + expect( + (await owner.agentic.wallet(wallet).rules.get(rulesId)).spent + ).toMatchObject({ + kind: 'native', + actions: [ + { callsUsed: 3, amountSpent: BigInt(0) }, + { callsUsed: 2, amountSpent: BigInt(10) }, + ], + }); + }); + it('one atomic agent batch can mix functions of the same rule', async () => { + const created = await owner.agentic.create('multi-action-batch', { + rules: [await rule()], + }); + const batchAgent = await h.client(1, { agenticWallet: created.wallet }); + const before = await counter(); + const tx = await batchAgent.universal.sendTransaction({ + to: h.addresses.target, + data: [ + { to: h.addresses.target, value: BigInt(0), data: increment() }, + { + to: h.addresses.target, + value: BigInt(0), + data: deposit(beneficiary, BigInt(7)), + }, + ], + }); + expect((await tx.wait()).status).toBe(1); + expect(tx.agentic?.rulesId).toBe(created.rulesIds[0]); + expect(await counter()).toBe(before + BigInt(2)); + // All-or-nothing: an over-cap item reverts the whole batch, including the increment. + const mid = await counter(); + const bad = await batchAgent.universal + .sendTransaction({ + to: h.addresses.target, + data: [ + { to: h.addresses.target, value: BigInt(0), data: increment() }, + { + to: h.addresses.target, + value: BigInt(0), + data: deposit(beneficiary, BigInt(11)), + }, + ], + }) + .catch((e) => e); + expect(bad).toBeInstanceOf(AgenticRevertError); + expect(await counter()).toBe(mid); + }); + it('rules.update replaces a multi-action rule atomically, asserting every action’s spend', async () => { + const before = (await h.publicClient.readContract({ + address: wallet, + abi: WALLET, + functionName: 'checkpointCount', + })) as bigint; + const replacement: Rule = { + ...(await rule()), + allowedCalls: [(await rule()).allowedCalls[0]], + }; + const res = await owner.agentic + .wallet(wallet) + .rules.update({ rules: [{ rulesId, rule: replacement }] }); + // 2 assertions + revoke + grant = 4 OWNER_ACTION, then RULES_REVOKED and RULES_GRANTED. + expect( + (await h.publicClient.readContract({ + address: wallet, + abi: WALLET, + functionName: 'checkpointCount', + })) as bigint + ).toBe(before + BigInt(6)); + const { rules } = await owner.agentic.wallet(wallet).rules.list(); + expect(rules.map((r) => r.rulesId)).toEqual([res.rules[0].rulesId]); + expect((rules[0].rule as Rule).allowedCalls).toHaveLength(1); + // The new rule starts with fresh counters: increment() works again. + expect( + ( + await ( + await agent.universal.sendTransaction({ + to: h.addresses.target, + data: increment(), + }) + ).wait() + ).status + ).toBe(1); + }); +}); diff --git a/packages/core/__agw-local__/svm-backend.spec.ts b/packages/core/__agw-local__/svm-backend.spec.ts index 98e22be9d..57046e94a 100644 --- a/packages/core/__agw-local__/svm-backend.spec.ts +++ b/packages/core/__agw-local__/svm-backend.spec.ts @@ -212,7 +212,9 @@ describe('internal prepared SVM backend on v5', () => { expect(stored.chainNamespace).toBe(CHAIN.SOLANA_DEVNET); await expect( owner.agentic.wallet(s.wallet).rules.get(s.id) - ).resolves.toMatchObject({ rule: { format: 'decoded', pins: s.input.terms.pins } }); + ).resolves.toMatchObject({ + rule: { format: 'decoded', pins: s.input.terms.pins }, + }); }); it('runs context, IDL, quote, allowance, wrapping and real token pull/burn as the agent', async () => { const s = await setup(); @@ -360,7 +362,7 @@ describe('internal prepared SVM backend on v5', () => { }); it('generic owner revocation disables a SVM rule without public decoding', async () => { const s = await setup(); - await (await owner.agentic.wallet(s.wallet).rules.revoke([s.id])).wait(); + await (await owner.agentic.wallet(s.wallet).rules.delete([s.id])).wait(); await expect(cfg(s.wallet, s.id)).rejects.toMatchObject({ code: 'RULE_NOT_FOUND', }); @@ -407,7 +409,7 @@ describe('internal prepared SVM backend on v5', () => { runtime.execute = async (params, options) => { if (kind === 'revocation') await ( - await owner.agentic.wallet(s.wallet).rules.revoke([s.id]) + await owner.agentic.wallet(s.wallet).rules.delete([s.id]) ).wait(); else await h.write(0, s.wallet, v5.abis.wallet, 'execute', [ diff --git a/packages/core/__agw-local__/svm-public.spec.ts b/packages/core/__agw-local__/svm-public.spec.ts index 1e8c423db..d28448e27 100644 --- a/packages/core/__agw-local__/svm-public.spec.ts +++ b/packages/core/__agw-local__/svm-public.spec.ts @@ -1,14 +1,17 @@ -/** Public named-IDL API against actual AGW/URP; destination/gateway are fixtures. */ +/** Public numeric-pin API against actual AGW/URP; destination/gateway are fixtures. */ import { PublicKey } from '@solana/web3.js'; import type { Idl } from '@coral-xyz/anchor'; import { + hexToBytes, + sha256, + toHex, encodeFunctionData, parseAbi, zeroAddress, type Address, type Hex, } from 'viem'; -import { CHAIN, PushChain, AgenticRevertError, type SolanaRule } from '../src'; +import { CHAIN, PushChain, AgenticRevertError, type Rule } from '../src'; import type { AgenticRuntime } from '../src/lib/agentic/runtime'; import { v5 } from '../src/lib/agentic/contracts/v5'; import { clearRegistry } from '../src/lib/orchestrator/svm-idl/registry'; @@ -21,7 +24,9 @@ const idl: Idl = { instructions: [ { name: 'deposit', - discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + discriminator: Array.from( + hexToBytes(sha256(toHex('global:deposit'))).slice(0, 8) + ), accounts: [{ name: 'cea_authority', writable: true }], args: [{ name: 'amount', type: 'u64' }], }, @@ -35,7 +40,7 @@ const TOKEN = parseAbi([ const CORE = parseAbi(['function setGasToken(string,address)']); const rt = (c: PushChain) => (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; -describe('public Solana IDL rules on actual v5', () => { +describe('public Solana rules on actual v5', () => { let h: Harness, owner: PushChain, token: Address; beforeAll(async () => { h = await startHarness(18561); @@ -74,7 +79,7 @@ describe('public Solana IDL rules on actual v5', () => { }), }); } - const rule = (): SolanaRule => ({ + const rule = (): Rule => ({ agent: h.wallets[1].account!.address, chainNamespace: CHAIN.SOLANA_DEVNET, validUntil: Math.floor(Date.now() / 1000) + 3600, @@ -82,12 +87,14 @@ describe('public Solana IDL rules on actual v5', () => { assets: [ { token: zeroAddress, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, ], - allowedInstructions: [ + allowedCalls: [ { - program, - instruction: { idl, name: 'deposit' }, - accounts: [{ name: 'cea_authority', expected: { kind: 'walletCEA' } }], - fields: [{ name: 'amount', max: BigInt(5) }], + target: program, + selector: 'deposit(u64)', + pins: [ + { account: 0, expected: PushChain.CONSTANTS.AGENTIC.WALLET }, + { arg: 0, max: BigInt(5) }, + ], }, ], }); @@ -102,10 +109,20 @@ describe('public Solana IDL rules on actual v5', () => { }); expect((await wallet.rules.list()).rules).toHaveLength(1); const update = await wallet.rules.update({ - rules: [{ rulesId: made.rulesIds[0], rule: rule() }], + rules: [ + { + rulesId: made.rulesIds[0], + rule: { ...record.rule, validUntil: record.validUntil + 60 }, + }, + ], }); expect(update.rules[0].rulesId).not.toBe(made.rulesIds[0]); - await (await wallet.rules.revoke([update.rules[0].rulesId])).wait(); + const reread = await wallet.rules.get(update.rules[0].rulesId); + expect(reread.rule).toEqual({ + ...record.rule, + validUntil: record.validUntil + 60, + }); + await (await wallet.rules.delete([update.rules[0].rulesId])).wait(); expect((await wallet.rules.list()).rules).toHaveLength(0); }); it('agent public send uses wallet context, mapped errors and replay metadata', async () => { @@ -353,27 +370,16 @@ describe('public Solana IDL rules on actual v5', () => { }, }); const input = rule(); - input.assets = [{ token: mint, maxPerCall: BigInt(5) }]; - input.outputTokens = [output]; - input.allowedInstructions[0].instruction = { - idl: { - ...idl, - instructions: [ - { - ...idl.instructions[0], - accounts: [ - { name: 'cea_authority' }, - { name: 'inputAta', writable: true }, - { name: 'outputAta', writable: true }, - ], - }, - ], - }, - name: 'deposit', - }; - input.allowedInstructions[0].accounts.push( - { name: 'inputAta', expected: { kind: 'walletATA', token: mint } }, - { name: 'outputAta', expected: { kind: 'walletATA', token: output } } + input.assets = [ + { token: mint, maxPerCall: BigInt(5), maxTotal: BigInt(5) }, + ]; + input.allowedCalls[0].pins!.push( + { account: 1, expected: PushChain.CONSTANTS.AGENTIC.WALLET, token: mint }, + { + account: 2, + expected: PushChain.CONSTANTS.AGENTIC.WALLET, + token: output, + } ); const made = await source.agentic.create('public-spl', { rules: [input] }); const read = await source.agentic @@ -385,9 +391,7 @@ describe('public Solana IDL rules on actual v5', () => { expect(read.rule.pins.map((p) => p.expected)).toEqual( read.rule.ceaAccounts ); - expect(read.rule.assets[0].maxTotal).toBe( - BigInt(2) ** BigInt(256) - BigInt(1) - ); + expect(read.rule.assets[0].maxTotal).toBe(BigInt(5)); await h.publicClient.waitForTransactionReceipt({ hash: await h.wallets[4].sendTransaction({ to: made.wallet, @@ -443,15 +447,17 @@ describe('public Solana IDL rules on actual v5', () => { .status ).toBe('success'); }); - it('invalid named input and removed ref never submit a transaction', async () => { + it('invalid account position and removed ref never submit a transaction', async () => { const before = await h.publicClient.getTransactionCount({ address: h.wallets[0].account!.address, }); const r = rule(); - r.allowedInstructions[0].accounts[0].name = 'typo'; + r.allowedCalls[0].pins = [ + { account: 64, expected: PushChain.CONSTANTS.AGENTIC.WALLET }, + ]; await expect( owner.agentic.create('invalid', { rules: [r] }) - ).rejects.toThrow('unknown IDL account'); + ).rejects.toThrow('account position'); await expect( owner.agentic.create('removed-ref', { rules: [{ ...rule(), ref: `0x${'11'.repeat(32)}` } as never], diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 0d5f04fef..2fe6edf43 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -1,3 +1,5 @@ +import { buildPresetApi } from '../src/lib/agentic/presets'; +import { publicRules } from '../src/lib/agentic/public-rule'; /** Actual v5 AGW/URP/engine. Token/core/gateway and destination lookup are local fixtures; no bridge/TSS proof. */ import { encodeFunctionData, @@ -6,12 +8,13 @@ import { type Address, type Hex, } from 'viem'; -import { CHAIN, type PushChain, type UniversalRule } from '../src'; +import { CHAIN, type PushChain, type Rule } from '../src'; import type { AgenticRuntime } from '../src/lib/agentic/runtime'; import { updateRules } from '../src/lib/agentic/management/rules-write'; import { createWallet } from '../src/lib/agentic/management/create'; import { configId } from '../src/lib/agentic/codec/ids'; -import { UINT256_MAX } from '../src/lib/agentic/codec/defaults'; +import { prepareWalletRules } from '../src/lib/agentic/management/prepare'; +import { Snapshot } from '../src/lib/agentic/reads/snapshot'; import { buildSession } from '../src/lib/agentic/codec/session'; import { encodeNativeTerms, @@ -69,11 +72,11 @@ describe('v5 multi-asset SDK management and execution', () => { }; } function rule( - assets: UniversalRule['assets'] = [ + assets: Rule['assets'] = [ { token: a, maxPerCall: BigInt(5), maxTotal: BigInt(7) }, { token: b, maxPerCall: BigInt(9), maxTotal: BigInt(30) }, ] - ): UniversalRule { + ): Rule { return { agent: h.wallets[1].account!.address, chainNamespace: CHAIN_NS, @@ -149,6 +152,42 @@ describe('v5 multi-asset SDK management and execution', () => { args: [configId(wallet, id, action), wallet], }); } + it('a fixture preset produces rules accepted by public create and enforced by real contracts', async () => { + const presets = buildPresetApi([ + { + constant: 'DEXES', + name: 'Local fixture only', + chains: [ + { + chainNamespace: CHAIN_NS, + allowedCalls: [ + { target: DEST, selector: 'increment()', app: 'Harness' }, + ], + maxGasPerCall: BigInt(10) ** BigInt(16), + }, + ], + }, + ]); + const { rules } = presets.get('DEXES', { + agent: h.wallets[1].account!.address, + validUntil: Math.floor(Date.now() / 1000) + 3600, + assets: [ + { + chainNamespace: CHAIN_NS, + token: a, + maxPerCall: BigInt(5), + maxTotal: BigInt(7), + }, + ], + }); + const { wallet, id, send } = await setup(rules[0]); + await waitPush(send(a, BigInt(5))); + expect((await stored(wallet, id)).assets[0].spent).toBe(BigInt(5)); + await expect(send(a, BigInt(3))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(TotalSpendCapExceeded)' }, + }); + }); + it('public create encodes two ordered assets; public get reconstructs source-token caps', async () => { const { wallet, id } = await setup(); expect((await stored(wallet, id)).assets.map((x) => x.token)).toEqual([ @@ -215,7 +254,7 @@ describe('v5 multi-asset SDK management and execution', () => { BigInt(9), ]); }); - it('omitted total encodes maxUint256; explicit zero prohibits movement', async () => { + it('omitted and explicit zero totals both prohibit movement', async () => { const { wallet, id, send } = await setup( rule([ { token: a, maxPerCall: BigInt(5) }, @@ -223,9 +262,12 @@ describe('v5 multi-asset SDK management and execution', () => { ]) ); expect((await stored(wallet, id)).assets.map((x) => x.maxTotal)).toEqual([ - UINT256_MAX, + BigInt(0), BigInt(0), ]); + await expect(send(a, BigInt(1))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(TotalSpendCapExceeded)' }, + }); await expect(send(b, BigInt(1))).rejects.toMatchObject({ decodedError: { name: 'PolicyCheckReverted(TotalSpendCapExceeded)' }, }); @@ -285,7 +327,7 @@ describe('v5 multi-asset SDK management and execution', () => { }; await expect( updateRules(racing, h.generation, wallet, { - rules: [{ rulesId: id, rule: rule() }], + rules: [{ rulesId: id, rule: publicRules([rule()])[0] }], }) ).rejects.toMatchObject({ decodedError: { name: 'AssetSpentMismatch' } }); expect( @@ -304,11 +346,26 @@ describe('v5 multi-asset SDK management and execution', () => { }) ).toBe(count); }); - it('duplicate grants are allowed, but an ambiguous send fails before any signature', async () => { + it('out-of-band duplicate contract grants still make sends ambiguous', async () => { const created = await owner.agentic.create('multiple', { - rules: [rule(), rule()], + rules: [rule()], }); - expect(new Set(created.rulesIds).size).toBe(2); + const rt = internal(owner); + const [prepared] = await prepareWalletRules( + rt, + h.generation, + h.wallets[0].account!.address, + created.wallet, + publicRules([rule()]), + await Snapshot.at(rt.reader) + ); + await h.write( + 0, + created.wallet, + h.generation.contracts.abis.wallet as never, + 'grantRules', + [prepared.session] + ); const agent = await h.client(1, { agenticWallet: created.wallet }); await expect( agent.universal.sendTransaction({ diff --git a/packages/core/__agw-local__/vectors.spec.ts b/packages/core/__agw-local__/vectors.spec.ts index ed603c0d7..dcc5d2915 100644 --- a/packages/core/__agw-local__/vectors.spec.ts +++ b/packages/core/__agw-local__/vectors.spec.ts @@ -1,3 +1,4 @@ +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; /** * Contract-generated vectors for the pure AGW codecs. Every `expected` value * below is computed BY THE PINNED CONTRACTS (pure engine/factory views, or @@ -127,7 +128,14 @@ describe('contract-generated AGW vectors (v5)', () => { maxValueTotal: BigInt(50), maxCalls: 3, }; - const created = await owner.agentic.create('vectors', { rules: [rule] }); + const { createWallet } = await import( + '../src/lib/agentic/management/create' + ); + const created = await createWallet( + (owner as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime, + 'vectors', + { rules: [rule] } + ); const wallet = created.wallet; const rid = created.rulesIds[0]; const [actionId] = (await pc.readContract({ diff --git a/packages/core/__e2e__/agw/create.spec.ts b/packages/core/__e2e__/agw/create.spec.ts index 177f72010..bd05db7c2 100644 --- a/packages/core/__e2e__/agw/create.spec.ts +++ b/packages/core/__e2e__/agw/create.spec.ts @@ -11,16 +11,13 @@ import { setupAgw, type AgwFixture, } from './_fixture'; - const d = AGW_E2E_ENABLED ? describe : describe.skip; - d('agw create', () => { let f: AgwFixture; beforeAll(async () => { f = await setupAgw(); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); - it('1. bare wallet: factory records the owner and index, no rules, no implicit funding', async () => { const before = await f.owner.agentic.derive(); const created = await f.owner.agentic.create('e2e-bare', { rules: [] }); @@ -60,16 +57,19 @@ d('agw create', () => { block: created.tx.blockNumber, }); }); - it('2. wallet with a native rule returns the receipt-confirmed rule ID', async () => { const created = await f.owner.agentic.create('e2e-rules', { rules: [ { agent: f.agentAddress, - target: f.ownerAddress, - selector: 'value-only', validUntil: inSeconds(3600), - maxValuePerCall: BigInt(1), + allowedCalls: [ + { + target: f.ownerAddress, + selector: 'value-only', + maxValue: BigInt(1), + }, + ], }, ], }); diff --git a/packages/core/__e2e__/agw/identity-gas.spec.ts b/packages/core/__e2e__/agw/identity-gas.spec.ts index 7d79af4e8..b90c5bee9 100644 --- a/packages/core/__e2e__/agw/identity-gas.spec.ts +++ b/packages/core/__e2e__/agw/identity-gas.spec.ts @@ -29,10 +29,8 @@ import { setupAgw, type AgwFixture, } from './_fixture'; - const d = AGW_E2E_ENABLED ? describe : describe.skip; const SINK = getAddress('0x000000000000000000000000000000000000dEaD'); - async function ueaOf(address: string, chain: CHAIN): Promise
{ const r = await PushChain.utils.account.deriveExecutorAccount( PushChain.utils.account.toUniversal(address, { chain }), @@ -42,31 +40,31 @@ async function ueaOf(address: string, chain: CHAIN): Promise
{ ); return getAddress(r.address); } - d('agw identity', () => { let f: AgwFixture; beforeAll(async () => { f = await setupAgw(); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); - const walletFor = async (agent: Address) => { const created = await f.owner.agentic.create('e2e-identity', { rules: [ { agent, - target: SINK, - selector: 'value-only', validUntil: inSeconds(3600), - maxValuePerCall: BigInt(1000), - maxValueTotal: BigInt(10_000), + allowedCalls: [ + { + target: SINK, + selector: 'value-only', + maxValue: BigInt(1000), + }, + ], }, ], }); await f.fundPC(created.wallet, parseEther('0.001')); return created.wallet; }; - it('1. an EVM key on Sepolia acts through its Sepolia UEA; origin and account status stay signer-scoped', async () => { const key = process.env['EVM_PRIVATE_KEY'] as Hex; if (!key) throw new Error('EVM_PRIVATE_KEY is required'); @@ -92,8 +90,7 @@ d('agw identity', () => { expect(tx.from).toBe(wallet); expect(tx.origin.startsWith('eip155:11155111:')).toBe(true); f.evidence('external-evm-agent', { wallet, uea, tx: tx.hash }); - }, 600_000); - + }, 600000); it('2. first use of an undeployed external UEA takes the one-time fee lock; the next action pays from the UEA', async () => { const key = generatePrivateKey(); const addr = privateKeyToAccount(key).address; @@ -136,8 +133,7 @@ d('agw identity', () => { ).wait(); expect(second.hasEvent('SEND-TX-105-01')).toBe(false); f.evidence('first-use-uea', { wallet, uea }); - }, 900_000); - + }, 900000); it('3. a Solana-origin key acts through its Solana UEA on a Push destination', async () => { const raw = process.env['SOLANA_PRIVATE_KEY']; if (!raw) throw new Error('SOLANA_PRIVATE_KEY is required'); @@ -160,5 +156,5 @@ d('agw identity', () => { expect(tx.from).toBe(wallet); expect(tx.origin.startsWith('solana:')).toBe(true); f.evidence('solana-origin-agent', { wallet, uea, tx: tx.hash }); - }, 600_000); + }, 600000); }); diff --git a/packages/core/__e2e__/agw/labels.spec.ts b/packages/core/__e2e__/agw/labels.spec.ts index 8940c1ea0..a5ca52d43 100644 --- a/packages/core/__e2e__/agw/labels.spec.ts +++ b/packages/core/__e2e__/agw/labels.spec.ts @@ -24,13 +24,12 @@ import { setupAgw, type AgwFixture, } from './_fixture'; - const d = AGW_E2E_ENABLED ? describe : describe.skip; d('agw labels', () => { let f: AgwFixture; beforeAll(async () => { f = await setupAgw(); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); const count = (address: Address) => f.push.readContract({ @@ -38,7 +37,6 @@ d('agw labels', () => { abi: v5.abis.wallet, functionName: 'checkpointCount', }); - it('1. default and custom deployment labels are stored and listed', async () => { const empty = await f.owner.agentic.create('', { rules: [] }); const custom = await f.owner.agentic.create('e2e-v5-custom', { rules: [] }); @@ -65,14 +63,13 @@ d('agw labels', () => { `AGW ${empty.index + 1}` ); expect(await count(empty.wallet)).toBe(BigInt(0)); - expect(await count(custom.wallet)).toBe(BigInt(1)); + expect(await count(custom.wallet)).toBe(BigInt(0)); f.evidence('v5-label-default-custom', { wallets: [empty.wallet, custom.wallet], indices: [empty.index, custom.index], hashes: [empty.tx.hash, custom.tx.hash, renamed.hash], }); }); - it('2. owner rename and empty reset update info/list, emit LabelSet and tick the owner door', async () => { const made = await f.owner.agentic.create('before', { rules: [] }); const w = f.owner.agentic.wallet(made.wallet), @@ -100,13 +97,13 @@ d('agw labels', () => { (x) => x.address === made.wallet )?.label ).toBe('after'); - expect(await count(made.wallet)).toBe(before + BigInt(1)); + expect(await count(made.wallet)).toBe(before); expect(hook).toHaveBeenCalledWith( expect.objectContaining({ id: 'AGENTIC-TX-199-01' }) ); const reset = await w.setLabel(''); expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); - expect(await count(made.wallet)).toBe(before + BigInt(2)); + expect(await count(made.wallet)).toBe(before); f.evidence('v5-label-rename-reset', { wallet: made.wallet, hashes: [tx.hash, reset.hash], @@ -114,7 +111,6 @@ d('agw labels', () => { checkpointsAfter: await count(made.wallet), }); }); - it('3. UTF-8 byte boundaries are accepted or rejected without changing wallet state', async () => { const made = await f.owner.agentic.create('🙂'.repeat(16), { rules: [] }); const w = f.owner.agentic.wallet(made.wallet); @@ -147,15 +143,18 @@ d('agw labels', () => { unchangedNextSlot: next.index, }); }); - it('4. an agent cannot rename through either the SDK or the contract', async () => { const made = await f.owner.agentic.create('owner-only', { rules: [ { agent: f.agentAddress, - target: f.ownerAddress, - selector: 'value-only', validUntil: inSeconds(3600), + allowedCalls: [ + { + target: f.ownerAddress, + selector: 'value-only', + }, + ], }, ], }); @@ -207,7 +206,6 @@ d('agw labels', () => { checkpoints: before, }); }); - it('5. an external EVM owner creates and renames through its UEA', async () => { const key = process.env['EVM_PRIVATE_KEY'] as Hex; if (!key) throw new Error('EVM_PRIVATE_KEY is required'); @@ -241,7 +239,7 @@ d('agw labels', () => { ).toBe('e2e-uea-after'); const reset = await w.setLabel(''); expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); - expect(await count(made.wallet)).toBe(before + BigInt(2)); + expect(await count(made.wallet)).toBe(before); f.evidence('v5-label-uea-owner', { wallet: made.wallet, owner: uea, @@ -291,7 +289,7 @@ d('agw labels', () => { ).toBe('Solana owner'); const reset = await w.setLabel(''); expect((await w.info()).label).toBe(`AGW ${made.index + 1}`); - expect(await count(made.wallet)).toBe(before + BigInt(2)); + expect(await count(made.wallet)).toBe(before); f.evidence('v5-label-solana-owner', { wallet: made.wallet, owner: uea, @@ -299,19 +297,20 @@ d('agw labels', () => { checkpointsBefore: before, checkpointsAfter: await count(made.wallet), }); - }, 600_000); - - it('7. renaming an active wallet preserves rules, spend and remaining agent budget', async () => { + }, 600000); + it('7. renaming an active wallet preserves rules, spend and per-call agent cap', async () => { const made = await f.owner.agentic.create('active-before', { rules: [ { agent: f.agentAddress, - target: f.ownerAddress, - selector: 'value-only', validUntil: inSeconds(3600), - maxValuePerCall: BigInt(1), - maxValueTotal: BigInt(2), - maxCalls: 3, + allowedCalls: [ + { + target: f.ownerAddress, + selector: 'value-only', + maxValue: BigInt(1), + }, + ], }, ], }); @@ -354,7 +353,7 @@ d('agw labels', () => { functionName: 'grantNonce', }) ).toBe(nonceBefore); - expect(await count(made.wallet)).toBe(checkpointsBefore + BigInt(1)); + expect(await count(made.wallet)).toBe(checkpointsBefore); const second = await agent.universal.sendTransaction({ to: f.ownerAddress, value: BigInt(1), @@ -372,9 +371,9 @@ d('agw labels', () => { address: f.agentAddress, }); await expect( - agent.universal.sendTransaction({ to: f.ownerAddress, value: BigInt(1) }) + agent.universal.sendTransaction({ to: f.ownerAddress, value: BigInt(2) }) ).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(TotalValueExceeded)' }, + decodedError: { name: 'PolicyCheckReverted(ValueExceedsCap)' }, }); expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( agentNonce @@ -387,7 +386,7 @@ d('agw labels', () => { made.rulesIds[0] ) ).toEqual(spendAfter); - expect(await count(made.wallet)).toBe(checkpointsBefore + BigInt(1)); + expect(await count(made.wallet)).toBe(checkpointsBefore); f.evidence('v5-label-active-rule', { wallet: made.wallet, rulesId: made.rulesIds[0], @@ -397,7 +396,6 @@ d('agw labels', () => { grantNonce: nonceBefore, }); }); - it('8. a failed owner batch rolls back the earlier rename and all checkpoints', async () => { const made = await f.owner.agentic.create('batch-before', { rules: [] }); const w = f.owner.agentic.wallet(made.wallet), @@ -435,7 +433,7 @@ d('agw labels', () => { const hash = await client.sendTransaction({ chain: client.chain, to: made.wallet, - gas: BigInt(400_000), + gas: BigInt(400000), data: v5.encodeExecute( calls.map((call) => ({ target: call.to, diff --git a/packages/core/__e2e__/agw/lifecycle.spec.ts b/packages/core/__e2e__/agw/lifecycle.spec.ts index 105a5ea24..77b3cc1b7 100644 --- a/packages/core/__e2e__/agw/lifecycle.spec.ts +++ b/packages/core/__e2e__/agw/lifecycle.spec.ts @@ -14,9 +14,7 @@ import { setupAgw, type AgwFixture, } from './_fixture'; - const d = AGW_E2E_ENABLED ? describe : describe.skip; - d('agw lifecycle', () => { let f: AgwFixture; let wallet: Address; @@ -24,11 +22,14 @@ d('agw lifecycle', () => { const sink = getAddress('0x000000000000000000000000000000000000dEaD'); const rule = (maxValuePerCall: bigint) => ({ agent: f.agentAddress, - target: sink, - selector: 'value-only' as const, validUntil: inSeconds(3600), - maxValuePerCall, - maxValueTotal: maxValuePerCall * BigInt(10), + allowedCalls: [ + { + target: sink, + selector: 'value-only' as const, + maxValue: maxValuePerCall, + }, + ], }); const count = () => f.push.readContract({ @@ -36,15 +37,13 @@ d('agw lifecycle', () => { abi: v5.abis.wallet, functionName: 'checkpointCount', }) as Promise; - beforeAll(async () => { f = await setupAgw(); wallet = (await f.owner.agentic.create('e2e-lifecycle', { rules: [] })) .wallet; await f.fundPC(wallet, parseEther('0.01')); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); - it('1. rules.add grants with one RULES_GRANTED checkpoint', async () => { const before = await count(); const added = await f.owner.agentic @@ -60,7 +59,6 @@ d('agw lifecycle', () => { ref: original, }); }); - it('2. rules.update: old ID disabled, new ID usable with reset counters, five checkpoint ticks', async () => { const agent = await f.agent(wallet); await ( @@ -107,7 +105,6 @@ d('agw lifecycle', () => { checkpointDelta: 5, }); }); - it('3. checkpoint history is contiguous and matches checkpointCount()', async () => { const { checkpoints } = await f.owner.agentic.wallet(wallet).checkpoints(); expect(checkpoints.map((c) => c.seq)).toEqual( @@ -115,10 +112,9 @@ d('agw lifecycle', () => { ); expect(BigInt(checkpoints.length)).toBe(await count()); }); - it('4. revoke({ all: true }) leaves no enabled rules', async () => { await ( - await f.owner.agentic.wallet(wallet).rules.revoke({ all: true }) + await f.owner.agentic.wallet(wallet).rules.delete({ all: true }) ).wait(); expect((await f.owner.agentic.wallet(wallet).rules.list()).rules).toEqual( [] diff --git a/packages/core/__e2e__/agw/management-extended.spec.ts b/packages/core/__e2e__/agw/management-extended.spec.ts index 6c5bb6b38..0a0b12106 100644 --- a/packages/core/__e2e__/agw/management-extended.spec.ts +++ b/packages/core/__e2e__/agw/management-extended.spec.ts @@ -4,25 +4,29 @@ import type { AgenticRuntime } from '../../src/lib/agentic/runtime'; import { v5 } from '../../src/lib/agentic/contracts/v5'; import { setupAgw, inSeconds, type AgwFixture } from './_fixture'; import { readNativeCounters } from '../shared/agw-state'; - const d = process.env['AGW_E2E'] === '1' ? describe : describe.skip; const runtime = (client: PushChain) => - (client as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; - + ( + client as unknown as { + agenticRuntime: AgenticRuntime; + } + ).agenticRuntime; d('agw extended management', () => { let f: AgwFixture; beforeAll(async () => { f = await setupAgw(); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); const rule = (agent = f.agentAddress) => ({ agent, - target: f.ownerAddress, - selector: 'value-only' as const, validUntil: inSeconds(3600), - maxValuePerCall: BigInt(10), - maxValueTotal: BigInt(100), - maxCalls: 10, + allowedCalls: [ + { + target: f.ownerAddress, + selector: 'value-only' as const, + maxValue: BigInt(10), + }, + ], }); const count = (wallet: Address) => f.push.readContract({ @@ -68,36 +72,37 @@ d('agw extended management', () => { txHash: made.tx.hash, }); }); - it('2. same-agent multiplicity is ambiguous before signing and selective revoke restores uncached lookup', async () => { - const made = await f.owner.agentic.create('e2e-ambiguous', { - rules: [rule(), rule()], - }); - await f.fundPC(made.wallet, BigInt(10)); - const agent = await f.agent(made.wallet), - nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + it('2. create and add reject duplicate agent/chain rules before signing', async () => { + const nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); await expect( - agent.universal.sendTransaction({ to: f.ownerAddress, value: BigInt(1) }) - ).rejects.toMatchObject({ - code: 'AMBIGUOUS_RULE', - details: { rulesIds: expect.arrayContaining(made.rulesIds) }, - }); - expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + f.owner.agentic.create('duplicate', { rules: [rule(), rule()] }) + ).rejects.toMatchObject({ code: 'DUPLICATE_RULE' }); + expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( nonce ); - await ( - await f.owner.agentic.wallet(made.wallet).rules.revoke([made.rulesIds[0]]) - ).wait(); - const tx = await agent.universal.sendTransaction({ - to: f.ownerAddress, - value: BigInt(1), + const made = await f.owner.agentic.create('single', { rules: [rule()] }); + const w = f.owner.agentic.wallet(made.wallet), + before = await count(made.wallet); + const afterCreate = await f.push.getTransactionCount({ + address: f.ownerAddress, + }); + await expect(w.rules.add([rule()])).rejects.toMatchObject({ + code: 'DUPLICATE_RULE', }); - expect((await tx.wait()).status).toBe(1); - expect(tx.agentic?.rulesId).toBe(made.rulesIds[1]); - f.evidence('extended-uncached-lookup', { + expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( + afterCreate + ); + expect(await count(made.wallet)).toBe(before); + expect((await w.rules.list()).rules.map((r) => r.rulesId)).toEqual( + made.rulesIds + ); + await (await w.rules.delete(made.rulesIds)).wait(); + const added = await w.rules.add([rule()]); + expect(added.rulesIds[0]).not.toBe(made.rulesIds[0]); + f.evidence('duplicate-rule-guards', { wallet: made.wallet, - removed: made.rulesIds[0], - selected: made.rulesIds[1], - txHash: tx.hash, + deleted: made.rulesIds, + added: added.rulesIds, }); }); it('3. multi-rule add and revoke are atomic batches with exact checkpoint counts', async () => { @@ -114,7 +119,7 @@ d('agw extended management', () => { expect((await w.rules.list()).rules.map((r) => r.rulesId)).toEqual( expect.arrayContaining(added.rulesIds) ); - await (await w.rules.revoke(added.rulesIds)).wait(); + await (await w.rules.delete(added.rulesIds)).wait(); expect(await count(made.wallet)).toBe(BigInt(8)); expect((await w.rules.list()).rules).toEqual([]); const cp = await w.checkpoints({ sinceBlock: added.tx.blockNumber }); @@ -132,14 +137,14 @@ d('agw extended management', () => { }), w = f.owner.agentic.wallet(made.wallet), nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); - await expect(w.rules.revoke([])).rejects.toMatchObject({ - code: 'REVOKE_NEEDS_TARGET', + await expect(w.rules.delete([])).rejects.toMatchObject({ + code: 'DELETE_NEEDS_TARGET', }); await expect( - w.rules.revoke([made.rulesIds[0], made.rulesIds[0]]) + w.rules.delete([made.rulesIds[0], made.rulesIds[0]]) ).rejects.toMatchObject({ code: 'INVALID_RULE' }); await expect( - w.rules.revoke([`0x${'12'.repeat(32)}`]) + w.rules.delete([`0x${'12'.repeat(32)}`]) ).rejects.toMatchObject({ code: 'RULE_NOT_FOUND' }); expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( nonce @@ -249,7 +254,13 @@ d('agw extended management', () => { ? (await f.owner.agentic.wallet(competing.wallet).rules.list()).rules : undefined; f.evidence('extended-index-race-error', { - code: (raceError as { code?: string } | undefined)?.code, + code: ( + raceError as + | { + code?: string; + } + | undefined + )?.code, expectedWallet: expectedNext.address, expectedIndex: expectedNext.index, competingWallet: competing?.wallet, diff --git a/packages/core/__e2e__/agw/native-batch.spec.ts b/packages/core/__e2e__/agw/native-batch.spec.ts index 5a8e06acd..04c9515c8 100644 --- a/packages/core/__e2e__/agw/native-batch.spec.ts +++ b/packages/core/__e2e__/agw/native-batch.spec.ts @@ -9,7 +9,6 @@ import { setupAgw, type AgwFixture, } from './_fixture'; - const d = AGW_E2E_ENABLED ? describe : describe.skip; const SINK = getAddress('0x000000000000000000000000000000000000dEaD'); const calls = () => @@ -18,32 +17,31 @@ const calls = () => value: BigInt(1000), data: '0x' as Hex, })); - d('agw native batch', () => { let f: AgwFixture; beforeAll(async () => { f = await setupAgw(); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); - - const create = async (maxCalls: number) => { + const create = async (_maxCalls: number) => { const created = await f.owner.agentic.create('e2e-native-batch', { rules: [ { agent: f.agentAddress, - target: SINK, - selector: 'value-only', validUntil: inSeconds(3600), - maxValuePerCall: BigInt(1000), - maxValueTotal: BigInt(5000), - maxCalls, + allowedCalls: [ + { + target: SINK, + selector: 'value-only', + maxValue: BigInt(1000), + }, + ], }, ], }); await f.fundPC(created.wallet, parseEther('0.001')); return { ...created, agent: await f.agent(created.wallet) }; }; - it('1. sender-preserving atomic native batch meters both actions', async () => { const { wallet, rulesIds, agent } = await create(3); const before = await f.push.getBalance({ address: wallet }); @@ -78,12 +76,14 @@ d('agw native batch', () => { tx: tx.hash, }); }); - it('2. a later policy failure rolls back earlier native actions', async () => { const { wallet, rulesIds, agent } = await create(1); const before = await f.push.getBalance({ address: wallet }); await expect( - agent.universal.sendTransaction({ to: SINK, data: calls() }) + agent.universal.sendTransaction({ + to: SINK, + data: [calls()[0], { ...calls()[1], value: BigInt(1001) }], + }) ).rejects.toBeInstanceOf(AgenticRevertError); expect(await f.push.getBalance({ address: wallet })).toBe(before); expect( diff --git a/packages/core/__e2e__/agw/native-exec.spec.ts b/packages/core/__e2e__/agw/native-exec.spec.ts index 7217c1459..ee3ca4b2a 100644 --- a/packages/core/__e2e__/agw/native-exec.spec.ts +++ b/packages/core/__e2e__/agw/native-exec.spec.ts @@ -8,42 +8,52 @@ import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'; import { AgenticError, AgenticRevertError } from '../../src'; import { CHAIN } from '../../src/lib/constants/enums'; import { AGENTIC_ERROR_CODE } from '../../src/lib/agentic/errors'; -import { AGW_E2E_ENABLED, evmClient, inSeconds, setupAgw, type AgwFixture } from './_fixture'; - +import { + AGW_E2E_ENABLED, + evmClient, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; const d = AGW_E2E_ENABLED ? describe : describe.skip; const CAP = parseEther('0.001'); - d('agw native', () => { let f: AgwFixture; let wallet: Address; let rulesId: Hex; - const recipient = getAddress(privateKeyToAccount(generatePrivateKey()).address); - + const recipient = getAddress( + privateKeyToAccount(generatePrivateKey()).address + ); beforeAll(async () => { f = await setupAgw(); const created = await f.owner.agentic.create('e2e-native', { rules: [ { agent: f.agentAddress, - target: recipient, - selector: 'value-only', validUntil: inSeconds(3600), - maxValuePerCall: CAP, - maxValueTotal: CAP * BigInt(3), + allowedCalls: [ + { + target: recipient, + selector: 'value-only', + maxValue: CAP, + }, + ], }, ], }); wallet = created.wallet; rulesId = created.rulesIds[0]; await f.fundPC(wallet, parseEther('0.01')); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); - it('1. agent door: value moves from the wallet under its rule; signer is origin, wallet is from', async () => { const agent = await f.agent(wallet); expect(agent.universal.account).toBe(wallet); const before = await f.push.getBalance({ address: recipient }); - const tx = await agent.universal.sendTransaction({ to: recipient, value: CAP }); + const tx = await agent.universal.sendTransaction({ + to: recipient, + value: CAP, + }); const receipt = await tx.wait(); expect(receipt.status).toBe(1); expect(await f.push.getBalance({ address: recipient })).toBe(before + CAP); @@ -51,51 +61,81 @@ d('agw native', () => { expect(receipt.from).toBe(wallet); expect(tx.origin.toLowerCase()).toContain(f.agentAddress.toLowerCase()); expect(tx.agentic).toMatchObject({ door: 'agent', rulesId }); - f.evidence('native-agent-send', { wallet, rulesId, tx: tx.hash, block: tx.blockNumber }); + f.evidence('native-agent-send', { + wallet, + rulesId, + tx: tx.hash, + block: tx.blockNumber, + }); }); - it('2. owner door: value moves from the wallet without rules', async () => { - const owner = await evmClient(process.env['PUSH_PRIVATE_KEY'] as Hex, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); const before = await f.push.getBalance({ address: recipient }); - const tx = await owner.universal.sendTransaction({ to: recipient, value: CAP * BigInt(2) }); + const tx = await owner.universal.sendTransaction({ + to: recipient, + value: CAP * BigInt(2), + }); expect((await tx.wait()).status).toBe(1); - expect(await f.push.getBalance({ address: recipient })).toBe(before + CAP * BigInt(2)); + expect(await f.push.getBalance({ address: recipient })).toBe( + before + CAP * BigInt(2) + ); expect(tx.agentic?.door).toBe('owner'); }); - it('3. over-cap value reverts with the decoded policy gate and moves nothing', async () => { const agent = await f.agent(wallet); const before = await f.push.getBalance({ address: recipient }); - const err = await agent.universal.sendTransaction({ to: recipient, value: CAP + BigInt(1) }).catch((e) => e); + const err = await agent.universal + .sendTransaction({ to: recipient, value: CAP + BigInt(1) }) + .catch((e) => e); expect(err).toBeInstanceOf(AgenticRevertError); expect(err.decodedError?.name).toMatch(/^PolicyCheckReverted/); expect(await f.push.getBalance({ address: recipient })).toBe(before); }); - it('4. an unrelated signer cannot initialize against the wallet', async () => { const stranger = generatePrivateKey(); - await expect(evmClient(stranger, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet)).rejects.toMatchObject({ + await expect( + evmClient(stranger, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.NOT_OWNER_OR_AGENT, }); }); - it('5. after revocation the agent fails before signing with NO_RULES_FOR_CHAIN', async () => { const agent = await f.agent(wallet); - await (await f.owner.agentic.wallet(wallet).rules.revoke([rulesId])).wait(); - const err = await agent.universal.sendTransaction({ to: recipient, value: BigInt(1) }).catch((e) => e); + await (await f.owner.agentic.wallet(wallet).rules.delete([rulesId])).wait(); + const err = await agent.universal + .sendTransaction({ to: recipient, value: BigInt(1) }) + .catch((e) => e); expect(err).toBeInstanceOf(AgenticError); expect(err.code).toBe(AGENTIC_ERROR_CODE.NO_RULES_FOR_CHAIN); }); - it('6. an expired rule still identifies the agent; the send is refused on-chain', async () => { const created = await f.owner.agentic.create('e2e-expiry', { - rules: [{ agent: f.agentAddress, target: recipient, selector: 'value-only', validUntil: inSeconds(45), maxValuePerCall: CAP, maxValueTotal: CAP }], + rules: [ + { + agent: f.agentAddress, + validUntil: inSeconds(45), + allowedCalls: [ + { + target: recipient, + selector: 'value-only', + maxValue: CAP, + }, + ], + }, + ], }); await f.fundPC(created.wallet, CAP * BigInt(2)); - await new Promise((r) => setTimeout(r, 60_000)); + await new Promise((r) => setTimeout(r, 60000)); const agent = await f.agent(created.wallet); - const err = await agent.universal.sendTransaction({ to: recipient, value: BigInt(1) }).catch((e) => e); + const err = await agent.universal + .sendTransaction({ to: recipient, value: BigInt(1) }) + .catch((e) => e); expect(err).toBeInstanceOf(AgenticRevertError); expect(err.decodedError?.name).toBe('PolicyCheckReverted(RulesExpired)'); - }, 300_000); + }, 300000); }); diff --git a/packages/core/__e2e__/agw/native-multi-action.spec.ts b/packages/core/__e2e__/agw/native-multi-action.spec.ts new file mode 100644 index 000000000..d5624ebe5 --- /dev/null +++ b/packages/core/__e2e__/agw/native-multi-action.spec.ts @@ -0,0 +1,663 @@ +/** Public multi-action native rules on checked Donut v5; no contract deployments. */ +import { + createWalletClient, + encodeFunctionData, + erc20Abi, + getAddress, + http, + parseEther, + type Address, + type Hex, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { AgenticRevertError, type Rule } from '../../src'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { + CHAIN_INFO, + getPushViemChain, + PUSH_BATCH_EXECUTOR_ADDRESS, +} from '../../src/lib/constants/chain'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import type { AgenticRuntime } from '../../src/lib/agentic/runtime'; +import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { actionId } from '../../src/lib/agentic/codec/ids'; +import { readNativeActionConfigs } from '@e2e/shared/agw-state'; +import { + AGW_E2E_ENABLED, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const ZERO = BigInt(0), + ONE = BigInt(1); +d('agw native multi-action', () => { + let f: AgwFixture, token: Address; + beforeAll(async () => { + if ( + parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5') > parseEther('0.5') + ) + throw new Error('Fixture agent budget is at most 0.5 PC'); + f = await setupAgw(); + token = getNativePRC20ForChain(CHAIN.ETHEREUM_SEPOLIA, f.manifest.network); + const balance = await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + }); + if (balance < BigInt(400)) + throw new Error( + 'Need 400 raw pETH units for bounded native multi-action fixtures' + ); + }, 300000); + afterAll(() => f?.teardown()); + const transfer = (amount: bigint, recipient = f.ownerAddress) => + encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient, amount], + }); + const rule = (): Rule => ({ + agent: f.agentAddress, + validUntil: inSeconds(3600), + allowedCalls: [ + { + target: f.ownerAddress, + selector: 'value-only', + maxValue: ONE, + }, + { + target: token, + selector: 'transfer(address,uint256)', + pins: [ + { arg: 0, expected: f.ownerAddress }, + { arg: 1, max: BigInt(3) }, + ], + }, + ], + }); + async function fixture(input = rule()) { + const made = await f.owner.agentic.create('e2e-native-multi', { + rules: [input], + }); + expect(made.rulesIds).toHaveLength(1); + await f.fundPC(made.wallet, BigInt(10)); + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: transfer(BigInt(20), made.wallet), + }) + ).wait(); + const agent = await f.agent(made.wallet); + return { + ...made, + id: made.rulesIds[0] as Hex, + handle: f.owner.agentic.wallet(made.wallet), + agent, + }; + } + type Fixture = Awaited>; + const sendPC = (s: Fixture, value = ONE) => + s.agent.universal.sendTransaction({ to: f.ownerAddress, value }); + const sendToken = (s: Fixture, amount = ONE, recipient = f.ownerAddress) => + s.agent.universal.sendTransaction({ + to: token, + data: transfer(amount, recipient), + }); + const calls = (amount = BigInt(2)) => [ + { to: f.ownerAddress, value: ONE, data: '0x' as Hex }, + { to: token, value: ZERO, data: transfer(amount) }, + ]; + async function state(s: Fixture) { + const blockNumber = await f.push.getBlockNumber({ cacheTime: 0 }); + const [ + configs, + walletPC, + recipientPC, + walletToken, + recipientToken, + checkpointCount, + grantNonce, + ] = await Promise.all([ + readNativeActionConfigs( + f.push, + f.manifest.addresses, + s.wallet, + s.id, + blockNumber + ), + f.push.getBalance({ address: s.wallet, blockNumber }), + f.push.getBalance({ address: f.ownerAddress, blockNumber }), + f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [s.wallet], + blockNumber, + }), + f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + blockNumber, + }), + f.push.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + blockNumber, + }), + f.push.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'grantNonce', + blockNumber, + }), + ]); + return { + configs, + walletPC, + recipientPC, + walletToken, + recipientToken, + checkpointCount, + grantNonce, + }; + } + function config( + snapshot: Awaited>, + target: Address, + selector: Hex + ) { + const entry = snapshot.configs.find( + (entry) => entry.actionId === actionId(target, selector) + ); + if (!entry) throw new Error('Missing independent per-action config'); + return entry.config; + } + async function refused( + s: Fixture, + send: () => Promise, + gate: string + ) { + const before = await state(s), + nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + const error = await send().then( + () => undefined, + (e: unknown) => e + ); + expect(error).toBeInstanceOf(AgenticRevertError); + expect((error as AgenticRevertError).decodedError?.name).toBe(gate); + expect(await state(s)).toEqual(before); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + nonce + ); + f.evidence('native-multi-refused', { + wallet: s.wallet, + id: s.id, + gate, + state: before, + agentNonce: nonce, + }); + } + it('1. one rule grants and reads both allowedCalls; decoded terms can be granted losslessly', async () => { + const s = await fixture(); + const { rules } = await s.handle.rules.list(); + expect(rules.map((r) => r.rulesId)).toEqual([s.id]); + const decoded = (await s.handle.rules.get(s.id)).rule as Rule; + expect(decoded.allowedCalls.map((a) => [a.target, a.selector])).toEqual([ + [f.ownerAddress, 'value-only'], + [token, '0xa9059cbb'], + ]); + expect(decoded.allowedCalls[1]).toMatchObject({ + pins: [{ arg: 0 }, { arg: 1, max: BigInt(3) }], + }); + const copy = await f.owner.agentic.create('e2e-native-multi-copy', { + rules: [decoded], + }); + expect( + (await f.owner.agentic.wallet(copy.wallet).rules.get(copy.rulesIds[0])) + .rule + ).toEqual(decoded); + const before = await state(s); + const pc = await sendPC(s); + await pc.wait(); + const afterPC = await state(s); + expect(afterPC.walletPC).toBe(before.walletPC - ONE); + expect(afterPC.recipientPC).toBe(before.recipientPC + ONE); + expect(config(afterPC, token, '0xa9059cbb')).toEqual( + config(before, token, '0xa9059cbb') + ); + const moved = await sendToken(s, BigInt(2)); + await moved.wait(); + const after = await state(s); + expect(after.walletToken).toBe(before.walletToken - BigInt(2)); + expect(after.recipientToken).toBe(before.recipientToken + BigInt(2)); + expect(config(after, f.ownerAddress, '0xffffffff')).toMatchObject({ + valueSpent: ONE, + callsUsed: 1, + }); + expect(config(after, token, '0xa9059cbb')).toMatchObject({ + amountSpent: BigInt(2), + callsUsed: 1, + }); + expect(after.checkpointCount).toBe(before.checkpointCount); + expect(pc.agentic?.rulesId).toBe(s.id); + expect(moved.agentic?.rulesId).toBe(s.id); + const replay = await f.owner.universal.trackTransaction(moved.hash); + expect(replay.from).toBe(s.wallet); + expect(replay.origin).toBe(moved.origin); + expect(replay.agentic?.rulesId).toBe(s.id); + f.evidence('native-multi-grant-read-send', { + wallet: s.wallet, + id: s.id, + copy: copy.wallet, + hashes: [s.tx.hash, copy.tx.hash, pc.hash, moved.hash], + state: after, + }); + }); + it('2. wrong pin, excess amount and an ungranted selector fail without changing either action', async () => { + const s = await fixture(); + await refused( + s, + () => sendToken(s, ONE, f.agentAddress), + 'PolicyCheckReverted(ArgPinMismatch)' + ); + await refused( + s, + () => sendToken(s, BigInt(4)), + 'PolicyCheckReverted(NativeAmountExceedsCap)' + ); + await refused( + s, + () => + s.agent.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [f.agentAddress, ONE], + }), + }), + 'NoPoliciesSet' + ); + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [s.wallet, f.agentAddress], + }) + ).toBe(ZERO); + }); + it('3. per-call limits allow repeated calls with independent spend entries', async () => { + const s = await fixture(); + await (await sendPC(s)).wait(); + await (await sendPC(s)).wait(); + await (await sendPC(s, ZERO)).wait(); + await (await sendToken(s, BigInt(3))).wait(); + await (await sendToken(s, BigInt(2))).wait(); + await (await sendToken(s)).wait(); + const final = await state(s); + expect(config(final, f.ownerAddress, '0xffffffff')).toMatchObject({ + valueSpent: BigInt(2), + callsUsed: 3, + }); + expect(config(final, token, '0xa9059cbb')).toMatchObject({ + amountSpent: BigInt(6), + callsUsed: 3, + }); + f.evidence('native-multi-independent-counters', { + wallet: s.wallet, + id: s.id, + state: final, + }); + }); + it('4. a mixed-function atomic agent batch preserves sender, per-action spend and replay', async () => { + const s = await fixture(), + before = await state(s); + const tx = await s.agent.universal.sendTransaction({ + to: f.ownerAddress, + data: calls(), + }); + expect((await tx.wait()).status).toBe(1); + const raw = await f.push.getTransaction({ hash: tx.hash as Hex }); + expect(raw.type).toBe('eip7702'); + expect(getAddress(raw.from)).toBe(f.agentAddress); + expect(getAddress(raw.to as Address)).toBe(f.agentAddress); + const after = await state(s); + expect(after.walletPC).toBe(before.walletPC - ONE); + expect(after.walletToken).toBe(before.walletToken - BigInt(2)); + expect(config(after, f.ownerAddress, '0xffffffff')).toMatchObject({ + callsUsed: 1, + valueSpent: ONE, + }); + expect(config(after, token, '0xa9059cbb')).toMatchObject({ + callsUsed: 1, + amountSpent: BigInt(2), + }); + expect(after.checkpointCount).toBe(before.checkpointCount); + const replay = await f.owner.universal.trackTransaction(tx.hash); + expect(replay.from).toBe(s.wallet); + expect(replay.agentic?.nativeCalls).toEqual(calls()); + expect(replay.agentic?.rulesId).toBe(s.id); + f.evidence('native-multi-mixed-batch', { + wallet: s.wallet, + id: s.id, + hash: tx.hash, + state: after, + }); + }); + it('5. a failing second batch action rolls back PC, tokens and all action counters in a mined transaction', async () => { + const s = await fixture(); + // Establish delegation through the real public SDK transport, then reuse + // its configured executor for a bounded deliberately reverted transaction. + await ( + await s.agent.universal.sendTransaction({ + to: f.ownerAddress, + data: calls(ONE), + }) + ).wait(); + await refused( + s, + () => + s.agent.universal.sendTransaction({ + to: f.ownerAddress, + data: calls(BigInt(4)), + }), + 'PolicyCheckReverted(NativeAmountExceedsCap)' + ); + const executor = PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_TESTNET_DONUT]; + if (!executor) throw new Error('Missing verified native batch executor'); + expect( + (await f.push.getCode({ address: f.agentAddress }))?.toLowerCase() + ).toBe(`0xef0100${executor.slice(2)}`.toLowerCase()); + const before = await state(s), + nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + const raw = createWalletClient({ + account: privateKeyToAccount(f.agentKey), + chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), + transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]), + }); + const data = v5.encodeExecute( + calls(BigInt(4)).map((c) => ({ + target: s.wallet, + value: ZERO, + data: v5.encodeExecuteAsAgent(s.id, { + target: c.to, + value: c.value, + data: c.data, + }), + })) + ); + const hash = await raw.sendTransaction({ + chain: raw.chain, + to: f.agentAddress, + data, + gas: BigInt(600000), + }); + const receipt = await f.push.waitForTransactionReceipt({ hash }); + expect(receipt.status).toBe('reverted'); + expect(receipt.logs).toHaveLength(0); + expect(await state(s)).toEqual(before); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + nonce + 1 + ); + f.evidence('native-multi-mined-rollback', { + wallet: s.wallet, + id: s.id, + hash, + block: receipt.blockNumber, + gasUsed: receipt.gasUsed, + state: before, + }); + }); + it('6. replacement guards every old action and records six checkpoints for a two-action rule', async () => { + const s = await fixture(); + await (await sendPC(s)).wait(); + await (await sendToken(s, BigInt(2))).wait(); + const before = await state(s), + input = rule(); + const replacement = await s.handle.rules.update({ + rules: [{ rulesId: s.id, rule: input }], + }); + const next = replacement.rules[0].rulesId as Hex; + expect(next).not.toBe(s.id); + await expect(s.handle.rules.get(s.id)).rejects.toMatchObject({ + code: 'RULE_NOT_FOUND', + }); + expect((await s.handle.rules.list()).rules.map((r) => r.rulesId)).toEqual([ + next, + ]); + expect( + await f.push.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(before.checkpointCount + BigInt(6)); + for (const { config: cfg } of await readNativeActionConfigs( + f.push, + f.manifest.addresses, + s.wallet, + next + )) { + expect(cfg).toMatchObject({ + valueSpent: ZERO, + amountSpent: ZERO, + callsUsed: 0, + }); + } + const reused = await sendToken(s); + await reused.wait(); + expect(reused.agentic?.rulesId).toBe(next); + const trimmed = await s.handle.rules.update({ + rules: [ + { + rulesId: next, + rule: { ...rule(), allowedCalls: [rule().allowedCalls[0]] }, + }, + ], + }); + expect( + (await s.handle.rules.get(trimmed.rules[0].rulesId)).rule + ).toMatchObject({ + allowedCalls: [{ target: f.ownerAddress, selector: 'value-only' }], + }); + const nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + await expect(sendToken(s)).rejects.toMatchObject({ + decodedError: { name: 'NoPoliciesSet' }, + }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + nonce + ); + f.evidence('native-multi-replacement', { + wallet: s.wallet, + oldId: s.id, + newId: next, + trimmedId: trimmed.rules[0].rulesId, + hashes: [replacement.tx.hash, reused.hash, trimmed.tx.hash], + }); + }); + it('7. spending the second action mid-update rejects the stale batch and a fresh retry succeeds', async () => { + const s = await fixture(), + before = await state(s); + const runtime = ( + f.owner as unknown as { + agenticRuntime: AgenticRuntime; + } + ).agenticRuntime; + const original = runtime.execute; + let injected = false, + spendHash: string | undefined; + runtime.execute = async (params, options) => { + if (!injected && params.to === s.wallet) { + injected = true; + const tx = await sendToken(s); + await tx.wait(); + spendHash = tx.hash; + } + return original(params, options); + }; + try { + await expect( + s.handle.rules.update({ rules: [{ rulesId: s.id, rule: rule() }] }) + ).rejects.toMatchObject({ + decodedError: { name: 'SpentMismatch' }, + }); + expect(injected).toBe(true); + } finally { + runtime.execute = original; + } + const after = await state(s); + expect(after.checkpointCount).toBe(before.checkpointCount); + expect(after.grantNonce).toBe(before.grantNonce); + expect(config(after, f.ownerAddress, '0xffffffff')).toEqual( + config(before, f.ownerAddress, '0xffffffff') + ); + expect(config(after, token, '0xa9059cbb')).toMatchObject({ + amountSpent: ONE, + callsUsed: 1, + }); + expect(after.walletToken).toBe(before.walletToken - ONE); + expect((await s.handle.rules.list()).rules.map((r) => r.rulesId)).toEqual([ + s.id, + ]); + const retry = await s.handle.rules.update({ + rules: [{ rulesId: s.id, rule: rule() }], + }); + expect( + await f.push.readContract({ + address: s.wallet, + abi: v5.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(before.checkpointCount + BigInt(6)); + f.evidence('native-multi-second-action-race', { + wallet: s.wallet, + oldId: s.id, + newId: retry.rules[0].rulesId, + spendHash, + retryHash: retry.tx.hash, + stateAfterRace: after, + }); + }); + it('8. eight unique native allowedCalls are accepted; duplicate, mixed and ninth-action inputs never sign', async () => { + const allowedCalls = Array.from({ length: 8 }, (_, i) => ({ + target: getAddress(`0x${(0x1000 + i).toString(16).padStart(40, '0')}`), + selector: 'value-only' as const, + })); + const input = { + agent: f.agentAddress, + validUntil: inSeconds(3600), + allowedCalls, + }; + const made = await f.owner.agentic.create('e2e-native-eight', { + rules: [input], + }); + expect( + ( + (await f.owner.agentic.wallet(made.wallet).rules.get(made.rulesIds[0])) + .rule as Rule + ).allowedCalls + ).toHaveLength(8); + const invalid = [ + { ...input, allowedCalls: [] }, + { + ...input, + allowedCalls: [ + ...allowedCalls, + { + target: f.ownerAddress, + selector: 'value-only', + }, + ], + }, + { ...input, allowedCalls: [allowedCalls[0], allowedCalls[0]] }, + { ...input, target: f.ownerAddress }, + ]; + const next = await f.owner.agentic.derive(), + nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); + for (const rule of invalid) + await expect( + f.owner.agentic.create('invalid-native-allowedCalls', { + rules: [rule as Rule], + }) + ).rejects.toMatchObject({ + code: expect.stringMatching(/INVALID_RULE|CAPABILITY_UNAVAILABLE/), + }); + expect(await f.owner.agentic.derive()).toEqual(next); + expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( + nonce + ); + f.evidence('native-multi-eight-boundary', { + wallet: made.wallet, + id: made.rulesIds[0], + hash: made.tx.hash, + unchangedNextSlot: next.index, + }); + }); + it('9. one expiry applies to all calls and expired calls move nothing', async () => { + const input = rule(), + expiry = inSeconds(60); + input.validUntil = expiry; + const s = await fixture(input); + expect((await s.handle.rules.get(s.id)).validUntil).toBe(expiry); + const deadline = Date.now() + 120_000; + while (Number((await f.push.getBlock()).timestamp) <= expiry) { + if (Date.now() > deadline) throw Error('Donut expiry wait timed out'); + await new Promise((r) => setTimeout(r, 2000)); + } + await refused(s, () => sendPC(s), 'PolicyCheckReverted(RulesExpired)'); + await refused(s, () => sendToken(s), 'PolicyCheckReverted(RulesExpired)'); + f.evidence('native-multi-shared-expiry', { + wallet: s.wallet, + id: s.id, + expiry, + }); + }, 300_000); + + it('10. malformed action objects and invalid rule expiry return structured errors before signing', async () => { + const base = rule(), + next = await f.owner.agentic.derive(), + nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); + const invalid = [ + { ...base, allowedCalls: [null] }, + { ...base, allowedCalls: [42] }, + { + ...base, + validUntil: undefined, + allowedCalls: [ + { + ...base.allowedCalls[0], + }, + ], + }, + { + ...base, + validUntil: 1, + allowedCalls: [ + { + ...base.allowedCalls[0], + }, + ], + }, + ]; + for (const input of invalid) + await expect( + f.owner.agentic.create('invalid-multi-action', { + rules: [input as unknown as Rule], + }) + ).rejects.toMatchObject({ name: 'AgenticError', code: 'INVALID_RULE' }); + expect(await f.owner.agentic.derive()).toEqual(next); + expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( + nonce + ); + f.evidence('native-multi-invalid-input', { + unchangedNextSlot: next.index, + ownerNonce: nonce, + }); + }); +}); diff --git a/packages/core/__e2e__/agw/native-policy-extended.spec.ts b/packages/core/__e2e__/agw/native-policy-extended.spec.ts index 72c315026..c98c74355 100644 --- a/packages/core/__e2e__/agw/native-policy-extended.spec.ts +++ b/packages/core/__e2e__/agw/native-policy-extended.spec.ts @@ -1,20 +1,18 @@ /** Native policy limits, confirmed zero defaults and lossless raw-offset reuse. */ -import { PushChain, AgenticRevertError, type NativeRule } from '../../src'; +import { PushChain, AgenticRevertError, type RuleInput } from '../../src'; import { encodeFunctionData, erc20Abi, type Address } from 'viem'; import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; import { readNativeCounters } from '../shared/agw-state'; import { CHAIN } from '../../src/lib/constants/enums'; import { setupAgw, inSeconds, type AgwFixture } from './_fixture'; - const d = process.env['AGW_E2E'] === '1' ? describe : describe.skip; const chain = PushChain.CONSTANTS.CHAIN.PUSH_TESTNET_DONUT; - d('agw extended native policy', () => { let f: AgwFixture, token: Address; beforeAll(async () => { f = await setupAgw(); token = getNativePRC20ForChain(CHAIN.ETHEREUM_SEPOLIA, f.manifest.network); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); const transfer = (to: Address, amount: bigint) => encodeFunctionData({ @@ -30,15 +28,18 @@ d('agw extended native policy', () => { ) { const rule = { agent: f.agentAddress, - target: token, - selector: 'transfer(address,uint256)' as const, validUntil: inSeconds(3600), - ...(omitPc - ? {} - : { maxValuePerCall: BigInt(0), maxValueTotal: BigInt(0) }), - maxCalls, - pins: [{ arg: 0, expected: f.ownerAddress }], - amount: { arg: 1, maxPerCall: BigInt(10), maxTotal: total }, + allowedCalls: [ + { + target: token, + selector: 'transfer(address,uint256)' as const, + ...(omitPc ? {} : { maxValue: BigInt(0) }), + pins: [ + { arg: 0, expected: f.ownerAddress }, + { arg: 1, max: total === BigInt(0) ? BigInt(0) : BigInt(10) }, + ], + }, + ], }; const made = await f.owner.agentic.create('e2e-native-meter', { rules: [rule], @@ -105,16 +106,20 @@ d('agw extended native policy', () => { .wallet(s.wallet) .rules.get(s.rulesIds[0]); expect(record.rule).toMatchObject({ - pins: [ + allowedCalls: [ { - offset: 4, - expected: `0x${f.ownerAddress - .slice(2) - .toLowerCase() - .padStart(64, '0')}`, + pins: [ + { + arg: 0, + expected: `0x${f.ownerAddress + .slice(2) + .toLowerCase() + .padStart(64, '0')}`, + }, + { arg: 1, max: BigInt(10) }, + ], }, ], - amount: { offset: 36, maxPerCall: BigInt(10), maxTotal: BigInt(15) }, }); const before = await f.push.readContract({ address: token, @@ -155,23 +160,25 @@ d('agw extended native policy', () => { const s = await fixture(); await refused(s, () => s.send(BigInt(11)), 'NativeAmountExceedsCap'); }); - it('4. cumulative amount accepts the exact total and refuses the next unit', async () => { + it('4. repeated per-call amounts accumulate spend without a removed lifetime cap', async () => { const s = await fixture(BigInt(50), 10); await (await s.send(BigInt(10))).wait(); await (await s.send(BigInt(5))).wait(); expect((await s.counters()).amountSpent).toBe(BigInt(15)); - await refused(s, () => s.send(BigInt(1)), 'TotalNativeAmountExceeded'); + await (await s.send(BigInt(1))).wait(); + expect((await s.counters()).amountSpent).toBe(BigInt(16)); }); - it('5. call count accepts the exact count and refuses a zero-amount extra call', async () => { + it('5. repeated calls have no removed maxCalls limit', async () => { const s = await fixture(); await (await s.send(BigInt(1))).wait(); await (await s.send(BigInt(1))).wait(); expect((await s.counters()).callsUsed).toBe(2); - await refused(s, () => s.send(BigInt(0)), 'CallLimitReached'); + await (await s.send(BigInt(0))).wait(); + expect((await s.counters()).callsUsed).toBe(3); }); - it('6. explicit zero total forbids positive amount but permits a metered zero call', async () => { + it('6. explicit zero MAX pin forbids positive amount but permits a metered zero call', async () => { const s = await fixture(BigInt(50), 10, BigInt(0)); - await refused(s, () => s.send(BigInt(1)), 'TotalNativeAmountExceeded'); + await refused(s, () => s.send(BigInt(1)), 'NativeAmountExceedsCap'); await (await s.send(BigInt(0))).wait(); expect(await s.counters()).toMatchObject({ amountSpent: BigInt(0), @@ -216,10 +223,13 @@ d('agw extended native policy', () => { .wallet(s.wallet) .rules.get(s.rulesIds[0]); expect(record.rule).toMatchObject({ - maxValuePerCall: BigInt(0), - maxValueTotal: BigInt(0), + allowedCalls: [{ maxValue: BigInt(0) }], }); - await refused(s, () => s.send(BigInt(1), f.ownerAddress, BigInt(1)), 'ValueExceedsCap'); + await refused( + s, + () => s.send(BigInt(1), f.ownerAddress, BigInt(1)), + 'ValueExceedsCap' + ); const sent = await s.send(BigInt(1)); expect((await sent.wait()).status).toBe(1); expect(await s.counters()).toMatchObject({ @@ -228,21 +238,21 @@ d('agw extended native policy', () => { }); f.evidence('zero-pc-defaults', { wallet: s.wallet, txHash: sent.hash }); }); - it('10. decoded raw-offset rules can be granted again without fabricated ref metadata', async () => { + it('10. decoded arg-index rules can be granted again without fabricated ref metadata', async () => { const s = await fixture(BigInt(0)); const original = await f.owner.agentic .wallet(s.wallet) .rules.get(s.rulesIds[0]); expect(original).not.toHaveProperty('ref'); - const made = await f.owner.agentic.create('raw-offset-reuse', { - rules: [original.rule as NativeRule], + const made = await f.owner.agentic.create('arg-index-reuse', { + rules: [original.rule as RuleInput], }); const reread = await f.owner.agentic .wallet(made.wallet) .rules.get(made.rulesIds[0]); expect(reread.rule).toEqual(original.rule); expect(reread).not.toHaveProperty('ref'); - f.evidence('raw-offset-reuse', { + f.evidence('arg-index-reuse', { wallet: made.wallet, txHash: made.tx.hash, }); @@ -251,9 +261,13 @@ d('agw extended native policy', () => { const nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); const rule = { agent: f.agentAddress, - target: token, - selector: 'transfer(address,uint256)', validUntil: inSeconds(3600), + allowedCalls: [ + { + target: token, + selector: 'transfer(address,uint256)', + }, + ], }; await expect( f.owner.agentic.create('invalid-ref', { diff --git a/packages/core/__e2e__/agw/responses-hooks.spec.ts b/packages/core/__e2e__/agw/responses-hooks.spec.ts index b026a005a..683e70af9 100644 --- a/packages/core/__e2e__/agw/responses-hooks.spec.ts +++ b/packages/core/__e2e__/agw/responses-hooks.spec.ts @@ -5,40 +5,67 @@ */ import { getAddress, parseEther, type Address } from 'viem'; import type { ProgressEvent } from '../../src/lib/progress-hook/progress-hook.types'; -import { AGW_E2E_ENABLED, inSeconds, setupAgw, type AgwFixture } from './_fixture'; - +import { + AGW_E2E_ENABLED, + inSeconds, + setupAgw, + type AgwFixture, +} from './_fixture'; const d = AGW_E2E_ENABLED ? describe : describe.skip; const SINK = getAddress('0x000000000000000000000000000000000000dEaD'); - d('agw responses', () => { let f: AgwFixture; let wallet: Address; beforeAll(async () => { f = await setupAgw(); const created = await f.owner.agentic.create('e2e-hooks', { - rules: [{ agent: f.agentAddress, target: SINK, selector: 'value-only', validUntil: inSeconds(3600), maxValuePerCall: BigInt(10), maxValueTotal: BigInt(100) }], + rules: [ + { + agent: f.agentAddress, + validUntil: inSeconds(3600), + allowedCalls: [ + { + target: SINK, + selector: 'value-only', + maxValue: BigInt(10), + }, + ], + }, + ], }); wallet = created.wallet; await f.fundPC(wallet, parseEther('0.001')); - }, 300_000); + }, 300000); afterAll(() => f?.teardown()); - it('1. init and per-call hooks each see every event once, through wait', async () => { const init: ProgressEvent[] = []; const perCall: ProgressEvent[] = []; const agent = await f.agent(wallet, (e) => init.push(e as ProgressEvent)); - const tx = await agent.universal.sendTransaction({ to: SINK, value: BigInt(1) }, { progressHook: (e) => perCall.push(e) }); + const tx = await agent.universal.sendTransaction( + { to: SINK, value: BigInt(1) }, + { progressHook: (e) => perCall.push(e) } + ); await tx.wait(); const ids = (xs: ProgressEvent[]) => xs.map((e) => e.id); expect(ids(perCall)).toEqual(ids(init)); - expect(new Set(ids(perCall).filter((i) => i.startsWith('AGENTIC-TX'))).size).toBe(ids(perCall).filter((i) => i.startsWith('AGENTIC-TX')).length); - expect(ids(perCall)).toEqual(expect.arrayContaining(['AGENTIC-TX-107', 'AGENTIC-TX-199-01', 'SEND-TX-199-01'])); + expect( + new Set(ids(perCall).filter((i) => i.startsWith('AGENTIC-TX'))).size + ).toBe(ids(perCall).filter((i) => i.startsWith('AGENTIC-TX')).length); + expect(ids(perCall)).toEqual( + expect.arrayContaining([ + 'AGENTIC-TX-107', + 'AGENTIC-TX-199-01', + 'SEND-TX-199-01', + ]) + ); expect(ids(perCall)).not.toContain('AGENTIC-TX-103'); }); - it('2. live and replayed responses agree on wallet from and signer origin', async () => { const agent = await f.agent(wallet); - const tx = await agent.universal.sendTransaction({ to: SINK, value: BigInt(1) }); + const tx = await agent.universal.sendTransaction({ + to: SINK, + value: BigInt(1), + }); await tx.wait(); const replay = await f.owner.universal.trackTransaction(tx.hash); expect(replay.from).toBe(tx.from); diff --git a/packages/core/__e2e__/agw/svm-public.spec.ts b/packages/core/__e2e__/agw/svm-public.spec.ts index 79ff90896..a135ea64b 100644 --- a/packages/core/__e2e__/agw/svm-public.spec.ts +++ b/packages/core/__e2e__/agw/svm-public.spec.ts @@ -1,6 +1,7 @@ -/** Public named-IDL lifecycle and destination delivery on Donut/Solana devnet. */ +import { actionId, configId } from '../../src/lib/agentic/codec/ids'; +/** Public numeric-pin lifecycle and destination delivery on Donut/Solana devnet. */ import '@e2e/shared/setup'; -import { PushChain, type SolanaRule } from '../../src'; +import { PushChain, type Rule } from '../../src'; import type { Idl } from '@coral-xyz/anchor'; import { PublicKey, Keypair } from '@solana/web3.js'; import bs58 from 'bs58'; @@ -43,35 +44,23 @@ d('agw public svm', () => { cea: PublicKey, idl: Idl; const sol = svmWireConnection(); - const rule = (): SolanaRule => ({ + const rule = (): Rule => ({ agent: f.agentAddress, chainNamespace: SVM_WIRE_CHAIN, validUntil: inSeconds(3600), assets: [{ token: zeroAddress, maxPerCall: amount, maxTotal: amount }], maxGasPerCall: parseEther('20'), - allowedInstructions: [ + allowedCalls: [ { - program: SVM_WIRE_PROGRAM, - instruction: { idl, name: 'receive_sol' }, - accounts: [ - { - name: 'counter', - expected: { kind: 'address', address: SVM_WIRE_COUNTER.toBase58() }, - }, - { - name: 'recipient', - expected: { kind: 'address', address: recipient.toBase58() }, - }, - { name: 'cea_authority', expected: { kind: 'walletCEA' } }, - { - name: 'system_program', - expected: { - kind: 'address', - address: PublicKey.default.toBase58(), - }, - }, + target: SVM_WIRE_PROGRAM, + selector: 'receive_sol(u64)', + pins: [ + { account: 0, expected: SVM_WIRE_COUNTER.toBase58() }, + { account: 1, expected: recipient.toBase58() }, + { account: 2, expected: PushChain.CONSTANTS.AGENTIC.WALLET }, + { account: 3, expected: PublicKey.default.toBase58() }, + { arg: 0, max: amount }, ], - fields: [{ name: 'amount', max: amount }], }, ], }); @@ -185,7 +174,7 @@ d('agw public svm', () => { }); }, 900_000); afterAll(() => f?.teardown()); - it('1. named IDL grant reads exact constraints and replaces atomically', async () => { + it('1. numeric-pin grant reads exact raw constraints and replaces atomically', async () => { const handle = f.owner.agentic.wallet(wallet), record = await handle.rules.get(id); expect(record).not.toHaveProperty('ref'); @@ -195,7 +184,12 @@ d('agw public svm', () => { }); const count = (await handle.checkpoints()).checkpoints.length; const updated = await handle.rules.update({ - rules: [{ rulesId: id, rule: rule() }], + rules: [ + { + rulesId: id, + rule: { ...record.rule, validUntil: record.validUntil + 60 }, + }, + ], }); const previous = id; id = updated.rules[0].rulesId; @@ -254,13 +248,10 @@ d('agw public svm', () => { }); }, 900_000); it('3. contract simulation maps account and data refusals without state changes', async () => { - const config = PushChain.utils.agentic.configId( + const config = configId( wallet, id, - PushChain.utils.agentic.actionId( - f.manifest.addresses.gateway, - '0x77b86bec' - ) + actionId(f.manifest.addresses.gateway, '0x77b86bec') ); const state = async () => { const [tokens, pcBalance, checkpoints, cfg] = await Promise.all([ @@ -332,7 +323,7 @@ d('agw public svm', () => { }); it('4. public revoke removes the Solana rule and subsequent sends are refused', async () => { const handle = f.owner.agentic.wallet(wallet); - await (await handle.rules.revoke([id])).wait(); + await (await handle.rules.delete([id])).wait(); expect((await handle.rules.list()).rules).toHaveLength(0); await expect( agent.universal.sendTransaction({ diff --git a/packages/core/__e2e__/agw/universal-evm.spec.ts b/packages/core/__e2e__/agw/universal-evm.spec.ts index a617a1788..f69d6c18f 100644 --- a/packages/core/__e2e__/agw/universal-evm.spec.ts +++ b/packages/core/__e2e__/agw/universal-evm.spec.ts @@ -143,7 +143,7 @@ d('agw universal evm', () => { const tx = await agent.universal.sendTransaction({ to: { address: counter, chain: SEPOLIA }, data: [{ to: counter, value: BigInt(0), data: INCREMENT }], - gasLimit: DESTINATION_GAS, + // Omit gasLimit: the SDK must budget for this fresh CEA. funds: { amount: AMOUNT, token: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH, diff --git a/packages/core/__e2e__/agw/v5-multiasset.spec.ts b/packages/core/__e2e__/agw/v5-multiasset.spec.ts index 8c3a3cb42..25bb48281 100644 --- a/packages/core/__e2e__/agw/v5-multiasset.spec.ts +++ b/packages/core/__e2e__/agw/v5-multiasset.spec.ts @@ -1,5 +1,5 @@ /** Opt-in funded Donut acceptance. Nothing in this file runs in unit/local validation. */ -import type { UniversalRule } from '../../src'; +import type { Rule } from '../../src'; import { createPublicClient, encodeFunctionData, @@ -42,7 +42,7 @@ d('agw v5 multi-asset', () => { }); afterAll(() => f?.teardown()); it('1. public create reads and replaces an ordered two-token rule', async () => { - const rule: UniversalRule = { + const rule: Rule = { agent: f.agentAddress, chainNamespace: chain, validUntil: inSeconds(3600), @@ -69,12 +69,18 @@ d('agw v5 multi-asset', () => { assets: [ { maxPerCall: BigInt(1000), - maxTotal: BigInt(2) ** BigInt(256) - BigInt(1), + maxTotal: BigInt(0), }, { maxPerCall: BigInt(10), maxTotal: BigInt(100) }, ], }); - expect(before).not.toHaveProperty('spent'); + expect(before.spent).toMatchObject({ + kind: 'universal', + assets: [ + expect.objectContaining({ spent: BigInt(0) }), + expect.objectContaining({ spent: BigInt(0) }), + ], + }); const count = await f.push.readContract({ address: created.wallet, abi: v5.abis.wallet, diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index 3bc27a740..9e08598ef 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -168,19 +168,32 @@ const F = { export const SCENARIOS: Scenario[] = [ { - id: 'agw-svm-replay-positive', group: 'agw-svm-replay', file: '__e2e__/agw-svm-wire/replay.spec.ts', - grep: 'agw svm recorded replay 1\\. positive replay', needs: {}, env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, + id: 'agw-svm-replay-positive', + group: 'agw-svm-replay', + file: '__e2e__/agw-svm-wire/replay.spec.ts', + grep: 'agw svm recorded replay 1\\. positive replay', + needs: {}, + env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, }, { - id: 'agw-svm-replay-rejected', group: 'agw-svm-replay', file: '__e2e__/agw-svm-wire/replay.spec.ts', - grep: 'agw svm recorded replay 2\\. later terminal rejection', needs: {}, env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, + id: 'agw-svm-replay-rejected', + group: 'agw-svm-replay', + file: '__e2e__/agw-svm-wire/replay.spec.ts', + grep: 'agw svm recorded replay 2\\. later terminal rejection', + needs: {}, + env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, }, { - id: 'agw-svm-replay-policy', group: 'agw-svm-replay', file: '__e2e__/agw-svm-wire/replay.spec.ts', - grep: 'agw svm recorded replay 3\\. pinned live policy', needs: {}, env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, + id: 'agw-svm-replay-policy', + group: 'agw-svm-replay', + file: '__e2e__/agw-svm-wire/replay.spec.ts', + grep: 'agw svm recorded replay 3\\. pinned live policy', + needs: {}, + env: { AGW_SVM_WIRE_REPLAY_E2E: '1' }, }, { - id: 'agw-svm-wire-positive', group: 'agw-svm-wire', + id: 'agw-svm-wire-positive', + group: 'agw-svm-wire', file: '__e2e__/agw-svm-wire/outbound.spec.ts', grep: 'agw svm wire 1\\. positive outbound', needs: { masterPC: '45', masterPSOL: '0.00002' }, @@ -188,14 +201,16 @@ export const SCENARIOS: Scenario[] = [ note: 'Internal prepared-wire backend; public SVM capability remains gated.', }, { - id: 'agw-svm-wire-refusals', group: 'agw-svm-wire', + id: 'agw-svm-wire-refusals', + group: 'agw-svm-wire', file: '__e2e__/agw-svm-wire/outbound.spec.ts', grep: 'agw svm wire 2\\. substituted account', needs: { masterPC: '45', masterPSOL: '0.00002' }, env: { AGW_SVM_WIRE_E2E: '1' }, }, { - id: 'agw-svm-wire-failure', group: 'agw-svm-wire', + id: 'agw-svm-wire-failure', + group: 'agw-svm-wire', file: '__e2e__/agw-svm-wire/outbound.spec.ts', grep: 'agw svm wire 3\\. destination program failure', needs: { masterPC: '45', masterPSOL: '0.00002' }, @@ -1043,35 +1058,62 @@ export const SCENARIOS: Scenario[] = [ }, ...Array.from({ length: 6 }, (_, i) => ({ - id: `agw-public-svm-${i + 1}`, group: 'agw' as const, - file: '__e2e__/agw/svm-public.spec.ts', grep: `agw public svm ${i + 1}\\.`, - needs: { masterPC: '45', masterPSOL: '0.000020001' }, env: { AGW_E2E: '1' }, + id: `agw-public-svm-${i + 1}`, + group: 'agw' as const, + file: '__e2e__/agw/svm-public.spec.ts', + grep: `agw public svm ${i + 1}\\.`, + needs: { masterPC: '45', masterPSOL: '0.000020001' }, + env: { AGW_E2E: '1' }, })), ...Array.from({ length: 8 }, (_, i) => ({ - id: `agw-labels-${i + 1}`, group: 'agw' as const, - file: '__e2e__/agw/labels.spec.ts', grep: `agw labels ${i + 1}\\.`, - needs: i === 4 ? { masterPC: '4', sepoliaEth: '0.003' } : i === 5 ? { masterPC: '4', solanaSOL: '0.003' } : { masterPC: '1' }, + id: `agw-labels-${i + 1}`, + group: 'agw' as const, + file: '__e2e__/agw/labels.spec.ts', + grep: `agw labels ${i + 1}\\.`, + needs: + i === 4 + ? { masterPC: '4', sepoliaEth: '0.003' } + : i === 5 + ? { masterPC: '4', solanaSOL: '0.003' } + : { masterPC: '1' }, env: { AGW_E2E: '1' }, })), { - id: 'agw-v5-independent-token-budgets', group: 'agw', - file: '__e2e__/agw/v5-multiasset.spec.ts', grep: 'agw v5 multi-asset 3\\.', - needs: { masterPC: '45', masterPETH: '0.000001000000000001', masterUsdcEth: '0.000011' }, + id: 'agw-v5-independent-token-budgets', + group: 'agw', + file: '__e2e__/agw/v5-multiasset.spec.ts', + grep: 'agw v5 multi-asset 3\\.', + needs: { + masterPC: '45', + masterPETH: '0.000001000000000001', + masterUsdcEth: '0.000011', + }, env: { AGW_E2E: '1' }, }, { - id: 'agw-owner-spl-transfer', group: 'agw', - file: '__e2e__/agw/svm-spl-transfer.spec.ts', grep: 'agw svm spl transfer 1\\.', + id: 'agw-owner-spl-transfer', + group: 'agw', + file: '__e2e__/agw/svm-spl-transfer.spec.ts', + grep: 'agw svm spl transfer 1\\.', needs: { masterPC: '23', masterUsdtSol: '0.001' }, env: { AGW_E2E: '1' }, }, + ...Array.from({ length: 10 }, (_, i) => ({ + id: `agw-native-multi-action-${i + 1}`, + group: 'agw' as const, + file: '__e2e__/agw/native-multi-action.spec.ts', + grep: `agw native multi-action ${i + 1}\\.`, + needs: { masterPC: '1', masterPETH: '0.00000000000000004' }, + env: { AGW_E2E: '1' }, + })), + // Extended AGW tests are opt-in and use bounded per-scenario testnet budgets. ...[ ['prefunding', '1\\. prefunding', '11'], - ['ambiguous-rules', '2\\. same-agent multiplicity', '11'], + ['duplicate-rules', '2\\. create and add reject duplicate', '11'], ['batched-lifecycle', '3\\. multi-rule add', '0.5'], ['invalid-revoke', '4\\. invalid revoke', '0.5'], ['stale-update', '5\\. intervening agent spend', '11'], diff --git a/packages/core/__e2e__/shared/agw-state.ts b/packages/core/__e2e__/shared/agw-state.ts index 2c633e03e..09d834434 100644 --- a/packages/core/__e2e__/shared/agw-state.ts +++ b/packages/core/__e2e__/shared/agw-state.ts @@ -29,3 +29,34 @@ export async function readNativeCounters( callsUsed: Number(cfg.callsUsed), }; } + +/** All native action terms/counters in engine order, pinned to one block. */ +export async function readNativeActionConfigs( + client: PublicClient, + addresses: { sessionEngine: Address; rulesPolicy: Address }, + wallet: Address, + rulesId: Hex, + blockNumber?: bigint +) { + const block = blockNumber ?? (await client.getBlockNumber({ cacheTime: 0 })); + const actions = await client.readContract({ + address: addresses.sessionEngine, + abi: v5.abis.engine, + functionName: 'getEnabledActions', + args: [wallet, rulesId], + blockNumber: block, + }); + if (!actions.length) throw new Error('Native fixture has no enabled action'); + return Promise.all( + actions.map(async (action) => ({ + actionId: action, + config: await client.readContract({ + address: addresses.rulesPolicy, + abi: v5.abis.policy, + functionName: 'getNativeConfig', + args: [configId(wallet, rulesId, action), wallet], + blockNumber: block, + }), + })) + ); +} diff --git a/packages/core/scripts/check-agw-api.js b/packages/core/scripts/check-agw-api.js index 41cde0232..4aa6c9294 100644 --- a/packages/core/scripts/check-agw-api.js +++ b/packages/core/scripts/check-agw-api.js @@ -21,6 +21,10 @@ const selected = [ 'AgenticError', 'AgenticRevertError', 'AGENTIC_ERROR_CODE', + 'Rule', + 'RuleInput', + 'Pin', + 'Spent', 'NativeRule', 'ArgPin', 'AmountLimit', @@ -320,7 +324,11 @@ function snapshot(entry) { const aType = checker.getTypeOfSymbolAtLocation(agw, file); const donut = aType.getProperty('TESTNET_DONUT'); result.agwConstants = properties( - checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(donut, file)) + donut + ? checker.getNonNullableType( + checker.getTypeOfSymbolAtLocation(donut, file) + ) + : aType ); result.constants = properties(cType); return result; diff --git a/packages/core/scripts/check-agw-package.js b/packages/core/scripts/check-agw-package.js index 83cc7805c..85e87be47 100644 --- a/packages/core/scripts/check-agw-package.js +++ b/packages/core/scripts/check-agw-package.js @@ -13,10 +13,7 @@ async function main() { esm = await import(pathToFileURL(entry).href); assert.equal(typeof cjs.PushChain.initialize, 'function'); assert.equal(esm.PushChain, cjs.PushChain); - assert.equal( - cjs.PushChain.CONSTANTS.AGENTIC.TESTNET_DONUT.ENVELOPE_VERSION, - 1 - ); + assert.equal(cjs.PushChain.CONSTANTS.AGENTIC.WALLET, 'agentic:wallet'); assert.equal(typeof cjs.AgenticError, 'function'); const [pack] = JSON.parse( execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { diff --git a/packages/core/src/lib/agentic/__tests__/fake-chain.ts b/packages/core/src/lib/agentic/__tests__/fake-chain.ts index d56212bf9..1b38300ea 100644 --- a/packages/core/src/lib/agentic/__tests__/fake-chain.ts +++ b/packages/core/src/lib/agentic/__tests__/fake-chain.ts @@ -51,6 +51,8 @@ export interface FakeRule { chain: string; actionIds: Hex[]; native?: Partial; + /** Per-action native config overrides, keyed by lowercase action ID. */ + nativeByAction?: Record>; universal?: { assets: readonly { token: Address; @@ -230,14 +232,19 @@ export class FakeChain implements ChainReader { } case 'getNativeConfig': { const found = this.findConfig(a[0] as Hex, a[1] as Address); - const n = found?.rule.native ?? {}; + const n = { + ...(found?.rule.native ?? {}), + ...(found + ? found.rule.nativeByAction?.[found.action.toLowerCase()] ?? {} + : {}), + }; return { initialized: !!found, validUntil: 2_000_000_000, target: ADDR.target, selector: '0xd09de08a', maxValuePerCall: BigInt(0), - maxValueTotal: BigInt(0), + maxValueTotal: (BigInt(1) << BigInt(256)) - BigInt(1), valueSpent: BigInt(0), amount: { enabled: false, diff --git a/packages/core/src/lib/agentic/__tests__/labels.spec.ts b/packages/core/src/lib/agentic/__tests__/labels.spec.ts index 9688dc4bd..84527c65c 100644 --- a/packages/core/src/lib/agentic/__tests__/labels.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/labels.spec.ts @@ -37,7 +37,7 @@ describe('v5 wallet labels', () => { expect(chain.calls).toEqual([]); }); - it('encodes a reset through the owner door and forwards the per-call progress hook', async () => { + it('encodes a direct reset and forwards the per-call progress hook', async () => { const chain = new FakeChain(); const w = chain.addWallet(ADDR.owner, 'custom'); const rt = mockRuntime(chain, { signer: ADDR.owner }); @@ -47,14 +47,12 @@ describe('v5 wallet labels', () => { }); const sent = rt.executeMock.mock.calls[0][0]; expect(sent).toMatchObject({ to: w.address, value: BigInt(0) }); - const outer = v5.decodeWalletCall(sent.data); - expect(outer?.kind).toBe('execute'); - if (outer?.kind !== 'execute') throw new Error('expected owner execute'); - expect(outer.calls).toHaveLength(1); - expect(outer.calls[0].target).toBe(w.address); expect( - decodeFunctionData({ abi: v5.abis.wallet, data: outer.calls[0].data }) + decodeFunctionData({ abi: v5.abis.wallet, data: sent.data }) ).toMatchObject({ functionName: 'setLabel', args: [''] }); + expect(hook).toHaveBeenCalledWith( + expect.objectContaining({ id: PROGRESS_HOOK.AGENTIC_TX_106 }) + ); expect(hook).toHaveBeenCalledWith( expect.objectContaining({ id: PROGRESS_HOOK.AGENTIC_TX_199_01 }) ); @@ -76,7 +74,7 @@ describe('v5 wallet labels', () => { expect(readOnly.executeMock).not.toHaveBeenCalled(); }); - it('uses the same owner door for an external owner identity', async () => { + it('uses a direct label call for an external owner identity', async () => { const chain = new FakeChain(); const w = chain.addWallet(ADDR.owner, 'x'); const rt = mockRuntime(chain, { @@ -85,8 +83,11 @@ describe('v5 wallet labels', () => { }); await setLabel(rt, gen(), w.address, 'UEA label'); expect( - v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data)?.kind - ).toBe('execute'); + decodeFunctionData({ + abi: v5.abis.wallet, + data: rt.executeMock.mock.calls[0][0].data, + }).functionName + ).toBe('setLabel'); }); it('maps the new contract length error and emits a failed operation hook', async () => { diff --git a/packages/core/src/lib/agentic/__tests__/management.spec.ts b/packages/core/src/lib/agentic/__tests__/management.spec.ts index d9726b6a4..f7d305e1d 100644 --- a/packages/core/src/lib/agentic/__tests__/management.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/management.spec.ts @@ -165,7 +165,7 @@ describe('agentic.create', () => { await expect( createWallet(rt, 'version', { rules: [rule()] }) ).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + code: AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, }); expect(rt.executeMock).not.toHaveBeenCalled(); }); @@ -391,7 +391,9 @@ describe('agentic.create', () => { throw failure; }) as never, }); - await expect(createWallet(rt, 'this-create', { rules: [rule()] })).rejects.toBe(failure); + await expect( + createWallet(rt, 'this-create', { rules: [rule()] }) + ).rejects.toBe(failure); }); it('a slot that is already deployed is refused before signing', async () => { @@ -414,10 +416,10 @@ describe('agentic.create', () => { }); }); -describe('rules.add / rules.revoke', () => { +describe('rules.add / rules.delete', () => { const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); - it('add: owner-only, same-agent grants allowed, IDs from the grant nonce', async () => { + it('add: owner-only, duplicate agent/chain refused before signing', async () => { const fake = new FakeChain(); const w = fake.addWallet(ADDR.owner, 'w', [ fake.nativeRule(ADDR.agent, ruleId(1)), @@ -438,9 +440,10 @@ describe('rules.add / rules.revoke', () => { signer: ADDR.owner, execute: (async () => withLogs([granted(w.address, expected)])) as never, }); - expect( - (await addRules(rt, gen(), w.address, [rule(ADDR.agent)])).rulesIds - ).toEqual([expected]); + await expect( + addRules(rt, gen(), w.address, [rule(ADDR.agent)]) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.DUPLICATE_RULE }); + expect(rt.executeMock).not.toHaveBeenCalled(); }); it('add: several rules are one atomic owner execute batch (one signature on any signer)', async () => { @@ -499,7 +502,7 @@ describe('rules.add / rules.revoke', () => { await expect( revokeRules(rt, gen(), w.address, bad as never) ).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET, + code: AGENTIC_ERROR_CODE.DELETE_NEEDS_TARGET, }); } await expect( diff --git a/packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts b/packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts new file mode 100644 index 000000000..b317a1a2a --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts @@ -0,0 +1,366 @@ +/** + * Multi-action native rules: one rule ID allowing 1..8 Push functions, each + * with its own limits (AGW _grantRules: MAX_NATIVE_ACTIONS, DuplicateAction, + * InconsistentChain). PENDING product sign-off — plan/agw/pending-items.md. + */ +import { getAddress, type Hex } from 'viem'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { actionId } from '../codec/ids'; +import { + MAX_NATIVE_ACTIONS, + nativeActionTermsToRule, + nativeRuleToActionTerms, + nativeRuleToTerms, +} from '../codec/native'; +import { prepareRules } from '../codec/rules'; +import { decodeEnvelope, decodeSession } from '../codec/session'; +import { + currentGeneration, + resetAgenticGenerations, + V5_CAPABILITIES, +} from '../deployments'; +import { AGENTIC_ERROR_CODE } from '../errors'; +import { decodeActiveRule, readActiveRules } from '../reads/rules'; +import { Snapshot } from '../reads/snapshot'; +import { agenticUtils, internalAgenticUtils } from '../utils'; +import type { NativeMultiRule, NativeRule } from '../agentic.types'; +import { + ADDR, + FakeChain, + PUSH_NS, + registerFakeGeneration, + ruleId, +} from './fake-chain'; + +const NOW = 1_700_000_000; +const other = getAddress('0x0000000000000000000000000000000000007b7b'); + +const multi = (over: Partial = {}): NativeMultiRule => ({ + agent: ADDR.agent, + validUntil: NOW + 100, + actions: [ + { target: ADDR.target, selector: 'increment()', maxCalls: 5 }, + { + target: other, + selector: 'deposit(address,uint256)', + pins: [{ arg: 0, expected: ADDR.other }], + amount: { arg: 1, maxPerCall: BigInt(10) }, + maxValuePerCall: BigInt(3), + maxValueTotal: BigInt(9), + }, + ], + ...over, +}); + +const ctx = { + owner: ADDR.owner, + pushChainNamespace: PUSH_NS, + validator: ADDR.validator, + rulesPolicy: ADDR.policy, + gateway: ADDR.gateway, + capabilities: V5_CAPABILITIES, + nowSeconds: NOW, + forbiddenTargets: [ + ADDR.factory, + ADDR.engine, + ADDR.policy, + ADDR.validator, + ADDR.gateway, + ], +}; + +describe('native rule codec — multiple actions', () => { + it('encodes each action with its own limits, in input order, sharing the rule expiry', () => { + const out = nativeRuleToActionTerms(multi(), { nowSeconds: NOW }); + expect(out.map((o) => o.terms.target)).toEqual([ADDR.target, other]); + expect(out[0].terms).toMatchObject({ + maxCalls: 5, + validUntil: NOW + 100, + maxValuePerCall: BigInt(0), + }); + expect(out[1].terms).toMatchObject({ + maxValuePerCall: BigInt(3), + maxValueTotal: BigInt(9), + validUntil: NOW + 100, + }); + expect(out[1].terms.pins).toHaveLength(1); + expect(out[1].terms.amount).toMatchObject({ + enabled: true, + offset: 36, + maxPerCall: BigInt(10), + }); + }); + + it('the single-action shorthand is unchanged and equals a one-entry actions list', () => { + const single: NativeRule = { + agent: ADDR.agent, + target: ADDR.target, + selector: 'increment()', + validUntil: NOW + 100, + maxCalls: 2, + }; + const asList: NativeRule = { + agent: ADDR.agent, + validUntil: NOW + 100, + actions: [{ target: ADDR.target, selector: 'increment()', maxCalls: 2 }], + }; + expect( + nativeRuleToActionTerms(asList, { nowSeconds: NOW })[0].terms + ).toEqual(nativeRuleToTerms(single, { nowSeconds: NOW }).terms); + }); + + it('round-trips: stored terms decode to the actions form and re-encode identically', () => { + const terms = nativeRuleToActionTerms(multi(), { nowSeconds: NOW }).map( + (o) => o.terms + ); + const decoded = nativeActionTermsToRule(terms, ADDR.agent); + expect(decoded.actions).toHaveLength(2); + expect(decoded.validUntil).toBe(NOW + 100); + expect(decoded.actions?.[0]).not.toHaveProperty('validUntil'); + expect( + nativeRuleToActionTerms(decoded, { nowSeconds: NOW }).map((o) => o.terms) + ).toEqual(terms); + }); + + it('a single stored action decodes to the shorthand form', () => { + const terms = nativeRuleToActionTerms( + multi({ actions: [multi().actions[0]] }), + { nowSeconds: NOW } + ).map((o) => o.terms); + const decoded = nativeActionTermsToRule(terms, ADDR.agent); + expect(decoded.actions).toBeUndefined(); + expect(decoded).toMatchObject({ target: ADDR.target, maxCalls: 5 }); + }); + + it('per-action expiries are kept: the rule shows the earliest, later actions carry their own', () => { + const rule = multi({ + actions: [ + { ...multi().actions[0], validUntil: NOW + 500 }, + multi().actions[1], + ], + }); + const terms = nativeRuleToActionTerms(rule, { nowSeconds: NOW }).map( + (o) => o.terms + ); + expect(terms.map((t) => t.validUntil)).toEqual([NOW + 500, NOW + 100]); + const decoded = nativeActionTermsToRule(terms, ADDR.agent); + expect(decoded.validUntil).toBe(NOW + 100); + expect(decoded.actions?.[0].validUntil).toBe(NOW + 500); + expect( + nativeRuleToActionTerms(decoded, { nowSeconds: NOW }).map((o) => o.terms) + ).toEqual(terms); + }); + + it.each([ + [ + 'a null action', + { actions: [null] } as never, + /actions\[0\] must be an action object/, + ], + [ + 'a non-object action', + { actions: [42] } as never, + /actions\[0\] must be an action object/, + ], + [ + 'missing rule expiry despite overrides', + { + validUntil: undefined, + actions: [{ ...multi().actions[0], validUntil: NOW + 100 }], + } as never, + /rule validUntil/, + ], + [ + 'expired rule expiry despite overrides', + { + validUntil: NOW - 1, + actions: [{ ...multi().actions[0], validUntil: NOW + 100 }], + }, + /rule validUntil/, + ], + ['an empty actions list', { actions: [] }, /1\.\.8/], + [ + 'more than the contract limit', + { + actions: Array.from({ length: MAX_NATIVE_ACTIONS + 1 }, (_, i) => ({ + target: getAddress(`0x${(i + 16).toString(16).padStart(40, '0')}`), + selector: 'increment()' as const, + })), + }, + /1\.\.8/, + ], + [ + 'a repeated target and selector', + { + actions: [ + { target: ADDR.target, selector: 'increment()' as const }, + { target: ADDR.target, selector: '0xd09de08a' as const }, + ], + }, + /repeats the target and selector of actions\[0\]/, + ], + [ + 'top-level action fields mixed with actions', + { target: ADDR.target } as never, + /cannot be combined with top-level target/, + ], + [ + 'an invalid action, reported with its index', + { + actions: [ + multi().actions[0], + { + target: other, + selector: 'f(bytes)' as const, + pins: [{ arg: 0, expected: '0x01' as Hex }], + }, + ], + }, + /^actions\[1\]: /, + ], + ])('rejects %s before signing', (_name, patch, message) => { + expect(() => + nativeRuleToActionTerms(multi(patch as Partial), { + nowSeconds: NOW, + }) + ).toThrow( + expect.objectContaining({ + code: AGENTIC_ERROR_CODE.INVALID_RULE, + message: expect.stringMatching(message), + }) + ); + }); + + it('the same function on different targets is allowed (distinct action IDs)', () => { + expect(() => + nativeRuleToActionTerms( + multi({ + actions: [ + { target: ADDR.target, selector: 'increment()' }, + { target: other, selector: 'increment()' }, + ], + }), + { nowSeconds: NOW } + ) + ).not.toThrow(); + }); +}); + +describe('prepareRules — one session, one rule ID, several actions', () => { + it('builds one session action per native action, each with its own URP envelope on the Push chain', () => { + const [p] = prepareRules([multi()], ctx); + expect(p.session.actions.map((a) => a.actionTarget)).toEqual([ + ADDR.target, + other, + ]); + expect( + p.session.actions.every( + (a) => + a.actionPolicies.length === 1 && + a.actionPolicies[0].policy === ADDR.policy + ) + ).toBe(true); + expect( + p.session.actions.map( + (a) => decodeEnvelope(a.actionPolicies[0].initData).chainNamespace + ) + ).toEqual([PUSH_NS, PUSH_NS]); + expect(p.nativeTerms).toHaveLength(2); + }); + + it('refuses an infrastructure target in any action, naming it', () => { + expect(() => + prepareRules( + [ + multi({ + actions: [ + multi().actions[0], + { target: ADDR.gateway, selector: 'increment()' }, + ], + }), + ], + ctx + ) + ).toThrow(/rules\[0\]\.actions\[1\]\.target/); + }); + + it('one multi-action rule is one rule per agent on Push, so it never collides with itself', () => { + expect(prepareRules([multi()], ctx)).toHaveLength(1); + }); +}); + +describe('utils.agentic.decodeRules — several actions', () => { + it('decodes a multi-action session into the actions form', () => { + const [bytes] = internalAgenticUtils.encodeRules([multi()], { + pushChainNamespace: PUSH_NS, + validator: ADDR.validator, + rulesPolicy: ADDR.policy, + nowSeconds: NOW, + }); + expect(decodeSession(bytes).actions).toHaveLength(2); + const back = agenticUtils.decodeRules(bytes, { + pushChainNamespace: PUSH_NS, + }) as NativeMultiRule; + expect(back.actions.map((a) => a.target)).toEqual([ADDR.target, other]); + expect(back.actions[1]).toMatchObject({ + maxValuePerCall: BigInt(3), + amount: { offset: 36, maxPerCall: BigInt(10) }, + }); + }); +}); + +describe('rules.get / rules.list — reading several stored actions', () => { + afterEach(() => resetAgenticGenerations()); + + it('decodes every action in engine order at one block', async () => { + registerFakeGeneration(); + const fake = new FakeChain(); + const a0 = actionId(ADDR.target, '0xd09de08a'); + const a1 = actionId(other, '0x47e7ef24'); + const w = fake.addWallet(ADDR.owner, 'w', [ + { + ...fake.nativeRule(ADDR.agent, ruleId(1)), + actionIds: [a0, a1], + nativeByAction: { + [a0.toLowerCase()]: { + target: ADDR.target, + selector: '0xd09de08a', + maxCalls: 0, + callsUsed: 5, + }, + [a1.toLowerCase()]: { + target: other, + selector: '0x47e7ef24', + maxValuePerCall: BigInt(3), + }, + }, + }, + ]); + const gen = currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + const snap = await Snapshot.at(fake); + const [active] = await readActiveRules(snap, gen, w.address); + const record = await decodeActiveRule( + snap, + gen, + w.address, + active, + PUSH_NS + ); + const rule = record.rule as import('../agentic.types').Rule; + expect(rule.allowedCalls.map((a) => [a.target, a.selector])).toEqual([ + [ADDR.target, '0xd09de08a'], + [other, '0x47e7ef24'], + ]); + expect(record.spent).toMatchObject({ + kind: 'native', + actions: [ + expect.objectContaining({ callsUsed: 5 }), + expect.objectContaining({ callsUsed: 0 }), + ], + }); + expect(rule.allowedCalls[1].maxValue).toBe(BigInt(3)); + expect(new Set(fake.calls.map((c) => c.blockNumber))).toEqual( + new Set([fake.block]) + ); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts new file mode 100644 index 000000000..0b24bd422 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -0,0 +1,251 @@ +import { getAddress, zeroAddress } from 'viem'; +import { AGENTIC } from '../constants'; +import { agenticPresets, buildPresetApi } from '../presets'; +import type { PresetDefinition } from '../presets/catalog'; +import type { Rule } from '../agentic.types'; +import { normalizePublicRule, publicRules } from '../public-rule'; +import { FakeChain, ADDR } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; +const chain = 'eip155:11155111' as const, + other = 'eip155:84532' as const; +const target = getAddress('0x0000000000000000000000000000000000001234'); +const token = getAddress('0x000000000000000000000000000000000000abcd'); +const agent = getAddress('0x000000000000000000000000000000000000a999'); +const until = () => Math.floor(Date.now() / 1000) + 3600; +function catalog(): PresetDefinition[] { + return [ + { + constant: 'DEXES', + name: 'Test-only DEX', + chains: [ + { + chainNamespace: chain, + allowedCalls: [ + { + target, + selector: 'deposit(address,uint256)', + pins: [{ arg: 0, expected: AGENTIC.WALLET }], + app: 'Fixture', + label: 'Deposit', + }, + ], + approvals: [{ spender: target, app: 'Fixture' }], + }, + { + chainNamespace: other, + allowedCalls: [ + { + target, + selector: 'withdraw(address)', + pins: [{ arg: 0, expected: AGENTIC.WALLET }], + }, + ], + }, + ], + }, + ]; +} +it('exposes enumerable frozen categories with no invented production addresses', () => { + expect(Object.values(AGENTIC.PRESETS.CATEGORY)).toEqual([ + 'DEXES', + 'LENDING', + 'PREDICTION', + ]); + expect(Object.isFrozen(AGENTIC.PRESETS.CATEGORY)).toBe(true); + expect( + agenticPresets.list().presets.map((p) => [p.constant, p.chains]) + ).toEqual([ + ['DEXES', []], + ['LENDING', []], + ['PREDICTION', []], + ]); + expect(agenticPresets.get('DEXES')).toEqual({ rules: [] }); + expect(() => agenticPresets.get('DEXES', { chainNamespace: chain })).toThrow( + /no approved entry/ + ); + expect(() => + agenticPresets.get('DEXES', { assets: [{ chainNamespace: chain, token }] }) + ).toThrow(/no approved app entries/); +}); +it('groups all apps on a chain into one editable template and omits unspecified fields', () => { + const data = catalog(); + data[0].chains[0].allowedCalls = [ + ...data[0].chains[0].allowedCalls, + { + target: ADDR.other, + selector: 'lend(address)', + pins: [{ arg: 0, expected: AGENTIC.WALLET }], + }, + ]; + const api = buildPresetApi(data), + res = api.get('DEXES'); + expect(res.rules.map((r) => r.chainNamespace)).toEqual([chain, other]); + expect(res.rules[0].allowedCalls).toHaveLength(2); + for (const rule of res.rules) { + expect(rule).not.toHaveProperty('agent'); + expect(rule).not.toHaveProperty('assets'); + expect(rule).not.toHaveProperty('validUntil'); + } + expect(api.list({ type: 'CATEGORY' }).presets[0].chains).toEqual([ + chain, + other, + ]); +}); +it('returns ready Rule types with identity and expiry; routes assets and defaults missing caps to zero', () => { + const api = buildPresetApi(catalog()); + const { rules } = api.get('DEXES', { + agent, + validUntil: until(), + assets: [ + { chainNamespace: chain, token }, + { + chainNamespace: other, + token: zeroAddress, + maxPerCall: BigInt(2), + maxTotal: BigInt(3), + }, + ], + }); + const typed: Rule[] = rules; + expect(typed[0].assets).toEqual([ + { token, maxPerCall: BigInt(0), maxTotal: BigInt(0) }, + ]); + expect(typed[1].assets).toEqual([ + { token: zeroAddress, maxPerCall: BigInt(2), maxTotal: BigInt(3) }, + ]); + expect(typed.every((r) => r.agent === agent && r.validUntil > 0)).toBe(true); +}); +it('does not type an incomplete template as a ready rule', () => { + const { rules } = buildPresetApi(catalog()).get('DEXES'); + // @ts-expect-error callers must fill agent and expiry before create + const ready: Rule[] = rules; + expect(ready[0].agent).toBeUndefined(); +}); +it('adds only spender-pinned and amount-bounded ERC20 approvals; native assets have none', () => { + const { rules } = buildPresetApi(catalog()).get('DEXES', { + chainNamespace: chain, + assets: [ + { token, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, + { token: zeroAddress, maxPerCall: BigInt(1) }, + ], + }); + expect(rules).toHaveLength(1); + expect(rules[0].allowedCalls).toHaveLength(2); + expect(rules[0].allowedCalls[1]).toMatchObject({ + target: token, + selector: 'approve(address,uint256)', + pins: [ + { arg: 0, expected: target }, + { arg: 1, max: BigInt(5) }, + ], + }); + expect(rules[0].assets![0]).not.toHaveProperty('chainNamespace'); +}); +it('does not mutate caller assets, returned records, or the bundled catalog', () => { + const data = catalog(); + const api = buildPresetApi(data); + const asset = { + token: { + address: token, + symbol: 'TEST', + decimals: 6, + mechanism: 'approve' as const, + }, + }; + const first = api.get('DEXES', { chainNamespace: chain, assets: [asset] }); + first.rules[0].allowedCalls[0].pins![0] = { arg: 0, expected: zeroAddress }; + (first.rules[0].assets![0].token as { address: string }).address = + zeroAddress; + api.list().presets[0].chains.length = 0; + data[0].chains[0].allowedCalls[0].target = zeroAddress; + expect(asset.token.address).toBe(token); + expect(api.get('DEXES').rules[0].allowedCalls[0]).toMatchObject({ + target, + pins: [{ arg: 0, expected: AGENTIC.WALLET }], + }); + expect(api.list().presets[0].chains).toHaveLength(2); +}); +it.each([ + { assets: [{ token }] }, + { chainNamespace: chain, assets: [{ chainNamespace: other, token }] }, + { + chainNamespace: chain, + assets: [{ token }, { token: token.toUpperCase().replace('0X', '0x') }], + }, + { chainNamespace: chain, assets: [{ token, maxPerCall: BigInt(-1) }] }, + { + chainNamespace: chain, + assets: [{ token, maxTotal: BigInt(1) << BigInt(256) }], + }, + { agent: zeroAddress }, + { validUntil: 1 }, + { validUntil: 1.5 }, + { assets: [null] }, +])( + 'rejects malformed or mismatched options before returning templates (case %#)', + (options) => { + expect(() => + buildPresetApi(catalog()).get('DEXES', options as never) + ).toThrow(); + } +); +it('rejects unknown presets, unsupported list types and unsupported chain filters', () => { + const api = buildPresetApi(catalog()); + expect(() => api.get('UNKNOWN' as never)).toThrow(/unknown preset/); + expect(() => api.list({ type: 'APP' } as never)).toThrow(/only CATEGORY/); + expect(() => api.get('DEXES', { chainNamespace: 'eip155:1' })).toThrow( + /no approved entry/ + ); +}); +it('refuses conflicting spenders rather than emitting unrestricted or ambiguous approvals', () => { + const data = catalog(); + data[0].chains[0].approvals = [ + { spender: target, app: 'One' }, + { spender: ADDR.other, app: 'Two' }, + ]; + expect(() => + buildPresetApi(data).get('DEXES', { + chainNamespace: chain, + assets: [{ token }], + }) + ).toThrow(/multiple spenders/); +}); +it('enforces the 32-call ceiling including generated approvals', () => { + const data = catalog(); + data[0].chains[0].allowedCalls = Array.from({ length: 32 }, (_, i) => ({ + target, + selector: `fn${i}()`, + })); + const api = buildPresetApi(data); + expect( + api.get('DEXES', { chainNamespace: chain }).rules[0].allowedCalls + ).toHaveLength(32); + expect(() => + api.get('DEXES', { chainNamespace: chain, assets: [{ token }] }) + ).toThrow(/1..32/); +}); +it('requires full signatures and rejects duplicate function entries', () => { + const data = catalog(); + data[0].chains[0].allowedCalls = [{ target, selector: 'deposit' as never }]; + expect(() => buildPresetApi(data).get('DEXES')).toThrow(/full signatures/); + data[0].chains[0].allowedCalls = [ + { target, selector: 'deposit()' }, + { target, selector: 'deposit()' }, + ]; + expect(() => buildPresetApi(data).get('DEXES')).toThrow(/duplicate target/); +}); +it('cannot bypass current deployment limitations through a preset approval', async () => { + const { rules } = buildPresetApi(catalog()).get('DEXES', { + chainNamespace: chain, + agent, + validUntil: until(), + assets: [{ token, maxPerCall: BigInt(5) }], + }); + await expect( + normalizePublicRule( + publicRules(rules)[0], + ADDR.other, + mockRuntime(new FakeChain()) + ) + ).rejects.toMatchObject({ code: 'CAPABILITY_UNAVAILABLE' }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts b/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts new file mode 100644 index 000000000..10c4b5876 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts @@ -0,0 +1,207 @@ +import { getAddress } from 'viem'; +import { PublicKey } from '@solana/web3.js'; +import { normalizePublicRule, publicRules, WALLET } from '../public-rule'; +import { prepareWalletRules } from '../management/prepare'; +import { currentGeneration, resetAgenticGenerations } from '../deployments'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { Snapshot } from '../reads/snapshot'; +import { UINT256_MAX } from '../codec/defaults'; +import type { + Rule, + NativeMultiRule, + UniversalRule, + SolanaRule, +} from '../agentic.types'; +import { ADDR, FakeChain, registerFakeGeneration } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; +import { compileSvmIdlRule } from '../codec/svm-idl'; +const now = 1_700_000_000; +const wallet = getAddress('0x000000000000000000000000000000000000aaaa'); +const base = (): Rule => ({ + agent: ADDR.agent, + validUntil: now + 100, + allowedCalls: [ + { + target: ADDR.target, + selector: 'transfer(address,uint256)', + pins: [ + { arg: 0, expected: WALLET }, + { arg: 1, max: BigInt(5) }, + ], + }, + ], +}); +const fake = () => new FakeChain(); +async function normalize(rule: Rule) { + return normalizePublicRule( + publicRules([rule])[0], + wallet, + mockRuntime(fake()) + ); +} +beforeEach(registerFakeGeneration); +afterEach(resetAgenticGenerations); +it('normalizes unified native calls into exact EQ/MAX words with zero PC default', async () => { + const out = (await normalize(base())) as NativeMultiRule; + expect(out.actions).toEqual([ + expect.objectContaining({ + maxValuePerCall: BigInt(0), + maxValueTotal: UINT256_MAX, + maxCalls: 0, + pins: [{ offset: 4, expected: `0x${wallet.slice(2).padStart(64, '0')}` }], + amount: { offset: 36, maxPerCall: BigInt(5), maxTotal: UINT256_MAX }, + }), + ]); + const rt = mockRuntime(fake()); + const [p] = await prepareWalletRules( + rt, + currentGeneration(PUSH_NETWORK.TESTNET_DONUT), + ADDR.owner, + wallet, + publicRules([base()]), + await Snapshot.at(rt.reader) + ); + expect(p.nativeTerms?.[0].amount.maxPerCall).toBe(BigInt(5)); +}); +it.each(['actions', 'target', 'amount', 'maxCalls', 'maxValueTotal', 'ref'])( + 'rejects retired top-level %s before signing', + async (field) => { + await expect(normalize({ ...base(), [field]: null })).rejects.toMatchObject( + { code: 'INVALID_RULE' } + ); + } +); +it('rejects two native MAX pins on the deployed policy', async () => { + const r = base(); + r.allowedCalls[0].pins = [ + { arg: 0, max: BigInt(1) }, + { arg: 1, max: BigInt(2) }, + ]; + await expect(normalize(r)).rejects.toMatchObject({ + code: 'CAPABILITY_UNAVAILABLE', + }); +}); +it('compiles the existing EVM WALLET beneficiary and defaults asset total to zero', async () => { + const r = base(); + r.chainNamespace = 'eip155:11155111'; + r.assets = [{ token: ADDR.other, maxPerCall: BigInt(1) }]; + r.allowedCalls[0].pins = [{ arg: 0, expected: WALLET }]; + const out = (await normalize(r)) as UniversalRule; + expect(out.allowedCalls[0]).toMatchObject({ + beneficiaryOffset: 4, + maxValue: BigInt(0), + }); + expect(out.assets[0].maxTotal).toBe(BigInt(0)); +}); +it('rejects unavailable destination MAX pins before contract deployment', async () => { + await expect( + normalize({ ...base(), chainNamespace: 'eip155:11155111' }) + ).rejects.toMatchObject({ code: 'CAPABILITY_UNAVAILABLE' }); +}); +it('refuses layouts whose top-level argument indexes cannot be reconstructed', async () => { + const r = base(); + r.allowedCalls[0] = { + target: ADDR.target, + selector: 'f(uint256[2],uint256)', + pins: [{ arg: 1, max: BigInt(1) }], + }; + await expect(normalize(r)).rejects.toMatchObject({ + code: 'CAPABILITY_UNAVAILABLE', + }); +}); +it('compiles numeric Solana arg/account pins without a caller IDL', async () => { + const program = new PublicKey(Buffer.alloc(32, 3)).toBase58(); + const gateway = new PublicKey(Buffer.alloc(32, 4)).toBase58(); + const rt = mockRuntime(fake(), { + svmMetadata: { + gateway: async (chain) => ({ chainNamespace: chain, program: gateway }), + mint: async () => { + throw Error('unused'); + }, + }, + }); + const rule: Rule = { + agent: ADDR.agent, + chainNamespace: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + validUntil: now + 100, + allowedCalls: [ + { + target: program, + selector: 'receive_sol(u64)', + pins: [ + { account: 0, expected: WALLET }, + { arg: 0, max: BigInt(5) }, + ], + }, + ], + }; + const out = (await normalizePublicRule( + publicRules([rule])[0], + wallet, + rt + )) as SolanaRule; + const cea = `0x${'01'.repeat(32)}` as const; + const terms = compileSvmIdlRule( + out, + { + expectedCEA: cea, + gatewayProgram: `0x${'04'.repeat(32)}`, + assets: [ + { token: ADDR.other, maxPerCall: BigInt(1), maxTotal: BigInt(1) }, + ], + ceaAccounts: [cea], + tokenAccounts: new Map(), + }, + now + ); + expect(terms.programs[0].maxAccounts).toBe(0); + expect(terms.pins).toEqual([ + { ruleIndex: 0, accountIndex: 0, expected: cea }, + ]); + expect(terms.dataPins[0]).toMatchObject({ offset: 8, len: 8, mode: 2 }); +}); + +it('public create rejects missing options asynchronously, and update rejects malformed pairs before reads', async () => { + const { AgenticNamespaceImpl } = await import('../agentic'); + const { AgenticWalletHandle } = await import('../wallet'); + const rt = mockRuntime(fake(), { signer: ADDR.owner }); + const ns = new AgenticNamespaceImpl(rt); + await expect(ns.create('invalid', undefined as never)).rejects.toMatchObject({ + code: 'INVALID_RULE', + }); + const handle = new AgenticWalletHandle(rt, wallet); + for (const params of [ + undefined, + {}, + { rules: [] }, + { rules: [null] }, + { rules: [{ rulesId: '0x', rule: base() }] }, + ]) { + await expect(handle.rules.update(params as never)).rejects.toMatchObject({ + code: 'INVALID_RULE', + }); + } + expect(rt.fake.calls).toEqual([]); + expect(rt.executeMock).not.toHaveBeenCalled(); +}); + +it('does not silently discard a token qualifier on an argument pin', async () => { + const r = base(); + r.allowedCalls[0].pins = [ + { arg: 0, expected: WALLET, token: ADDR.other } as never, + ]; + await expect(normalize(r)).rejects.toMatchObject({ code: 'INVALID_RULE' }); +}); + +it('reads native wallet equality as WALLET and re-encodes its exact word', async () => { + const { getRule } = await import('../reads/rules'); + const { ruleId } = await import('./fake-chain'); + const chain=fake();const w=chain.addWallet(ADDR.owner,'wallet-pin'); + const word=`0x${w.address.slice(2).toLowerCase().padStart(64,'0')}` as const; + w.rules.push(chain.nativeRule(ADDR.agent,ruleId(1),{pins:[{offset:4,expected:word}]})); + const rt=mockRuntime(chain); + const rec=await getRule(await Snapshot.at(chain),currentGeneration(PUSH_NETWORK.TESTNET_DONUT),w.address,ruleId(1),rt.pushChainNamespace); + expect(rec.rule).toMatchObject({allowedCalls:[{pins:[{arg:0,expected:WALLET}]}]}); + const normalized=await normalizePublicRule(publicRules([rec.rule])[0],w.address,rt) as NativeMultiRule; + expect(normalized.actions[0].pins).toEqual([{offset:4,expected:word}]); +}); diff --git a/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts b/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts index 15dc12175..72cf7515b 100644 --- a/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts @@ -35,7 +35,7 @@ describe('generation registry', () => { )) { expect(() => currentGeneration(n)).toThrow( expect.objectContaining({ - code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + code: AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, }) ); } @@ -50,7 +50,7 @@ describe('generation registry', () => { ADDR.target ) ).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + code: AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, }); }); }); @@ -164,7 +164,7 @@ describe('initialize with agenticWallet — door resolution', () => { w.address ) ).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + code: AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, }); }); }); @@ -293,12 +293,12 @@ describe('rules.list / rules.get', () => { afterEach(() => resetAgenticGenerations()); const gen = () => currentGeneration(PUSH_NETWORK.TESTNET_DONUT); - it('decodes active native records without exposing internal spend', async () => { + it('decodes active native records with spend', async () => { const w = fake.addWallet(ADDR.owner, 'w', [ fake.nativeRule(ADDR.agent, ruleId(1), { callsUsed: 3, valueSpent: BigInt(5), - maxCalls: 9, + maxCalls: 0, }), ]); const rules = await listRules( @@ -313,12 +313,15 @@ describe('rules.list / rules.get', () => { enabled: true, chainNamespace: PUSH_NS, agent: ADDR.agent, - rule: expect.objectContaining({ maxCalls: 9, selector: '0xd09de08a' }), + rule: expect.objectContaining({ + allowedCalls: [expect.objectContaining({ selector: '0xd09de08a' })], + }), + spent: expect.objectContaining({ valueSpent: BigInt(5) }), }), ]); }); - it('public records contain no spent field', async () => { + it('public records contain native spend', async () => { const w = fake.addWallet(ADDR.owner, 'w', [ fake.nativeRule(ADDR.agent, ruleId(1)), ]); @@ -329,7 +332,12 @@ describe('rules.list / rules.get', () => { ruleId(1), PUSH_NS ); - expect(record).not.toHaveProperty('spent'); + expect(record.spent).toMatchObject({ + kind: 'native', + valueSpent: BigInt(0), + amountSpent: BigInt(0), + callsUsed: 0, + }); }); it('lists native and universal rules without truncating', async () => { @@ -466,11 +474,19 @@ describe('wallet reads', () => { const w = fake.addWallet(ADDR.owner, 'original'); w.label = 'renamed'; fake.extraLogs = []; - expect(await walletInfo(fake, gen(), w.address)).toMatchObject({ label: 'renamed' }); - expect((await listForOwner(fake, gen(), ADDR.owner))[0].label).toBe('renamed'); - expect(fake.calls.filter((c) => c.functionName === 'label')).toHaveLength(2); + expect(await walletInfo(fake, gen(), w.address)).toMatchObject({ + label: 'renamed', + }); + expect((await listForOwner(fake, gen(), ADDR.owner))[0].label).toBe( + 'renamed' + ); + expect(fake.calls.filter((c) => c.functionName === 'label')).toHaveLength( + 2 + ); w.label = ''; - expect(await walletInfo(fake, gen(), w.address)).toMatchObject({ label: 'AGW 1' }); + expect(await walletInfo(fake, gen(), w.address)).toMatchObject({ + label: 'AGW 1', + }); }); it('info of the signer’s next slot describes it as undeployed; other undeployed addresses are refused', async () => { @@ -536,6 +552,46 @@ describe('checkpoints', () => { ); }); + it('uses retained logs only when a complete sequence proves no checkpoint was pruned', async () => { + const w = fake.addWallet(ADDR.owner, 'fresh'); + w.checkpointCount = BigInt(2); + fake.checkpointLogs = [ + checkpointLog(w.address, 1, 1, BigInt(60)), + checkpointLog(w.address, 2, 2, BigInt(70)), + ]; + const original = fake.getLogs.bind(fake); + jest.spyOn(fake, 'getLogs').mockImplementation(async (args) => { + if (args.fromBlock < BigInt(50)) + throw new Error('height 10 is not available, lowest height is 50'); + return original(args); + }); + const result = await readCheckpoints(fake, gen(), w.address); + expect(result.checkpoints.map((c) => c.seq)).toEqual([1, 2]); + fake.checkpointLogs = [checkpointLog(w.address, 2, 2, BigInt(70))]; + await expect(readCheckpoints(fake, gen(), w.address)).rejects.toMatchObject( + { code: AGENTIC_ERROR_CODE.INCONSISTENT_READ } + ); + }); + + it('does not skip arbitrary RPC errors or a pruned explicit sinceBlock range', async () => { + const w = fake.addWallet(ADDR.owner, 'history'); + w.checkpointCount = BigInt(1); + const logs = jest + .spyOn(fake, 'getLogs') + .mockRejectedValue(new Error('temporary RPC failure')); + await expect(readCheckpoints(fake, gen(), w.address)).rejects.toMatchObject( + { code: AGENTIC_ERROR_CODE.RULE_READ_FAILED } + ); + expect(logs).toHaveBeenCalledTimes(1); + logs.mockRejectedValue( + new Error('height 10 is not available, lowest height is 50') + ); + await expect( + readCheckpoints(fake, gen(), w.address, BigInt(20)) + ).rejects.toMatchObject({ code: AGENTIC_ERROR_CODE.RULE_READ_FAILED }); + expect(logs).toHaveBeenCalledTimes(2); + }); + it('validateSequence checks contiguity and the count', () => { expect(validateSequence([1, 2, 3], BigInt(3), true)).toBeNull(); expect(validateSequence([1, 3], BigInt(3), true)).toMatch(/gap/); diff --git a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts index 3b508a491..e4550ae4a 100644 --- a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts @@ -225,30 +225,14 @@ describe('public surface', () => { expect(new AgenticRevertError('x', { cause: 'c' }).cause).toBe('c'); }); - it('CONSTANTS.AGENTIC carries the checked Donut v5 proxies', () => { - expect(CONSTANTS.AGENTIC.TESTNET_DONUT).toMatchObject({ - FACTORY: '0x8137F96A50EBF41d904e3678c84c391a0D1BCcc5', - RULES_POLICY: '0x603E7f0aF6e1aAFf46DDfb28b1e99364f8BC59af', - ENVELOPE_VERSION: 1, - }); + it('exposes the wallet marker and preset API, keeping deployment wiring and codecs internal', () => { + expect(CONSTANTS.AGENTIC.WALLET).toBe('agentic:wallet'); + expect(Object.keys(CONSTANTS.AGENTIC).sort()).toEqual([ + 'PRESETS', + 'WALLET', + ]); expect(Object.isFrozen(CONSTANTS.AGENTIC)).toBe(true); - }); - - it('PushChain.utils.agentic is the pure helper set', () => { - expect(Object.keys(Utils.agentic).sort()).toEqual( - ['actionId', 'configId', 'decodeRules'].sort() - ); - // @ts-expect-error generation context helpers are not public - expect(Utils.agentic.rulesId).toBeUndefined(); - // @ts-expect-error the marketplace compiler is outside AGW - expect(Utils.agentic.compileCard).toBeUndefined(); - for (const name of [ - 'rulesId', - 'deriveWallet', - 'encodeRules', - 'compileCard', - ]) { - expect(Utils.agentic).not.toHaveProperty(name); - } + expect(Object.keys(Utils.agentic)).toEqual(['presets']); + expect(Object.keys(Utils.agentic.presets).sort()).toEqual(['get', 'list']); }); }); diff --git a/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts b/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts index fe3013760..8127cfc8c 100644 --- a/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts @@ -9,7 +9,11 @@ import { validateUniversalRuleShape } from '../codec/universal'; import { V5_CAPABILITIES } from '../deployments'; import { AGENTIC_ERROR_CODE } from '../errors'; import { internalAgenticUtils } from '../utils'; -import type { NativeRule, UniversalRule } from '../agentic.types'; +import type { + NativeRule, + NativeSingleRule, + UniversalRule, +} from '../agentic.types'; import { ADDR, PUSH_NS, SEPOLIA_NS } from './fake-chain'; const NOW = 1_700_000_000; @@ -28,7 +32,7 @@ const ctx = { ADDR.gateway, ], }; -const native = (over: Partial = {}): NativeRule => ({ +const native = (over: Partial = {}): NativeSingleRule => ({ agent: ADDR.agent, target: ADDR.target, selector: 'increment()', @@ -70,7 +74,7 @@ describe('prepareRules — spec 1.b checks before any signature', () => { ); expect(prepared).toHaveLength(2); expect(prepared.map((p) => p.agent)).toEqual([ADDR.agent, ADDR.agent]); - expect(prepared.map((p) => p.nativeTerms?.target)).toEqual([ + expect(prepared.map((p) => p.nativeTerms?.[0].target)).toEqual([ ADDR.target, ADDR.other, ]); @@ -104,8 +108,11 @@ describe('prepareRules — spec 1.b checks before any signature', () => { }); it('removed ref inputs fail explicitly instead of being silently dropped', () => { - expect(() => prepareRules([{ ...native(), ref: `0x${'99'.repeat(32)}` } as never], ctx)) - .toThrow(expect.objectContaining({ code: AGENTIC_ERROR_CODE.INVALID_RULE })); + expect(() => + prepareRules([{ ...native(), ref: `0x${'99'.repeat(32)}` } as never], ctx) + ).toThrow( + expect.objectContaining({ code: AGENTIC_ERROR_CODE.INVALID_RULE }) + ); }); it('refuses infrastructure targets the contracts would reject', () => { diff --git a/packages/core/src/lib/agentic/__tests__/send.spec.ts b/packages/core/src/lib/agentic/__tests__/send.spec.ts index 5291fac1d..a9b567cfc 100644 --- a/packages/core/src/lib/agentic/__tests__/send.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/send.spec.ts @@ -350,6 +350,38 @@ describe('EVM outbound composition from the wallet', () => { : {}), }); + it.each([ + [false, undefined, BigInt(2_000_000)], + [true, undefined, BigInt(0)], + [false, BigInt(123456), BigInt(123456)], + [true, BigInt(123456), BigInt(123456)], + [false, BigInt(0), BigInt(0)], + ])( + 'destination gas: deployed=%s explicit=%s quotes %s', + async (isDeployed, gasLimit, expected) => { + const s = universalSetup(); + const quoteOutbound = jest.fn(async (_token: Address, _gas: bigint) => ({ + protocolFee: BigInt(100), + nativeValueForGas: BigInt(1000), + gasLimitUsed: BigInt(200_000), + })); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: () => token, + resolveCEA: async () => ({ + cea: getAddress('0x000000000000000000000000000000000000cea1'), + isDeployed, + }), + quoteOutbound, + }); + await agenticSend(rt, s.ctx, { + ...send(BigInt(100)), + ...(gasLimit === undefined ? {} : { gasLimit }), + }); + expect(quoteOutbound.mock.calls[0][1]).toBe(expected); + } + ); + it('builds the policy-compatible request: empty recipient, wallet refund, nonzero gas cap, multicall payload, CEA of the wallet', async () => { const s = universalSetup(); const resolveCEA = jest.fn(async () => ({ diff --git a/packages/core/src/lib/agentic/agentic.ts b/packages/core/src/lib/agentic/agentic.ts index e15344c0a..e02699242 100644 --- a/packages/core/src/lib/agentic/agentic.ts +++ b/packages/core/src/lib/agentic/agentic.ts @@ -1,3 +1,4 @@ +import { publicRules } from './public-rule'; import { AgenticCapability, requireCapability } from './capabilities'; import { currentGeneration } from './deployments'; import { createWallet } from './management/create'; @@ -23,23 +24,48 @@ import type { export class AgenticNamespaceImpl implements AgenticNamespace { constructor(private readonly runtime: AgenticRuntime) {} - async derive(opts?: { index?: number }): Promise<{ address: AgenticAddress; index: number; deployed: boolean }> { + async derive(opts?: { + index?: number; + }): Promise<{ address: AgenticAddress; index: number; deployed: boolean }> { const gen = currentGeneration(this.runtime.network); requireCapability(gen.capabilities, AgenticCapability.WALLET_READS); const owner = this.runtime.signerPushAccount(); - const r = await deriveForOwner(this.runtime.reader, gen, owner, opts?.index); - emitAgentic(this.runtime, undefined, PROGRESS_HOOK.AGENTIC_TX_101, r.address, r.index, r.deployed); - return { address: r.address as AgenticAddress, index: r.index, deployed: r.deployed }; + const r = await deriveForOwner( + this.runtime.reader, + gen, + owner, + opts?.index + ); + emitAgentic( + this.runtime, + undefined, + PROGRESS_HOOK.AGENTIC_TX_101, + r.address, + r.index, + r.deployed + ); + return { + address: r.address as AgenticAddress, + index: r.index, + deployed: r.deployed, + }; } - create(label: string, options: CreateOptions): Promise { - return createWallet(this.runtime, label, options); + async create(label: string, options: CreateOptions): Promise { + return createWallet(this.runtime, label, { + ...options, + rules: publicRules(options?.rules), + }); } async list(): Promise<{ wallets: WalletSummary[] }> { const gen = currentGeneration(this.runtime.network); requireCapability(gen.capabilities, AgenticCapability.WALLET_READS); - const wallets = await listForOwner(this.runtime.reader, gen, this.runtime.signerPushAccount()); + const wallets = await listForOwner( + this.runtime.reader, + gen, + this.runtime.signerPushAccount() + ); return { wallets }; } diff --git a/packages/core/src/lib/agentic/agentic.types.ts b/packages/core/src/lib/agentic/agentic.types.ts index a0d1b6b22..6dcc6e9a8 100644 --- a/packages/core/src/lib/agentic/agentic.types.ts +++ b/packages/core/src/lib/agentic/agentic.types.ts @@ -1,8 +1,5 @@ -/** - * Public AGW types — AGW SDK page section 3 (Notion export 2026-10-03 13:23 IST). - * - * Named-IDL Solana inputs and native/EVM raw offsets follow the October 7 - * decisions. Contract-dependent label editing remains capability-gated. +/** Public section-6 rules and internal adapters for the deployed v5 wire format. + * Source snapshot: October 12, 2026. Internal normalized types are not package exports. */ import type { Idl } from '@coral-xyz/anchor'; import type { SvmTermsWire } from './codec/svm-terms'; @@ -18,15 +15,10 @@ export type AgenticProgressHook = (event: ProgressEvent) => void; /** '0x617ba037' or 'supply(address,uint256,address,uint16)'. */ export type Selector = AgenticHex | `${string}(${string})`; -export interface NativeRule { - /** Push address: an EOA, or the UEA of an external key. Never the owner. */ - agent: AgenticAddress; - /** Omitted = the connected Push chain. */ - chainNamespace?: never; +/** One Push function a native rule allows, with its own limits and counters. */ +export interface NativeAction { target: AgenticAddress; selector: Selector | 'value-only'; - /** Unix seconds. */ - validUntil: number; /** Default 0: no native PC value allowance. */ maxValuePerCall?: bigint; /** Default 0: no native PC value allowance. */ @@ -36,8 +28,48 @@ export interface NativeRule { pins?: ArgPin[]; /** `maxTotal` default uint256 max (unlimited total). */ amount?: AmountLimit; + /** + * Overrides the rule's `validUntil` for this action. Decoded rules set it + * only when stored actions expire at different times. + */ + validUntil?: number; +} + +interface NativeRuleCommon { + /** Push address: an EOA, or the UEA of an external key. Never the owner. */ + agent: AgenticAddress; + /** Omitted = the connected Push chain. */ + chainNamespace?: never; + /** Unix seconds. */ + validUntil: number; +} + +/** A native rule allowing one function (shorthand for a one-entry `actions`). */ +export interface NativeSingleRule + extends NativeRuleCommon, + Omit { + actions?: never; +} + +/** + * A native rule allowing 1..8 functions under ONE rule ID. Each action keeps + * its own limits and counters on-chain; (target, selector) pairs must be + * unique. One rule per agent on Push keeps send-time selection unambiguous. + * Internal normalized representation; public callers use Rule.allowedCalls. + */ +export interface NativeMultiRule extends NativeRuleCommon { + actions: NativeAction[]; + target?: never; + selector?: never; + maxValuePerCall?: never; + maxValueTotal?: never; + maxCalls?: never; + pins?: never; + amount?: never; } +export type NativeRule = NativeSingleRule | NativeMultiRule; + /** * Where a pin or amount sits in the calldata. `arg` is the argument index in * the selector signature (requires the signature form of `selector`). @@ -65,7 +97,7 @@ export interface AssetCap { /** The destination chain's token (MOVEABLE constant) or native marker. */ token: MoveableToken | AgenticAddress; maxPerCall: bigint; - /** Omitted = uint256 maximum (unlimited). Explicit zero forbids movement. */ + /** Omitted = zero. Explicit zero forbids movement. */ maxTotal?: bigint; } @@ -106,6 +138,8 @@ export type SvmFieldConstraint = minRatio: { num: bigint; den: bigint }; }; export interface SvmInstructionRule { + /** Internal wire override: 0 means no exact account-count constraint. */ + maxAccounts?: number; program: string; instruction: { idl: Idl; name: string }; /** Top-level account names from a supported Anchor IDL; optional/nested accounts are rejected. */ @@ -127,26 +161,92 @@ export interface SolanaRule { outputTokens?: string[]; allowedInstructions: SvmInstructionRule[]; } -/** Stored constraints contain no IDL; supply a named SolanaRule when replacing. */ +/** Lossless stored Solana layout, accepted by update without an invented IDL. */ export interface DecodedSolanaRule extends Omit { format: 'decoded'; agent: AgenticAddress; chainNamespace: `solana:${string}`; assets: { token: string; maxPerCall: bigint; maxTotal: bigint }[]; } -export type Rule = NativeRule | UniversalRule | SolanaRule; +export type InternalRule = NativeRule | UniversalRule | SolanaRule; -export interface RulesRecord { +export interface InternalRulesRecord { rulesId: AgenticHex; enabled: boolean; chainNamespace: string; agent: AgenticAddress; validUntil: number; - rule: Rule | DecodedSolanaRule; + rule: InternalRule | DecodedSolanaRule; +} + +/** Current SDK section 6 public rule shape; contract wire types above are internal. */ +export type WalletMarker = 'agentic:wallet'; +export type Pin = + | { + arg: number; + expected: AgenticHex | AgenticAddress | bigint | WalletMarker; + max?: never; + account?: never; + } + | { arg: number; max: bigint; expected?: never; account?: never } + | { + account: number; + expected: string | WalletMarker; + token?: MoveableToken | string; + arg?: never; + max?: never; + }; +export interface RuleCall { + target: string; + selector: Selector | 'value-only'; + pins?: Pin[]; + maxValue?: bigint; + app?: string; + label?: string; +} +export interface RuleAsset { + token: MoveableToken | string; + maxPerCall: bigint; + maxTotal?: bigint; +} +export interface Rule { + agent: AgenticAddress; + chainNamespace?: ForeignChainNamespace; + validUntil: number; + assets?: RuleAsset[]; + maxGasPerCall?: bigint; + allowedCalls: RuleCall[]; +} +export type RuleInput = Rule | DecodedSolanaRule; +export type Spent = + | { + kind: 'native'; + valueSpent: bigint; + amountSpent: bigint; + callsUsed: number; + } + | { + kind: 'native'; + actions: { + target: AgenticAddress; + selector: Selector | 'value-only'; + valueSpent: bigint; + amountSpent: bigint; + callsUsed: number; + }[]; + } + | { kind: 'universal'; assets: { token: string; spent: bigint }[] }; +export interface RulesRecord extends Omit { + rule: RuleInput; + spent: Spent; +} +export interface InternalCreateOptions { + rules: InternalRule[]; + progressHook?: AgenticProgressHook; } export interface CreateOptions { - rules: Rule[]; + rules: RuleInput[]; progressHook?: AgenticProgressHook; } @@ -197,26 +297,26 @@ export interface AgenticWallet { sinceBlock?: bigint; }): Promise<{ checkpoints: Checkpoint[] }>; rules: { - /** Enabled rules only; expired-but-enabled rules remain visible. No public spend counters. */ + /** Enabled rules only; expired-but-enabled rules remain visible. Includes policy spend counters. */ list(): Promise<{ rules: RulesRecord[] }>; /** Unknown and revoked IDs both return RULE_NOT_FOUND; v1 has no historical records. */ get(rulesId: AgenticHex): Promise; add( - rules: Rule[], + rules: RuleInput[], opts?: { progressHook?: AgenticProgressHook } ): Promise<{ rulesIds: AgenticHex[]; tx: UniversalTxResponse }>; update(params: { - rules: { rulesId: AgenticHex; rule: Rule }[]; + rules: { rulesId: AgenticHex; rule: RuleInput }[]; progressHook?: AgenticProgressHook; }): Promise<{ rules: { rulesId: AgenticHex; replaced: AgenticHex }[]; tx: UniversalTxResponse; }>; - revoke( + delete( rulesIds: AgenticHex[], opts?: { progressHook?: AgenticProgressHook } ): Promise; - revoke(params: { + delete(params: { all: true; progressHook?: AgenticProgressHook; }): Promise; diff --git a/packages/core/src/lib/agentic/capabilities.ts b/packages/core/src/lib/agentic/capabilities.ts index 6e481d075..0f6cecf06 100644 --- a/packages/core/src/lib/agentic/capabilities.ts +++ b/packages/core/src/lib/agentic/capabilities.ts @@ -27,7 +27,7 @@ export enum AgenticCapability { UNIVERSAL_EVM_RULES = 'universalEvmRules', /** Per-token expected-spend assertion for universal replacement. A05. */ ASSERT_SPENT_UNIVERSAL_MULTI = 'assertSpentUniversalMulti', - /** Named-IDL Solana authoring, exact decoded reads and instruction execution. */ + /** Numeric-pin Solana authoring, exact decoded reads and instruction execution. */ UNIVERSAL_SVM_RULES = 'universalSvmRules', /** Editable wallet label (setLabel / LabelSet). A07. */ SET_LABEL = 'setLabel', diff --git a/packages/core/src/lib/agentic/codec/defaults.ts b/packages/core/src/lib/agentic/codec/defaults.ts index cc652c461..a5fc885b0 100644 --- a/packages/core/src/lib/agentic/codec/defaults.ts +++ b/packages/core/src/lib/agentic/codec/defaults.ts @@ -39,9 +39,9 @@ export const AGENTIC_DEFAULTS = { }, universal: { assetMaxTotal: { - value: UINT256_MAX, - meaning: 'unlimited total; explicit zero forbids movement', - source: 'v5', + value: BigInt(0), + meaning: 'omitted or zero forbids token movement', + source: 'product', }, allowedCallMaxValue: { value: BigInt(0), diff --git a/packages/core/src/lib/agentic/codec/native.ts b/packages/core/src/lib/agentic/codec/native.ts index b11976853..570131617 100644 --- a/packages/core/src/lib/agentic/codec/native.ts +++ b/packages/core/src/lib/agentic/codec/native.ts @@ -9,7 +9,12 @@ import { type Hex, } from 'viem'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; -import type { AgenticHex, NativeRule } from '../agentic.types'; +import type { + AgenticHex, + NativeAction, + NativeRule, + NativeSingleRule, +} from '../agentic.types'; import { argumentOffset, encodeArgWord, rawOffset } from './abi-layout'; import { AGENTIC_DEFAULTS, UINT256_MAX, withDefault } from './defaults'; import { @@ -20,6 +25,7 @@ import { /** URP native limits at v5 (Types.sol MAX_PINS, AGW MAX_NATIVE_ACTIONS). */ export const MAX_PINS = 8; +export const MAX_NATIVE_ACTIONS = 8; const UINT32_MAX = 2 ** 32 - 1; const UINT48_MAX = 2 ** 48 - 1; @@ -94,10 +100,135 @@ export interface NativeEncodeContext { nowSeconds: number; } -/** Normalize and validate a public NativeRule into wire terms. Pure. */ +const SINGLE_ACTION_FIELDS = [ + 'target', + 'selector', + 'maxValuePerCall', + 'maxValueTotal', + 'maxCalls', + 'pins', + 'amount', +] as const; + +/** + * The actions a native rule allows: the single-action shorthand, or the + * explicit `actions` list (1..8). Each carries its effective `validUntil`. + */ +export function nativeActionsOf( + rule: NativeRule +): (NativeAction & { validUntil: number })[] { + if ( + !Number.isInteger(rule.validUntil) || + rule.validUntil < 1 || + rule.validUntil > UINT48_MAX + ) { + throw invalid( + 'rule validUntil must be a positive unix timestamp in seconds (uint48)' + ); + } + if (rule.actions !== undefined) { + if ( + !Array.isArray(rule.actions) || + rule.actions.length < 1 || + rule.actions.length > MAX_NATIVE_ACTIONS + ) { + throw invalid( + `actions must contain 1..${MAX_NATIVE_ACTIONS} entries (the AGW native action limit)` + ); + } + const mixed = SINGLE_ACTION_FIELDS.filter( + (f) => (rule as unknown as Record)[f] !== undefined + ); + if (mixed.length > 0) { + throw invalid( + `actions cannot be combined with top-level ${mixed.join( + ', ' + )}; put them inside each action` + ); + } + return rule.actions.map((a, i) => { + if (!a || typeof a !== 'object' || Array.isArray(a)) { + throw invalid(`actions[${i}] must be an action object`, { action: i }); + } + return { ...a, validUntil: a.validUntil ?? rule.validUntil }; + }); + } + const single = rule as NativeSingleRule; + return [ + { + target: single.target, + selector: single.selector, + maxValuePerCall: single.maxValuePerCall, + maxValueTotal: single.maxValueTotal, + maxCalls: single.maxCalls, + pins: single.pins, + amount: single.amount, + validUntil: single.validUntil, + }, + ]; +} + +/** + * Normalize and validate every action of a native rule into wire terms, in + * input order. (target, selector) pairs must be unique — the wallet refuses + * duplicates (DuplicateAction) because they share one action ID. Pure. + */ +export function nativeRuleToActionTerms( + rule: NativeRule, + ctx: NativeEncodeContext +): { terms: NativeTermsWire; parsed: ParsedSelector }[] { + if (rule.validUntil <= ctx.nowSeconds) { + throw invalid( + 'rule validUntil must be a future unix timestamp in seconds (uint48)' + ); + } + const actions = nativeActionsOf(rule); + const multi = rule.actions !== undefined; + const out = actions.map((action, i) => { + try { + return nativeActionToTerms(action, ctx); + } catch (err) { + if (multi && err instanceof AgenticError) { + throw new AgenticError(err.code, `actions[${i}]: ${err.message}`, { + hint: err.hint, + details: { ...(err.details ?? {}), action: i }, + cause: err, + }); + } + throw err; + } + }); + const seen = new Map(); + out.forEach(({ terms }, i) => { + const key = `${terms.target.toLowerCase()}:${terms.selector}`; + const first = seen.get(key); + if (first !== undefined) { + throw invalid( + `actions[${i}] repeats the target and selector of actions[${first}]`, + { action: i } + ); + } + seen.set(key, i); + }); + return out; +} + +/** Single-action convenience; multi-action rules use nativeRuleToActionTerms. */ export function nativeRuleToTerms( rule: NativeRule, ctx: NativeEncodeContext +): { terms: NativeTermsWire; parsed: ParsedSelector } { + const all = nativeRuleToActionTerms(rule, ctx); + if (all.length !== 1) { + throw invalid('nativeRuleToTerms takes a single-action rule'); + } + return all[0]; +} + +/** Normalize and validate one native action into wire terms. Pure. */ +export function nativeActionToTerms( + rule: NativeAction & { validUntil: number }, + ctx: NativeEncodeContext ): { terms: NativeTermsWire; parsed: ParsedSelector } { if ( !isAddress(rule.target, { strict: false }) || @@ -244,8 +375,8 @@ export function decodeNativeTerms(body: Hex): NativeTermsWire { export function nativeTermsToRule( terms: NativeTermsWire, agent: Address -): NativeRule { - const rule: NativeRule = { +): NativeSingleRule { + const rule: NativeSingleRule = { agent: getAddress(agent), target: getAddress(terms.target), selector: @@ -273,6 +404,37 @@ export function nativeTermsToRule( return rule; } +/** + * Stored action terms back to a public NativeRule: the single-action + * shorthand for one action, otherwise `actions` in stored order. The rule's + * `validUntil` is the earliest action expiry; an action expiring later keeps + * its own `validUntil`, so the result re-encodes to identical terms. + */ +export function nativeActionTermsToRule( + termsList: readonly NativeTermsWire[], + agent: Address +): NativeRule { + if (termsList.length === 0) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'a native rule needs at least one action' + ); + } + if (termsList.length === 1) return nativeTermsToRule(termsList[0], agent); + const decoded = termsList.map((t) => nativeTermsToRule(t, agent)); + const validUntil = Math.min(...decoded.map((d) => d.validUntil)); + return { + agent: getAddress(agent), + validUntil, + actions: decoded.map( + ({ agent: _agent, validUntil: actionValidUntil, ...action }) => + actionValidUntil === validUntil + ? action + : { ...action, validUntil: actionValidUntil } + ), + }; +} + export function isUnlimited(value: bigint): boolean { return value === UINT256_MAX; } diff --git a/packages/core/src/lib/agentic/codec/rules.ts b/packages/core/src/lib/agentic/codec/rules.ts index 0d5364b82..3b9988b05 100644 --- a/packages/core/src/lib/agentic/codec/rules.ts +++ b/packages/core/src/lib/agentic/codec/rules.ts @@ -10,13 +10,13 @@ import { AgenticCapability, requireCapability } from '../capabilities'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; import type { NativeRule, - Rule, + InternalRule, UniversalRule, SolanaRule, } from '../agentic.types'; import { encodeNativeTerms, - nativeRuleToTerms, + nativeRuleToActionTerms, type NativeTermsWire, } from './native'; import { buildSession, encodeEnvelope, type SessionWire } from './session'; @@ -25,21 +25,24 @@ import { encodeSvmTerms, type SvmTermsWire } from './svm-terms'; import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; import type { UniversalTermsWire } from './universal-terms'; -export function isSolanaRule(rule: Rule): rule is SolanaRule { +export function isSolanaRule(rule: InternalRule): rule is SolanaRule { return ( typeof (rule as SolanaRule).chainNamespace === 'string' && (rule as SolanaRule).chainNamespace.startsWith('solana:') ); } -export function isUniversalRule(rule: Rule): rule is UniversalRule { +export function isUniversalRule(rule: InternalRule): rule is UniversalRule { return ( (rule as UniversalRule).chainNamespace !== undefined && !isSolanaRule(rule) ); } /** CAIP-2 chain a rule governs: omitted = the connected Push chain. */ -export function ruleChain(rule: Rule, pushChainNamespace: string): string { +export function ruleChain( + rule: InternalRule, + pushChainNamespace: string +): string { return ( (rule as UniversalRule | SolanaRule).chainNamespace ?? pushChainNamespace ); @@ -68,19 +71,19 @@ export interface RuleEncodeContext { } export interface PreparedRule { - rule: Rule; + rule: InternalRule; agent: Address; chainNamespace: string; session: SessionWire; - /** Native terms when the rule is native (used for previews and assertions). */ - nativeTerms?: NativeTermsWire; + /** Native terms, one per action in input order, when the rule is native. */ + nativeTerms?: NativeTermsWire[]; universalTerms?: UniversalTermsWire; svmTerms?: SvmTermsWire; } /** Validate before signing. V5 permits several grants for the same agent/chain. */ export function prepareRules( - rules: readonly Rule[], + rules: readonly InternalRule[], ctx: RuleEncodeContext & { /** Wallet owner; when given, an agent equal to it is AGENT_IS_OWNER. */ owner?: Address; @@ -128,7 +131,7 @@ export function prepareRules( if ('ref' in rule) { throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, - 'Rule ref is not supported; remove it from the input' + 'InternalRule ref is not supported; remove it from the input' ); } @@ -141,7 +144,7 @@ export function prepareRules( if (!terms || !ctx.gateway) throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, - 'SVM rules require resolved wallet context and named IDL inputs' + 'SVM rules require resolved wallet context and validated instruction layouts' ); return { rule, @@ -169,30 +172,36 @@ export function prepareRules( return encodeUniversalRule(rule, ctx); } requireCapability(ctx.capabilities, AgenticCapability.NATIVE_RULES); - const { terms } = nativeRuleToTerms(rule as NativeRule, ctx); + const actions = nativeRuleToActionTerms(rule as NativeRule, ctx).map( + (a) => a.terms + ); const forbidden = [ '0x0000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000001', ...(ctx.forbiddenTargets ?? []), ].map((a) => a.toLowerCase()); - if (forbidden.includes(terms.target.toLowerCase())) { - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - `rules[${i}].target ${terms.target} is a wallet-infrastructure address the contracts refuse as a native target` - ); - } + actions.forEach((terms, j) => { + if (forbidden.includes(terms.target.toLowerCase())) { + const where = + actions.length > 1 ? `rules[${i}].actions[${j}]` : `rules[${i}]`; + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `${where}.target ${terms.target} is a wallet-infrastructure address the contracts refuse as a native target` + ); + } + }); + // One rule, one rule ID: every action is a separate (target, selector) + // entry of the same session, each with its own stored terms. const session = buildSession({ validator: ctx.validator, agent, rulesPolicy: ctx.rulesPolicy, - actions: [ - { - target: terms.target, - selector: terms.selector, - initData: encodeEnvelope(chainNamespace, encodeNativeTerms(terms)), - }, - ], + actions: actions.map((terms) => ({ + target: terms.target, + selector: terms.selector, + initData: encodeEnvelope(chainNamespace, encodeNativeTerms(terms)), + })), }); - return { rule, agent, chainNamespace, session, nativeTerms: terms }; + return { rule, agent, chainNamespace, session, nativeTerms: actions }; }); } diff --git a/packages/core/src/lib/agentic/codec/svm-idl.ts b/packages/core/src/lib/agentic/codec/svm-idl.ts index 4e7f22f44..9ed41f35d 100644 --- a/packages/core/src/lib/agentic/codec/svm-idl.ts +++ b/packages/core/src/lib/agentic/codec/svm-idl.ts @@ -167,7 +167,7 @@ export function compileSvmIdlRule( ), discriminatorLen: ix.discriminator.length, dataless: false, - maxAccounts: names.length, + maxAccounts: entry.maxAccounts ?? names.length, }); for (const a of entry.accounts) { const accountIndex = names.indexOf(a.name); diff --git a/packages/core/src/lib/agentic/constants.ts b/packages/core/src/lib/agentic/constants.ts index f2f7d24b2..1b3640a20 100644 --- a/packages/core/src/lib/agentic/constants.ts +++ b/packages/core/src/lib/agentic/constants.ts @@ -1,23 +1,11 @@ -import { PUSH_NETWORK } from '../constants/enums'; -import { DONUT_V5_ADDRESSES } from './contracts/donut-v5'; - -/** Checked Donut v5 proxies. Unsupported networks are absent. */ -export interface AgenticNetworkConstants { - FACTORY: `0x${string}`; - RULES_POLICY: `0x${string}`; - MAX_PINS: number; - MAX_ALLOWED_CALLS: number; - ENVELOPE_VERSION: number; -} - -export const AGENTIC: Readonly< - Partial> -> = Object.freeze({ - [PUSH_NETWORK.TESTNET_DONUT]: Object.freeze({ - FACTORY: DONUT_V5_ADDRESSES.factory, - RULES_POLICY: DONUT_V5_ADDRESSES.rulesPolicy, - MAX_PINS: 8, - MAX_ALLOWED_CALLS: 32, - ENVELOPE_VERSION: 1, +/** Public section-7 discovery constants; deployment wiring stays internal. */ +export const AGENTIC = Object.freeze({ + WALLET: 'agentic:wallet' as const, + PRESETS: Object.freeze({ + CATEGORY: Object.freeze({ + DEXES: 'DEXES' as const, + LENDING: 'LENDING' as const, + PREDICTION: 'PREDICTION' as const, + }), }), }); diff --git a/packages/core/src/lib/agentic/deployments.ts b/packages/core/src/lib/agentic/deployments.ts index 421a0a025..e9a9f84eb 100644 --- a/packages/core/src/lib/agentic/deployments.ts +++ b/packages/core/src/lib/agentic/deployments.ts @@ -87,7 +87,7 @@ export function registerAgenticGeneration( ): AgenticGeneration { if (input.id !== v5.id || input.sourceCommit !== V5_SOURCE_COMMIT) { throw new AgenticError( - AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `no contract adapter for generation "${input.id}"` ); } @@ -132,7 +132,7 @@ export async function verifyPolicyVersion( }); if (version !== '3.1.0') throw new AgenticError( - AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `URP version ${String(version)} is not supported by the v5 adapter` ); } @@ -149,7 +149,7 @@ export function currentGeneration(network: PUSH_NETWORK): AgenticGeneration { const gen = gens[gens.length - 1]; if (!gen) { throw new AgenticError( - AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `no verified AGW deployment is registered for ${network}`, { hint: 'AGW v5 is configured on Donut only; this Push network has no registered v5 deployment.', @@ -194,7 +194,7 @@ export function verifyFactoryWiring( })) as Address; if (getAddress(impl) !== gen.addresses.walletImplementation) { throw new AgenticError( - AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `factory ${gen.addresses.factory} points at implementation ${impl}, not the registered ${gen.addresses.walletImplementation}` ); } @@ -216,7 +216,7 @@ export async function resolveWalletGeneration( wallet: Address ): Promise { const gens = generationsFor(network); - if (gens.length === 0) currentGeneration(network); // throws GENERATION_UNSUPPORTED (A07) + if (gens.length === 0) currentGeneration(network); // throws CAPABILITY_UNAVAILABLE (A07) const code = await reader.getCode({ address: wallet }); if (!code || code === '0x') { @@ -247,7 +247,7 @@ export async function resolveWalletGeneration( const gen = gens.find((g) => g.addresses.factory === factory); if (!gen) { throw new AgenticError( - AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `${wallet} was created by factory ${factory}, which is not a verified AGW generation on ${network}`, { details: { factory, assumption: 'A07' } } ); @@ -289,7 +289,7 @@ export async function resolveWalletGeneration( accountId !== gen.accountId; if (mismatch) { throw new AgenticError( - AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `${wallet} wiring does not match registered generation ${gen.id}`, { details: { engine, policy, validator, gateway, accountId } } ); diff --git a/packages/core/src/lib/agentic/errors.ts b/packages/core/src/lib/agentic/errors.ts index d66055dc5..b7583887e 100644 --- a/packages/core/src/lib/agentic/errors.ts +++ b/packages/core/src/lib/agentic/errors.ts @@ -10,6 +10,7 @@ import { */ export const AGENTIC_ERROR_CODE = { // create / rule validation (spec 3.f) + DUPLICATE_RULE: 'DUPLICATE_RULE', AGENT_IS_OWNER: 'AGENT_IS_OWNER', AMBIGUOUS_RULE: 'AMBIGUOUS_RULE', TARGET_NOT_ON_RULE_CHAIN: 'TARGET_NOT_ON_RULE_CHAIN', @@ -18,8 +19,8 @@ export const AGENTIC_ERROR_CODE = { NOT_OWNER_OR_AGENT: 'NOT_OWNER_OR_AGENT', WALLET_NOT_DEPLOYED: 'WALLET_NOT_DEPLOYED', NOT_AGENTIC_WALLET: 'NOT_AGENTIC_WALLET', - // rules.revoke (spec 3.f) - REVOKE_NEEDS_TARGET: 'REVOKE_NEEDS_TARGET', + // rules.delete (spec 3.f) + DELETE_NEEDS_TARGET: 'DELETE_NEEDS_TARGET', // sendTransaction (spec 3.f) NO_RULES_FOR_CHAIN: 'NO_RULES_FOR_CHAIN', FROM_NOT_ALLOWED: 'FROM_NOT_ALLOWED', @@ -27,7 +28,6 @@ export const AGENTIC_ERROR_CODE = { // SDK-owned INVALID_RULE: 'INVALID_RULE', CAPABILITY_UNAVAILABLE: 'CAPABILITY_UNAVAILABLE', - GENERATION_UNSUPPORTED: 'GENERATION_UNSUPPORTED', READ_ONLY: 'READ_ONLY', NOT_WALLET_OWNER: 'NOT_WALLET_OWNER', RULE_NOT_FOUND: 'RULE_NOT_FOUND', @@ -40,7 +40,6 @@ export const AGENTIC_ERROR_CODE = { AGENT_GAS_INSUFFICIENT: 'AGENT_GAS_INSUFFICIENT', WALLET_BALANCE_INSUFFICIENT: 'WALLET_BALANCE_INSUFFICIENT', GATEWAY_ALLOWANCE_INSUFFICIENT: 'GATEWAY_ALLOWANCE_INSUFFICIENT', - RULE_LIMIT_EXCEEDED: 'RULE_LIMIT_EXCEEDED', } as const; export type AgenticErrorCode = diff --git a/packages/core/src/lib/agentic/execution/outbound.ts b/packages/core/src/lib/agentic/execution/outbound.ts index ab78059f0..999690ef5 100644 --- a/packages/core/src/lib/agentic/execution/outbound.ts +++ b/packages/core/src/lib/agentic/execution/outbound.ts @@ -27,6 +27,9 @@ import { } from '../errors'; import type { AgenticRuntime } from '../runtime'; +/** Includes first-use CEA deployment; explicit caller gas always takes precedence. */ +const FIRST_USE_EVM_GAS_LIMIT = BigInt(2_000_000); + /** URP MAX_ACTIONS_PER_REQUEST at v5. */ export const MAX_DESTINATION_CALLS = 10; @@ -219,7 +222,8 @@ export async function composeOutbound( const quote = await runtime.quoteOutbound( token, - params.gasLimit ?? BigInt(0), + params.gasLimit ?? + (resolved.isDeployed ? BigInt(0) : FIRST_USE_EVM_GAS_LIMIT), chain ); if (quote.nativeValueForGas <= BigInt(0)) { diff --git a/packages/core/src/lib/agentic/execution/svm-outbound.ts b/packages/core/src/lib/agentic/execution/svm-outbound.ts index 6f35c58f6..390f3320f 100644 --- a/packages/core/src/lib/agentic/execution/svm-outbound.ts +++ b/packages/core/src/lib/agentic/execution/svm-outbound.ts @@ -25,7 +25,7 @@ export function composeAgwSvmAction( nowSeconds: number ): Call { function fail(name: string): never { - throw new AgenticError(AGENTIC_ERROR_CODE.RULE_LIMIT_EXCEEDED, name, { + throw new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, name, { details: { contractError: name }, }); } diff --git a/packages/core/src/lib/agentic/index.ts b/packages/core/src/lib/agentic/index.ts index 0bfceff36..667d51550 100644 --- a/packages/core/src/lib/agentic/index.ts +++ b/packages/core/src/lib/agentic/index.ts @@ -6,28 +6,34 @@ export type { AgenticProgressHook, AgenticTxMetadata, AgenticWallet, - AllowedCall, - AssetCap, + RuleCall as AllowedCall, + RuleAsset as AssetCap, Checkpoint, CheckpointKind, CreateOptions, CreateResult, ForeignChainNamespace, - NativeRule, - SolanaRule, DecodedSolanaRule, - SvmInstructionRule, - SvmAccountRef, - SvmFieldConstraint, Rule, + RuleInput, + Pin, + Spent, + WalletMarker, RulesRecord, Selector, - UniversalRule, WalletInfo, WalletSummary, } from './agentic.types'; export { AGENTIC_ERROR_CODE, AgenticError, AgenticRevertError } from './errors'; export type { AgenticErrorCode } from './errors'; export { AGENTIC } from './constants'; -export type { AgenticNetworkConstants } from './constants'; -export { agenticUtils } from './utils'; + +export type { + Preset, + PresetCategory, + PresetAsset, + PresetOptions, + ReadyPresetOptions, + PresetRuleTemplate, + AgenticPresets, +} from './presets/types'; diff --git a/packages/core/src/lib/agentic/management/create.ts b/packages/core/src/lib/agentic/management/create.ts index 3ed829509..a021a99a2 100644 --- a/packages/core/src/lib/agentic/management/create.ts +++ b/packages/core/src/lib/agentic/management/create.ts @@ -1,3 +1,4 @@ +import { assertNoDuplicateRules } from './duplicates'; import { getAddress, type Address, type Hex, type Log } from 'viem'; import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; import type { MultiCall } from '../../orchestrator/orchestrator.types'; @@ -9,7 +10,7 @@ import { prepareWalletRules } from './prepare'; import { assertLabel } from './label'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; import type { AgenticRuntime } from '../runtime'; -import type { CreateOptions, CreateResult } from '../agentic.types'; +import type { InternalCreateOptions, CreateResult } from '../agentic.types'; import { Snapshot, scanLogs } from '../reads/snapshot'; import { walletCount, walletInfo } from '../reads/wallets'; import { @@ -36,7 +37,7 @@ import { export async function createWallet( runtime: AgenticRuntime, label: string, - options: CreateOptions + options: InternalCreateOptions ): Promise { const hook = options?.progressHook; assertLabel(label); @@ -82,8 +83,9 @@ export async function createWallet( options.rules, snap ); - const selfTarget = prepared.findIndex( - (p) => p.nativeTerms?.target === wallet + assertNoDuplicateRules(prepared); + const selfTarget = prepared.findIndex((p) => + p.nativeTerms?.some((t) => t.target === wallet) ); if (selfTarget >= 0) { throw new AgenticError( @@ -387,7 +389,7 @@ async function createFailure( function emitFailure( runtime: AgenticRuntime, - hook: CreateOptions['progressHook'], + hook: InternalCreateOptions['progressHook'], err: unknown ): void { const e = err as { code?: string; message?: string; decodedError?: unknown }; diff --git a/packages/core/src/lib/agentic/management/duplicates.ts b/packages/core/src/lib/agentic/management/duplicates.ts new file mode 100644 index 000000000..68e1e02ea --- /dev/null +++ b/packages/core/src/lib/agentic/management/duplicates.ts @@ -0,0 +1,28 @@ +import { getAddress } from 'viem'; +import type { PreparedRule } from '../codec/rules'; +import { chainHash } from '../codec/rules'; +import type { ActiveRule } from '../reads/rules'; +import { AgenticError, AGENTIC_ERROR_CODE } from '../errors'; + +/** SDK creation policy; existing contract overlaps still use AMBIGUOUS_RULE at send. */ +export function assertNoDuplicateRules( + prepared: readonly PreparedRule[], + existing: readonly ActiveRule[] = [] +): void { + const keys = new Set( + existing.map((r) => `${getAddress(r.agent)}:${r.chainHash}`) + ); + for (const rule of prepared) { + const key = `${getAddress(rule.agent)}:${chainHash(rule.chainNamespace)}`; + if (keys.has(key)) + throw new AgenticError( + AGENTIC_ERROR_CODE.DUPLICATE_RULE, + `an enabled rule already names ${rule.agent} for ${rule.chainNamespace}`, + { + hint: 'Put multiple allowed calls in one rule or update the existing rule.', + details: { agent: rule.agent, chainNamespace: rule.chainNamespace }, + } + ); + keys.add(key); + } +} diff --git a/packages/core/src/lib/agentic/management/prepare.ts b/packages/core/src/lib/agentic/management/prepare.ts index d20196295..195841b06 100644 --- a/packages/core/src/lib/agentic/management/prepare.ts +++ b/packages/core/src/lib/agentic/management/prepare.ts @@ -1,3 +1,4 @@ +import { isPublicRule, normalizePublicRule } from '../public-rule'; import { preparePublicSvmRule } from './svm-public'; import type { SvmTermsWire } from '../codec/svm-terms'; import { getAddress, zeroAddress, type Address } from 'viem'; @@ -16,7 +17,7 @@ import { } from '../deployments'; import { AgenticCapability, requireCapability } from '../capabilities'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; -import type { Rule, UniversalRule, SolanaRule } from '../agentic.types'; +import type { InternalRule, UniversalRule, SolanaRule } from '../agentic.types'; import type { AgenticRuntime } from '../runtime'; import { Snapshot } from '../reads/snapshot'; @@ -32,9 +33,14 @@ export async function prepareWalletRules( gen: AgenticGeneration, owner: Address, wallet: Address, - rules: readonly Rule[], + inputs: readonly InternalRule[], snap: Snapshot ) { + const rules = await Promise.all( + inputs.map((rule) => + isPublicRule(rule) ? normalizePublicRule(rule, wallet, runtime) : rule + ) + ); if (rules.length) await verifyPolicyVersion(runtime.reader, gen); const svm = new Map(); const universal = new Map(); diff --git a/packages/core/src/lib/agentic/management/rules-write.ts b/packages/core/src/lib/agentic/management/rules-write.ts index 96d5fcb75..07c8f0ae7 100644 --- a/packages/core/src/lib/agentic/management/rules-write.ts +++ b/packages/core/src/lib/agentic/management/rules-write.ts @@ -1,3 +1,4 @@ +import { assertNoDuplicateRules } from './duplicates'; import type { Address, Hex } from 'viem'; import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; import type { @@ -11,7 +12,11 @@ import { prepareWalletRules } from './prepare'; import { assertLabel } from './label'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; import type { AgenticRuntime } from '../runtime'; -import type { AgenticHex, AgenticProgressHook, Rule } from '../agentic.types'; +import type { + AgenticHex, + AgenticProgressHook, + InternalRule, +} from '../agentic.types'; import { readActiveRules, readNativeSpend, @@ -69,7 +74,7 @@ export async function addRules( runtime: AgenticRuntime, gen: AgenticGeneration, wallet: Address, - rules: readonly Rule[], + rules: readonly InternalRule[], hook?: AgenticProgressHook ): Promise<{ rulesIds: AgenticHex[]; tx: UniversalTxResponse }> { assertCanSign(runtime, 'rules.add'); @@ -89,6 +94,7 @@ export async function addRules( rules, snap ); + assertNoDuplicateRules(prepared, await readActiveRules(snap, gen, wallet)); const nonce = BigInt( await snap.read(wallet, gen.contracts.abis.wallet, 'grantNonce') ); @@ -146,8 +152,8 @@ export function assertRevokeTarget(target: unknown): { const ids = Array.isArray(target) ? (target as AgenticHex[]) : undefined; if (!all && (!ids || ids.length === 0)) { throw new AgenticError( - AGENTIC_ERROR_CODE.REVOKE_NEEDS_TARGET, - 'rules.revoke needs explicit rule IDs or { all: true }', + AGENTIC_ERROR_CODE.DELETE_NEEDS_TARGET, + 'rules.delete needs explicit rule IDs or { all: true }', { hint: 'A bare revoke that wipes every rule is too easy to make by mistake.', } @@ -156,7 +162,7 @@ export function assertRevokeTarget(target: unknown): { return { all, ids }; } -/** w.rules.revoke(ids) / w.rules.revoke({ all: true }). Bare revoke is refused. */ +/** w.rules.delete(ids) / w.rules.delete({ all: true }). Bare revoke is refused. */ export async function revokeRules( runtime: AgenticRuntime, gen: AgenticGeneration, @@ -171,7 +177,7 @@ export async function revokeRules( if (all) hook = (target as { progressHook?: AgenticProgressHook }).progressHook ?? hook; - assertCanSign(runtime, 'rules.revoke'); + assertCanSign(runtime, 'rules.delete'); const snap = await Snapshot.at(runtime.reader); await assertOwner(runtime, gen, wallet, snap); @@ -189,7 +195,7 @@ export async function revokeRules( if (unique.size !== (ids as Hex[]).length) { throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, - 'rules.revoke got the same rule ID twice' + 'rules.delete got the same rule ID twice' ); } for (const id of ids as Hex[]) { @@ -231,7 +237,7 @@ export async function revokeRules( }, ]; } - const result = await finish(runtime, hook, 'rules.revoke', async () => { + const result = await finish(runtime, hook, 'rules.delete', async () => { const tx = await sendOwnerCalls(runtime, calls, hook); const { logs } = await confirmedLogs(runtime, tx); const revoked = gen.contracts @@ -260,7 +266,7 @@ export async function updateRules( gen: AgenticGeneration, wallet: Address, params: { - rules: { rulesId: AgenticHex; rule: Rule }[]; + rules: { rulesId: AgenticHex; rule: InternalRule }[]; progressHook?: AgenticProgressHook; } ): Promise<{ @@ -415,7 +421,7 @@ function sameIds(a: readonly Hex[], b: readonly Hex[]): boolean { ); } -/** Owner rename through the owner door; empty resets the default label. */ +/** Direct owner rename; empty resets the default label without a checkpoint. */ export async function setLabel( runtime: AgenticRuntime, gen: AgenticGeneration, @@ -434,18 +440,13 @@ export async function setLabel( { to: wallet, value: BigInt(0), - data: gen.contracts.encodeExecute([ - { - target: wallet, - value: BigInt(0), - data: gen.contracts.encodeSetLabel(label), - }, - ]), + data: gen.contracts.encodeSetLabel(label), }, ], hook ); await confirmedLogs(runtime, tx); + emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_106, label); return { tx }; }).then(({ tx }) => tx); } diff --git a/packages/core/src/lib/agentic/management/svm-public.ts b/packages/core/src/lib/agentic/management/svm-public.ts index f74d996ba..3fcc1409f 100644 --- a/packages/core/src/lib/agentic/management/svm-public.ts +++ b/packages/core/src/lib/agentic/management/svm-public.ts @@ -3,7 +3,7 @@ import { getAddress, hexToBytes, zeroAddress } from 'viem'; import type { Address, Hex } from 'viem'; import { CHAIN, VM } from '../../constants/enums'; import { CHAIN_INFO } from '../../constants/chain'; -import type { SolanaRule } from '../agentic.types'; +import type { SolanaRule, DecodedSolanaRule } from '../agentic.types'; import type { AgenticRuntime } from '../runtime'; import type { Snapshot } from '../reads/snapshot'; import { readGasPrc20 } from '../contracts/prc20-metadata'; @@ -12,7 +12,7 @@ import { createSvmMetadataProvider } from './svm-metadata'; import { deriveAgwSvmAta, svmKey } from '../codec/svm-accounts'; import { compileSvmIdlRule } from '../codec/svm-idl'; import { svmInvalid } from '../codec/svm-terms'; -import { UINT256_MAX } from '../codec/defaults'; +import { validateSvmTerms } from '../codec/svm-terms'; export function svmMetadata(runtime: AgenticRuntime) { return ( @@ -29,14 +29,16 @@ export async function preparePublicSvmRule( runtime: AgenticRuntime, snap: Snapshot, wallet: Address, - rule: SolanaRule + rule: SolanaRule | DecodedSolanaRule ) { const chain = rule.chainNamespace as CHAIN; if (CHAIN_INFO[chain]?.vm !== VM.SVM) throw svmInvalid('unknown Solana chain'); if (!Array.isArray(rule.assets) || rule.assets.length > 8) throw svmInvalid('assets must contain at most eight entries'); - if (rule.outputTokens !== undefined && !Array.isArray(rule.outputTokens)) + const raw = 'format' in rule && rule.format === 'decoded'; + const outputTokens = 'outputTokens' in rule ? rule.outputTokens : undefined; + if (outputTokens !== undefined && !Array.isArray(outputTokens)) throw svmInvalid('outputTokens must be an array of mint addresses'); const metadata = svmMetadata(runtime); const requested = rule.assets.length @@ -74,12 +76,12 @@ export async function preparePublicSvmRule( caps.push({ token: getAddress(token), maxPerCall: asset.maxPerCall, - maxTotal: asset.maxTotal ?? UINT256_MAX, + maxTotal: asset.maxTotal ?? BigInt(0), }); sources.push({ key, token }); if (key) mints.add(key); } - const outputs = (rule.outputTokens ?? []).map((mint) => ({ mint })); + const outputs = (outputTokens ?? []).map((mint) => ({ mint })); outputs.forEach((o) => mints.add(svmKey(o.mint))); const ctx = await resolveSvmContext( snap, @@ -113,8 +115,25 @@ export async function preparePublicSvmRule( throw svmInvalid('mint resolver returned another mint'); tokenAccounts.set(mint, deriveAgwSvmAta(ctx.expectedCEA, token)); } + if (raw) { + const stored = rule as DecodedSolanaRule; + if ( + svmKey(stored.expectedCEA) !== ctx.expectedCEA || + svmKey(stored.gatewayProgram) !== ctx.gatewayProgram + ) + throw svmInvalid('decoded rule belongs to another wallet or gateway'); + if ( + ctx.ceaAccounts.some( + (a) => !stored.ceaAccounts.some((s) => svmKey(s) === a) + ) + ) + throw svmInvalid('decoded rule omits a required wallet value account'); + const terms = { ...stored, assets: caps }; + validateSvmTerms(terms, runtime.nowSeconds()); + return terms; + } return compileSvmIdlRule( - rule, + rule as SolanaRule, { ...ctx, tokenAccounts }, runtime.nowSeconds() ); diff --git a/packages/core/src/lib/agentic/management/svm.ts b/packages/core/src/lib/agentic/management/svm.ts index 685b661a9..14aa58ab7 100644 --- a/packages/core/src/lib/agentic/management/svm.ts +++ b/packages/core/src/lib/agentic/management/svm.ts @@ -61,7 +61,7 @@ export async function verifySvmWallet( ); if (actual.addresses.factory !== gen.addresses.factory) throw new AgenticError( - AGENTIC_ERROR_CODE.GENERATION_UNSUPPORTED, + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, 'SVM wallet generation mismatch' ); await verifyPolicyVersion(runtime.reader, gen); diff --git a/packages/core/src/lib/agentic/presets/catalog.ts b/packages/core/src/lib/agentic/presets/catalog.ts new file mode 100644 index 000000000..5edbbec6e --- /dev/null +++ b/packages/core/src/lib/agentic/presets/catalog.ts @@ -0,0 +1,23 @@ +import type { Address } from 'viem'; +import type { RuleCall, ForeignChainNamespace } from '../agentic.types'; +import type { PresetCategory } from './types'; + +/** Internal catalog: reviewed addresses and explicit calls only; never fetched at runtime. */ +export interface PresetChainDefinition { + chainNamespace: ForeignChainNamespace; + allowedCalls: readonly RuleCall[]; + /** Exact spenders that need an approval for each non-native supplied asset. */ + approvals?: readonly { spender: Address; app: string }[]; + maxGasPerCall?: bigint; +} +export interface PresetDefinition { + constant: PresetCategory; + name: string; + chains: readonly PresetChainDefinition[]; +} +/** No approved app/address catalog yet. Empty categories must be hidden by UIs. */ +export const PRESET_CATALOG: readonly PresetDefinition[] = [ + { constant: 'DEXES', name: 'Popular DEXes', chains: [] }, + { constant: 'LENDING', name: 'Lending', chains: [] }, + { constant: 'PREDICTION', name: 'Prediction markets', chains: [] }, +]; diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts new file mode 100644 index 000000000..60f5a842b --- /dev/null +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -0,0 +1,240 @@ +import { getAddress, isAddress, zeroAddress } from 'viem'; +import { + AgenticError, + AGENTIC_ERROR_CODE, + capabilityUnavailable, +} from '../errors'; +import type { RuleAsset, RuleCall } from '../agentic.types'; +import type { + AgenticPresets, + PresetOptions, + PresetRuleTemplate, +} from './types'; +import { parseSelector } from '../codec/selectors'; +import { PRESET_CATALOG, type PresetDefinition } from './catalog'; + +const MAX_UINT256 = (BigInt(1) << BigInt(256)) - BigInt(1); +const invalid = (message: string) => + new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message); +function cap(value: unknown, name: string): bigint { + if (value === undefined) return BigInt(0); + if (typeof value !== 'bigint' || value < BigInt(0) || value > MAX_UINT256) + throw invalid(`${name} must be a uint256 bigint`); + return value; +} +function copyCall(call: RuleCall): RuleCall { + return { + ...call, + ...(call.pins + ? { + pins: call.pins.map((p) => ({ + ...p, + ...('token' in p && p.token && typeof p.token === 'object' + ? { token: { ...p.token } } + : {}), + })), + } + : {}), + }; +} +function tokenAddress(token: RuleAsset['token']): string { + const address = typeof token === 'string' ? token : token?.address; + if (typeof address !== 'string' || !address.trim()) + throw invalid('preset asset requires a token address'); + return address; +} +/** Not a public registration API. Tests inject fixtures; production uses the bundled catalog. */ +export function buildPresetApi( + catalog: readonly PresetDefinition[] +): AgenticPresets { + // Detach from the input so neither a returned template nor an input mutation changes the catalog. + const entries = catalog.map((p) => ({ + ...p, + chains: p.chains.map((c) => ({ + ...c, + allowedCalls: c.allowedCalls.map(copyCall), + approvals: c.approvals?.map((a) => ({ ...a })), + })), + })); + if ( + new Set(entries.map((e) => e.constant)).size !== entries.length || + entries.some( + (e) => + new Set(e.chains.map((c) => c.chainNamespace)).size !== e.chains.length + ) + ) + throw invalid('duplicate preset or chain in catalog'); + const api = { + list(options?: { type?: 'CATEGORY' }) { + if (options?.type !== undefined && options.type !== 'CATEGORY') + throw invalid('only CATEGORY presets are supported'); + return { + presets: entries.map((p) => ({ + type: 'CATEGORY' as const, + constant: p.constant, + name: p.name, + chains: p.chains.map((c) => c.chainNamespace), + })), + }; + }, + get(preset: string, options: PresetOptions = {}) { + const entry = entries.find((p) => p.constant === preset); + if (!entry) throw invalid(`unknown preset ${String(preset)}`); + if (!options || typeof options !== 'object' || Array.isArray(options)) + throw invalid('preset options must be an object'); + if ( + options.agent !== undefined && + (!isAddress(options.agent, { strict: false }) || + getAddress(options.agent) === zeroAddress) + ) + throw invalid('preset agent must be a nonzero Push address'); + if ( + options.validUntil !== undefined && + (!Number.isSafeInteger(options.validUntil) || + options.validUntil <= Math.floor(Date.now() / 1000) || + options.validUntil >= 2 ** 48) + ) + throw invalid('preset validUntil must be a future uint48 timestamp'); + if (options.assets !== undefined && !Array.isArray(options.assets)) + throw invalid('preset assets must be an array'); + const chains = + options.chainNamespace === undefined + ? entry.chains + : entry.chains.filter( + (c) => c.chainNamespace === options.chainNamespace + ); + if (options.chainNamespace !== undefined && !chains.length) + throw capabilityUnavailable( + 'presetChain', + `preset ${preset} has no approved entry for ${options.chainNamespace}` + ); + if (!chains.length && options.assets?.length) + throw capabilityUnavailable( + 'presetCatalog', + `preset ${preset} has no approved app entries` + ); + const assets = new Map(); + for (const asset of options.assets ?? []) { + if (!asset || typeof asset !== 'object') + throw invalid('preset asset must be an object'); + const chain = asset.chainNamespace ?? options.chainNamespace; + if (!chain) + throw invalid( + 'each asset needs chainNamespace when no chain filter is supplied' + ); + if (!chains.some((c) => c.chainNamespace === chain)) + throw invalid(`asset chain ${chain} is outside the selected preset`); + const address = tokenAddress(asset.token); + if ( + chain.startsWith('eip155:') && + !isAddress(address, { strict: false }) + ) + throw invalid('EVM preset asset requires an EVM address'); + const group = assets.get(chain) ?? []; + const key = chain.startsWith('eip155:') + ? address.toLowerCase() + : address; + if ( + group.some( + (a) => + (chain.startsWith('eip155:') + ? tokenAddress(a.token).toLowerCase() + : tokenAddress(a.token)) === key + ) + ) + throw invalid('duplicate preset asset on the same chain'); + group.push({ + token: + typeof asset.token === 'string' ? asset.token : { ...asset.token }, + maxPerCall: cap(asset.maxPerCall, 'maxPerCall'), + maxTotal: cap(asset.maxTotal, 'maxTotal'), + }); + assets.set(chain, group); + } + const rules: PresetRuleTemplate[] = chains.map((chain) => { + const calls = chain.allowedCalls.map(copyCall); + const approvals = new Map(); + for (const asset of assets.get(chain.chainNamespace) ?? []) { + const token = tokenAddress(asset.token); + if ( + token === zeroAddress || + (typeof asset.token !== 'string' && + asset.token.mechanism === 'native') + ) + continue; + for (const approval of chain.approvals ?? []) { + if (!chain.chainNamespace.startsWith('eip155:')) + throw capabilityUnavailable( + 'presetApproval', + 'Solana approvals require a separately reviewed instruction template' + ); + const spender = getAddress(approval.spender); + const target = getAddress(token); + const previous = approvals.get(target); + if (previous && previous !== spender) + throw capabilityUnavailable( + 'presetApproval', + 'multiple spenders for one token need a reviewed policy representation; no unrestricted approval is generated' + ); + if (previous) continue; + approvals.set(target, spender); + calls.push({ + target, + selector: 'approve(address,uint256)', + pins: [ + { arg: 0, expected: spender }, + { arg: 1, max: asset.maxPerCall }, + ], + app: approval.app, + label: 'Approve token spending', + }); + } + } + if (!calls.length || calls.length > 32) + throw capabilityUnavailable( + 'presetCalls', + 'preset must contain 1..32 allowed calls including approvals' + ); + const pairs = new Set(); + for (const call of calls) { + if (!/^[A-Za-z_$][\w$]*\(.*\)$/.test(call.selector)) + throw invalid('preset selectors must be full signatures'); + const selector = chain.chainNamespace.startsWith('eip155:') + ? parseSelector(call.selector).selector + : call.selector; + const pair = `${ + chain.chainNamespace.startsWith('eip155:') + ? call.target.toLowerCase() + : call.target + }:${selector}`; + if (pairs.has(pair)) + throw capabilityUnavailable( + 'presetCalls', + 'duplicate target/function entries need a reviewed policy representation' + ); + pairs.add(pair); + } + return { + chainNamespace: chain.chainNamespace, + allowedCalls: calls, + ...(options.agent !== undefined + ? { agent: getAddress(options.agent) } + : {}), + ...(options.validUntil !== undefined + ? { validUntil: options.validUntil } + : {}), + ...(options.assets !== undefined + ? { assets: assets.get(chain.chainNamespace) ?? [] } + : {}), + ...(chain.maxGasPerCall !== undefined + ? { maxGasPerCall: cap(chain.maxGasPerCall, 'maxGasPerCall') } + : {}), + }; + }); + return { rules }; + }, + }; + // The overload narrows templates only when both required fields were supplied and validated. + return Object.freeze(api) as AgenticPresets; +} +export const agenticPresets = buildPresetApi(PRESET_CATALOG); diff --git a/packages/core/src/lib/agentic/presets/types.ts b/packages/core/src/lib/agentic/presets/types.ts new file mode 100644 index 000000000..5e3a6bc74 --- /dev/null +++ b/packages/core/src/lib/agentic/presets/types.ts @@ -0,0 +1,40 @@ +import type { Address } from 'viem'; +import type { Rule, RuleAsset, ForeignChainNamespace } from '../agentic.types'; + +export type PresetCategory = 'DEXES' | 'LENDING' | 'PREDICTION'; +export interface Preset { + type: 'CATEGORY'; + constant: PresetCategory; + name: string; + chains: ForeignChainNamespace[]; +} +/** An editable review-screen template; both identity and expiry are required by create. */ +export type PresetRuleTemplate = Omit & { + agent?: Address; + validUntil?: number; +}; +export interface PresetAsset { + chainNamespace?: ForeignChainNamespace; + token: RuleAsset['token']; + /** Omitted caps are zero, by the user's October 12 decision. */ + maxPerCall?: bigint; + maxTotal?: bigint; +} +export interface PresetOptions { + chainNamespace?: ForeignChainNamespace; + agent?: Address; + validUntil?: number; + assets?: PresetAsset[]; +} +export interface ReadyPresetOptions extends PresetOptions { + agent: Address; + validUntil: number; +} +export interface AgenticPresets { + list(options?: { type?: 'CATEGORY' }): { presets: Preset[] }; + get(preset: PresetCategory, options: ReadyPresetOptions): { rules: Rule[] }; + get( + preset: PresetCategory, + options?: PresetOptions + ): { rules: PresetRuleTemplate[] }; +} diff --git a/packages/core/src/lib/agentic/public-rule.ts b/packages/core/src/lib/agentic/public-rule.ts new file mode 100644 index 000000000..bf293415b --- /dev/null +++ b/packages/core/src/lib/agentic/public-rule.ts @@ -0,0 +1,420 @@ +/** Public section-6 rule normalization into the deployed v5 wire model. */ +import type { Idl } from '@coral-xyz/anchor'; +import { PublicKey } from '@solana/web3.js'; +import { + getAddress, + hexToBytes, + isAddress, + isHex, + numberToHex, + sha256, + toHex, + type Address, + type Hex, +} from 'viem'; +import type { + Rule, + RuleInput, + InternalRule, + NativeAction, + SolanaRule, + UniversalRule, + Pin, +} from './agentic.types'; +import { + AGENTIC_ERROR_CODE, + AgenticError, + capabilityUnavailable, +} from './errors'; +import { parseSelector } from './codec/selectors'; +import { argumentOffset, encodeArgWord, headSize } from './codec/abi-layout'; +import { UINT256_MAX } from './codec/defaults'; +import { deriveAgwSvmCea, svmKey } from './codec/svm-accounts'; +import type { AgenticRuntime } from './runtime'; +import { CHAIN } from '../constants/enums'; + +export const WALLET = 'agentic:wallet' as const; +const PUBLIC_INPUT = Symbol('AGW public rule'); +/** Mark authored inputs for normalization after the wallet address is fixed. */ +export function publicRules(rules: readonly RuleInput[]): InternalRule[] { + if (!Array.isArray(rules)) throw invalid('rules must be an array'); + return rules.map((rule) => { + if (!rule || typeof rule !== 'object') + throw invalid('rule must be an object'); + return { ...rule, [PUBLIC_INPUT]: true } as unknown as InternalRule; + }); +} +export function isPublicRule(rule: InternalRule): boolean { + return !!rule && !!(rule as unknown as Record)[PUBLIC_INPUT]; +} +function invalid(message: string) { + return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message); +} +function rejectFields(obj: object, fields: string[], where: string) { + for (const field of fields) + if (field in obj) + throw invalid( + `${where}.${field} is not supported by the current rule shape` + ); +} +function uint(value: unknown, name: string): bigint { + if (typeof value !== 'bigint' || value < BigInt(0) || value > UINT256_MAX) + throw invalid(`${name} must be a uint256 bigint`); + return value; +} +export function evmPinOffset( + selector: Rule['allowedCalls'][number]['selector'], + arg: number +): number { + if (!Number.isInteger(arg) || arg < 0 || 4 + 32 * arg + 32 > 65535) + throw invalid('pin arg is outside the supported calldata range'); + const parsed = parseSelector(selector); + if (parsed.valueOnly) + throw invalid('value-only calls cannot have argument pins'); + if (!parsed.inputs) return 4 + 32 * arg; + if (parsed.inputs.slice(0, arg).some((p) => headSize(p) !== 32)) + throw capabilityUnavailable( + 'argumentLayout', + 'pins after multi-word tuple/array arguments need an explicit lossless index convention' + ); + return argumentOffset(parsed.inputs, arg, 'pin'); +} +function expectedWord( + selector: Rule['allowedCalls'][number]['selector'], + arg: number, + value: unknown +): Hex { + const parsed = parseSelector(selector); + if (parsed.inputs) + return encodeArgWord(parsed.inputs[arg], value as Hex | bigint, 'pin'); + if (typeof value === 'bigint') + return numberToHex(uint(value, 'pin.expected'), { size: 32 }); + if (typeof value === 'string' && isAddress(value, { strict: false })) + return `0x${value.slice(2).padStart(64, '0')}`.toLowerCase() as Hex; + if (typeof value === 'string' && isHex(value) && value.length === 66) + return value.toLowerCase() as Hex; + throw invalid( + 'a raw EVM selector needs an address, uint256 or exact 32-byte expected word' + ); +} +export async function normalizePublicRule( + input: InternalRule, + wallet: Address, + runtime: AgenticRuntime +): Promise { + const rule = input as unknown as RuleInput; + if ('format' in rule && rule.format === 'decoded') return input; + const r = rule as Rule; + rejectFields( + r, + [ + 'target', + 'selector', + 'actions', + 'pins', + 'amount', + 'maxCalls', + 'maxValuePerCall', + 'maxValueTotal', + 'ref', + 'allowedInstructions', + 'outputTokens', + ], + 'rule' + ); + if ( + !Number.isSafeInteger(r.validUntil) || + r.validUntil <= runtime.nowSeconds() || + r.validUntil >= 2 ** 48 + ) + throw invalid('validUntil must be a future uint48 timestamp'); + if ( + !Array.isArray(r.allowedCalls) || + r.allowedCalls.length < 1 || + r.allowedCalls.length > 32 + ) + throw invalid('allowedCalls must contain 1..32 calls'); + for (const c of r.allowedCalls) { + if (!c || typeof c !== 'object') + throw invalid('allowedCall must be an object'); + rejectFields( + c, + [ + 'beneficiary', + 'beneficiaryOffset', + 'amount', + 'maxCalls', + 'maxValuePerCall', + 'maxValueTotal', + 'validUntil', + ], + 'allowedCall' + ); + uint(c.maxValue ?? BigInt(0), 'allowedCall.maxValue'); + if (c.pins !== undefined && !Array.isArray(c.pins)) + throw invalid('pins must be an array'); + for (const pin of c.pins ?? []) { + if (!pin || typeof pin !== 'object') + throw invalid('pin must be an object'); + rejectFields(pin, ['offset', 'min'], 'pin'); + if ('token' in pin && (!('account' in pin) || pin.expected !== WALLET)) + throw invalid('token is only valid on a WALLET account pin'); + if ('arg' in pin === 'account' in pin) + throw invalid('pin must identify exactly one arg or account'); + if ('expected' in pin === 'max' in pin) + throw invalid('pin must specify exactly one expected or max'); + } + } + if (r.chainNamespace?.startsWith('solana:')) { + const { svmMetadata } = await import('./management/svm-public'); + const gateway = await svmMetadata(runtime).gateway( + r.chainNamespace as `solana:${string}` + ); + if (gateway.chainNamespace !== r.chainNamespace) + throw invalid('gateway registry cluster mismatch'); + return svmRule(r, deriveAgwSvmCea(wallet, gateway.program).address); + } + const native = r.chainNamespace === undefined; + if (r.chainNamespace === runtime.pushChainNamespace) + throw invalid('omit chainNamespace for Push rules'); + if (native && r.allowedCalls.length > 8) + throw capabilityUnavailable( + 'nativeActions', + 'the deployed v5 wallet permits at most eight native actions' + ); + if (native && (r.assets !== undefined || r.maxGasPerCall !== undefined)) + throw invalid('native rules do not carry assets or maxGasPerCall'); + const own = native + ? wallet + : (await runtime.resolveCEA(wallet, r.chainNamespace as CHAIN)).cea; + const nativeActions: NativeAction[] = []; + const calls: UniversalRule['allowedCalls'] = []; + for (const call of r.allowedCalls) { + const pins = call.pins ?? []; + let amount: NativeAction['amount']; + const eq: NonNullable = []; + let beneficiaryOffset: number | undefined; + const occupied = new Set(); + for (const pin of pins) { + if ('account' in pin) throw invalid('account pins only apply to Solana'); + const offset = evmPinOffset(call.selector, pin.arg); + if (occupied.has(offset)) throw invalid('duplicate pin argument'); + occupied.add(offset); + if (!native) { + if ( + !('expected' in pin) || + pin.expected !== WALLET || + beneficiaryOffset !== undefined + ) + throw capabilityUnavailable( + 'evmDestinationPins', + 'general or multiple EVM destination pins require contract change 6.3; v5 supports one WALLET pin per call' + ); + beneficiaryOffset = offset; + } else if ('max' in pin) { + if (amount) + throw capabilityUnavailable( + 'nativeMaxPins', + 'v5 supports one max pin per native call' + ); + amount = { + offset, + maxPerCall: uint(pin.max, 'pin.max'), + maxTotal: UINT256_MAX, + }; + } else { + eq.push({ + offset, + expected: expectedWord( + call.selector, + pin.arg, + pin.expected === WALLET ? own : pin.expected + ), + }); + } + } + if (native) + nativeActions.push({ + target: getAddress(call.target), + selector: call.selector, + pins: eq, + amount, + maxValuePerCall: call.maxValue ?? BigInt(0), + maxValueTotal: UINT256_MAX, + maxCalls: 0, + }); + else + calls.push({ + target: getAddress(call.target), + selector: + call.selector as UniversalRule['allowedCalls'][number]['selector'], + beneficiaryOffset, + maxValue: call.maxValue ?? BigInt(0), + }); + } + if (native) + return { agent: r.agent, validUntil: r.validUntil, actions: nativeActions }; + return { + agent: r.agent, + validUntil: r.validUntil, + chainNamespace: r.chainNamespace as `eip155:${string}`, + assets: (r.assets ?? []).map((a) => ({ + ...a, + token: a.token as Address, + maxTotal: a.maxTotal ?? BigInt(0), + })), + maxGasPerCall: r.maxGasPerCall ?? BigInt(0), + allowedCalls: calls, + }; +} + +function svmType( + text: string +): Idl['instructions'][number]['args'][number]['type'] { + const array = text.match(/^(.+)\[(\d+)\]$/); + if (array) { + const count = Number(array[2]); + if (!Number.isSafeInteger(count) || count < 1 || count > 128) + throw invalid('unsupported SVM fixed array size'); + return { array: [svmType(array[1]), count] }; + } + if ( + [ + 'u8', + 'u16', + 'u32', + 'u64', + 'i8', + 'i16', + 'i32', + 'i64', + 'bool', + 'pubkey', + ].includes(text) + ) + return text as 'u64'; + if (text === 'publicKey') return 'pubkey'; + throw capabilityUnavailable( + 'svmArgumentLayout', + `unsupported fixed-size Solana type ${text}` + ); +} +function svmRule(rule: Rule, cea: Hex): SolanaRule { + return { + agent: rule.agent, + validUntil: rule.validUntil, + chainNamespace: rule.chainNamespace as `solana:${string}`, + outputTokens: [ + ...new Set( + rule.allowedCalls.flatMap((c) => + (c.pins ?? []).flatMap((p) => + 'account' in p && 'token' in p && p.expected === WALLET && p.token + ? [typeof p.token === 'string' ? p.token : p.token.address] + : [] + ) + ) + ), + ], + assets: (rule.assets ?? []).map((asset) => ({ + ...asset, + maxTotal: asset.maxTotal ?? BigInt(0), + })), + maxGasPerCall: rule.maxGasPerCall ?? BigInt(0), + allowedInstructions: rule.allowedCalls.map((call) => { + if ((call.maxValue ?? BigInt(0)) !== BigInt(0)) + throw capabilityUnavailable( + 'svmCallValue', + 'native Solana instruction value is governed by its transfer/pin, not an EVM call value' + ); + const full = String(call.selector).match( + /^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)$/ + ); + const raw = /^0x[0-9a-fA-F]{16}$/.test(call.selector); + if (!full && !raw) + throw invalid( + 'Solana selector needs a fixed-width signature or eight-byte discriminator' + ); + const types = + full && full[2] ? full[2].split(',').map((s) => svmType(s.trim())) : []; + const name = full?.[1] ?? 'raw'; + const discriminator = raw + ? hexToBytes(call.selector as Hex) + : hexToBytes( + sha256( + toHex( + `global:${name + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase()}` + ) + ) + ).slice(0, 8); + const pins = call.pins ?? []; + const accounts = pins.filter( + (p): p is Extract => 'account' in p + ); + if ( + accounts.some( + (p) => + !Number.isInteger(p.account) || p.account < 0 || p.account >= 64 + ) + ) + throw invalid('Solana account position must be 0..63'); + const accountCount = Math.max(0, ...accounts.map((p) => p.account + 1)); + const idl: Idl = { + address: new PublicKey(hexToBytes(svmKey(call.target))).toBase58(), + metadata: { name: 'rule_layout', version: '1', spec: '0.1.0' }, + instructions: [ + { + name, + discriminator: Array.from(discriminator), + accounts: Array.from({ length: accountCount }, (_, i) => ({ + name: `account${i}`, + })), + args: types.map((type, i) => ({ name: `arg${i}`, type })), + }, + ], + }; + return { + maxAccounts: 0, + program: call.target, + instruction: { idl, name }, + accounts: accounts.map((p) => ({ + name: `account${p.account}`, + expected: + p.expected === WALLET + ? p.token + ? { + kind: 'walletATA' as const, + token: + typeof p.token === 'string' ? p.token : p.token.address, + } + : { kind: 'walletCEA' as const } + : { kind: 'address' as const, address: p.expected }, + })), + fields: pins + .filter((p) => 'arg' in p) + .map((p) => { + const arg = (p as { arg: number }).arg; + if (!Number.isInteger(arg) || arg < 0 || arg >= types.length) + throw capabilityUnavailable( + 'svmArgumentLayout', + 'argument pins need a signature with the indexed argument type; use decoded raw constraints for stored rules' + ); + if ('max' in p) + return { name: `arg${arg}`, max: uint(p.max, 'pin.max') }; + const rawExpected = (p as { expected: string | bigint }).expected; + const eq = rawExpected === WALLET ? cea : rawExpected; + return { + name: `arg${arg}`, + equals: + typeof eq === 'bigint' + ? eq + : /^0x/.test(eq) + ? (eq as Hex) + : svmKey(eq), + }; + }), + }; + }), + }; +} diff --git a/packages/core/src/lib/agentic/reads/checkpoints.ts b/packages/core/src/lib/agentic/reads/checkpoints.ts index 713819205..93a3979c3 100644 --- a/packages/core/src/lib/agentic/reads/checkpoints.ts +++ b/packages/core/src/lib/agentic/reads/checkpoints.ts @@ -1,4 +1,4 @@ -import type { Address } from 'viem'; +import type { Address, Log } from 'viem'; import { AgenticCapability, requireCapability } from '../capabilities'; import type { AgenticGeneration } from '../deployments'; import type { ChainReader } from '../contracts/reader'; @@ -25,15 +25,37 @@ export async function readCheckpoints( for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const snap = await Snapshot.at(reader); const count = BigInt( - await snap.read(wallet, gen.contracts.abis.wallet, 'checkpointCount') - ); - const from = sinceBlock !== undefined && sinceBlock > gen.startBlock ? sinceBlock : gen.startBlock; - const logs = await scanLogs( - reader, - { address: wallet, event: gen.contracts.events.checkpointed }, - from, - snap.blockNumber + await snap.read( + wallet, + gen.contracts.abis.wallet, + 'checkpointCount' + ) ); + const from = + sinceBlock !== undefined && sinceBlock > gen.startBlock + ? sinceBlock + : gen.startBlock; + const filter = { + address: wallet, + event: gen.contracts.events.checkpointed, + }; + let logs: Log[]; + try { + logs = await scanLogs(reader, filter, from, snap.blockNumber); + } catch (cause) { + const retained = lowestRetainedHeight(cause); + // A fresh wallet may have its entire history after the RPC retention + // floor. Never treat truncation as success: the FULL sequence/count + // validation below must still prove every checkpoint is present. + if ( + sinceBlock !== undefined || + retained === undefined || + retained <= from || + retained > snap.blockNumber + ) + throw cause; + logs = await scanLogs(reader, filter, retained, snap.blockNumber); + } const decoded = gen.contracts .parseCheckpointed(logs, wallet) .sort((a, b) => @@ -42,10 +64,14 @@ export async function readCheckpoints( ? -1 : 1 : a.transactionIndex !== b.transactionIndex - ? a.transactionIndex - b.transactionIndex - : a.logIndex - b.logIndex + ? a.transactionIndex - b.transactionIndex + : a.logIndex - b.logIndex ); - const problem = validateSequence(decoded.map((c) => c.seq), count, sinceBlock === undefined || from === gen.startBlock); + const problem = validateSequence( + decoded.map((c) => c.seq), + count, + sinceBlock === undefined || from === gen.startBlock + ); if (!problem) { return { count, @@ -59,27 +85,65 @@ export async function readCheckpoints( })), }; } - lastError = new AgenticError(AGENTIC_ERROR_CODE.INCONSISTENT_READ, problem, { - details: { wallet, count, blockNumber: snap.blockNumber }, - }); + lastError = new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + problem, + { + details: { wallet, count, blockNumber: snap.blockNumber }, + } + ); } throw lastError as AgenticError; } /** Sequence must be contiguous, end at `count`, and start at 1 for a full scan. */ -export function validateSequence(seqs: number[], count: bigint, fullRange: boolean): string | null { +export function validateSequence( + seqs: number[], + count: bigint, + fullRange: boolean +): string | null { for (let i = 1; i < seqs.length; i++) { - if (seqs[i] !== seqs[i - 1] + 1) return `checkpoint sequence gap between ${seqs[i - 1]} and ${seqs[i]}`; + if (seqs[i] !== seqs[i - 1] + 1) + return `checkpoint sequence gap between ${seqs[i - 1]} and ${seqs[i]}`; } if (fullRange) { if (BigInt(seqs.length) !== count) { return `found ${seqs.length} Checkpointed events but checkpointCount() is ${count}`; } - if (seqs.length > 0 && seqs[0] !== 1) return `first checkpoint seq is ${seqs[0]}, expected 1`; + if (seqs.length > 0 && seqs[0] !== 1) + return `first checkpoint seq is ${seqs[0]}, expected 1`; return null; } if (seqs.length > 0 && BigInt(seqs[seqs.length - 1]) !== count) { - return `last checkpoint seq ${seqs[seqs.length - 1]} does not match checkpointCount() ${count}`; + return `last checkpoint seq ${ + seqs[seqs.length - 1] + } does not match checkpointCount() ${count}`; } return null; } + +/** Recognize Donut/CometBFT's explicit pruning error, not arbitrary RPC failures. */ +function lowestRetainedHeight(error: unknown): bigint | undefined { + const visited = new Set(); + for ( + let depth = 0; + depth < 8 && error && typeof error === 'object' && !visited.has(error); + depth++ + ) { + visited.add(error); + const value = error as { + message?: unknown; + details?: unknown; + cause?: unknown; + }; + for (const text of [value.message, value.details]) { + if (typeof text !== 'string') continue; + const match = /height \d+ is not available, lowest height is (\d+)/.exec( + text + ); + if (match) return BigInt(match[1]); + } + error = value.cause; + } + return undefined; +} diff --git a/packages/core/src/lib/agentic/reads/rules.ts b/packages/core/src/lib/agentic/reads/rules.ts index 0c4dcc854..44298144a 100644 --- a/packages/core/src/lib/agentic/reads/rules.ts +++ b/packages/core/src/lib/agentic/reads/rules.ts @@ -10,8 +10,10 @@ import type { UniversalConfigRead, } from '../contracts/v5'; import { configId } from '../codec/ids'; -import { nativeTermsToRule } from '../codec/native'; -import { universalTermsToRule } from '../codec/universal'; +import { nativeActionTermsToRule } from '../codec/native'; +import { UINT256_MAX } from '../codec/defaults'; +import { WALLET } from '../public-rule'; +import { capabilityUnavailable } from '../errors'; import { readOriginToken } from '../contracts/prc20-metadata'; import { chainHash } from '../codec/rules'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; @@ -198,6 +200,13 @@ export async function decodeActiveRule( agent: rule.agent, chainNamespace: stored.chainNamespace, validUntil: Number(cfg.validUntil), + spent: { + kind: 'universal', + assets: assets.map((a, i) => ({ + token: a.token, + spent: cfg.assets[i].spent, + })), + }, rule: { format: 'decoded', agent: rule.agent, @@ -257,12 +266,32 @@ export async function decodeActiveRule( chainNamespace: chain, agent: rule.agent, validUntil: Number(cfg.validUntil), - rule: universalTermsToRule( - cfg, - rule.agent, - chain as `eip155:${string}`, - tokens - ), + spent: { + kind: 'universal', + assets: tokens.map((token, i) => ({ + token, + spent: cfg.assets[i].spent, + })), + }, + rule: { + agent: rule.agent, + chainNamespace: chain as `eip155:${string}`, + validUntil: Number(cfg.validUntil), + maxGasPerCall: cfg.maxGasPerCall, + assets: cfg.assets.map((a, i) => ({ + token: tokens[i], + maxPerCall: a.maxPerCall, + maxTotal: a.maxTotal, + })), + allowedCalls: cfg.allowedCalls.map((c) => ({ + target: c.target, + selector: c.selector, + maxValue: c.maxValue, + pins: c.hasBeneficiary + ? [{ arg: storedArg(c.beneficiaryOffset), expected: WALLET }] + : [], + })), + }, }; } if (rule.mode !== MODE_NATIVE) { @@ -271,24 +300,64 @@ export async function decodeActiveRule( `rule ${rule.rulesId} has unknown mode ${rule.mode}` ); } - if (rule.actionIds.length !== 1) { - throw new AgenticError( - AGENTIC_ERROR_CODE.RULE_READ_FAILED, - `rule ${rule.rulesId} has ${rule.actionIds.length} native actions; the public NativeRule is single-action (A02)` - ); - } - const cfg = await snap.read( - gen.addresses.rulesPolicy, - gen.contracts.abis.policy, - 'getNativeConfig', - [configId(wallet, rule.rulesId, rule.actionIds[0]), wallet] + // One stored config per action, read at the same block, in engine order. + const configs = await Promise.all( + rule.actionIds.map((action) => + snap.read( + gen.addresses.rulesPolicy, + gen.contracts.abis.policy, + 'getNativeConfig', + [configId(wallet, rule.rulesId, action), wallet] + ) + ) ); - if (!cfg.initialized) { + if (configs.some((c) => !c.initialized)) { throw new AgenticError( AGENTIC_ERROR_CODE.INCONSISTENT_READ, `native config for ${rule.rulesId} is not initialized` ); } + const decoded = nativeActionTermsToRule(configs, rule.agent); + if ( + configs.some( + (c) => + c.maxValueTotal !== UINT256_MAX || + Number(c.maxCalls) !== 0 || + (c.amount.enabled && c.amount.maxTotal !== UINT256_MAX) || + Number(c.validUntil) !== decoded.validUntil + ) + ) { + throw capabilityUnavailable( + 'nativeRuleRead', + 'stored lifetime limits or differing action expiries cannot be represented by section-6 rules without losing constraints' + ); + } + const allowedCalls = configs.map((c) => ({ + target: getAddress(c.target), + selector: + c.selector === '0xffffffff' ? ('value-only' as const) : c.selector, + maxValue: c.maxValuePerCall, + pins: [ + ...c.pins.map((p) => ({ + arg: storedArg(p.offset), + expected: + p.expected.toLowerCase() === + `0x${wallet.slice(2).toLowerCase().padStart(64, '0')}` + ? WALLET + : p.expected, + })), + ...(c.amount.enabled + ? [{ arg: storedArg(c.amount.offset), max: c.amount.maxPerCall }] + : []), + ], + })); + const counters = configs.map((c, i) => ({ + target: allowedCalls[i].target, + selector: allowedCalls[i].selector, + valueSpent: c.valueSpent, + amountSpent: c.amountSpent, + callsUsed: Number(c.callsUsed), + })); if (rule.agent === getAddress(`0x${'00'.repeat(20)}`)) { throw new AgenticError( AGENTIC_ERROR_CODE.INCONSISTENT_READ, @@ -300,8 +369,17 @@ export async function decodeActiveRule( enabled: true, chainNamespace: pushChainNamespace, agent: rule.agent, - validUntil: Number(cfg.validUntil), - rule: nativeTermsToRule(cfg, rule.agent), + validUntil: decoded.validUntil, + rule: { agent: rule.agent, validUntil: decoded.validUntil, allowedCalls }, + spent: + counters.length === 1 + ? { + kind: 'native', + valueSpent: counters[0].valueSpent, + amountSpent: counters[0].amountSpent, + callsUsed: counters[0].callsUsed, + } + : { kind: 'native', actions: counters }, }; } @@ -396,3 +474,13 @@ export async function getRule( `rule ${rulesId} is not enabled on ${wallet} (unknown or revoked; v1 has no rule history)` ); } + +/** With a raw selector, arg denotes its ABI head-word position. */ +function storedArg(offset: number): number { + if (offset < 4 || (offset - 4) % 32 !== 0) + throw capabilityUnavailable( + 'argumentLayout', + 'stored pin is not aligned to an ABI argument word' + ); + return (offset - 4) / 32; +} diff --git a/packages/core/src/lib/agentic/utils.ts b/packages/core/src/lib/agentic/utils.ts index 2c863bd22..e2dbcfd9d 100644 --- a/packages/core/src/lib/agentic/utils.ts +++ b/packages/core/src/lib/agentic/utils.ts @@ -12,7 +12,7 @@ import { deriveWallet as deriveWalletRaw, rulesId as rulesIdRaw, } from './codec/ids'; -import { decodeNativeTerms, nativeTermsToRule } from './codec/native'; +import { decodeNativeTerms, nativeActionTermsToRule } from './codec/native'; import { prepareRules } from './codec/rules'; import { parseSelector } from './codec/selectors'; import { decodeEnvelope, decodeSession, encodeSession } from './codec/session'; @@ -26,7 +26,7 @@ import { decodeUniversalTerms } from './codec/universal-terms'; import { universalTermsToRule } from './codec/universal'; import type { AgenticHex, - Rule, + InternalRule, Selector, DecodedSolanaRule, } from './agentic.types'; @@ -105,7 +105,10 @@ export const internalAgenticUtils = { * Encode rules into the grant `Session` bytes, one per rule, in order. * EVM universal rules require wallet/token resolution in the internal context. */ - encodeRules(rules: readonly Rule[], ctx: RulesEncodeContext): AgenticHex[] { + encodeRules( + rules: readonly InternalRule[], + ctx: RulesEncodeContext + ): AgenticHex[] { return prepareRules(rules, { owner: ctx.owner ? getAddress(ctx.owner) : undefined, pushChainNamespace: nativeNamespace(ctx), @@ -122,7 +125,7 @@ export const internalAgenticUtils = { decodeRules( bytes: Hex, ctx: { network?: PUSH_NETWORK; pushChainNamespace?: string } = {} - ): Rule | DecodedSolanaRule { + ): InternalRule | DecodedSolanaRule { if (!isHex(bytes)) throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, @@ -130,12 +133,12 @@ export const internalAgenticUtils = { ); const session = decodeSession(bytes); if ( - session.actions.length !== 1 || - session.actions[0].actionPolicies.length !== 1 + session.actions.length === 0 || + session.actions.some((a) => a.actionPolicies.length !== 1) ) { throw new AgenticError( AGENTIC_ERROR_CODE.RULE_READ_FAILED, - 'only single-action rules (one URP policy) are representable as a public Rule' + 'every action needs exactly one URP policy to be representable as a public InternalRule' ); } const agent = getAddress( @@ -144,6 +147,15 @@ export const internalAgenticUtils = { const { chainNamespace, body } = decodeEnvelope( session.actions[0].actionPolicies[0].initData ); + if ( + chainNamespace !== nativeNamespace(ctx) && + session.actions.length !== 1 + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + 'only native rules carry more than one action' + ); + } if (chainNamespace !== nativeNamespace(ctx)) { if (chainNamespace.startsWith('solana:')) { const terms = decodeSvmTerms(body); @@ -237,7 +249,21 @@ export const internalAgenticUtils = { tokens ); } - return nativeTermsToRule(decodeNativeTerms(body), agent); + // Native: one stored terms body per action, all on the native chain. + const terms = session.actions.map((a, i) => { + const env = + i === 0 + ? { chainNamespace, body } + : decodeEnvelope(a.actionPolicies[0].initData); + if (env.chainNamespace !== chainNamespace) { + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + `action ${i} names chain ${env.chainNamespace}, not ${chainNamespace}` + ); + } + return decodeNativeTerms(env.body); + }); + return nativeActionTermsToRule(terms, agent); }, }; diff --git a/packages/core/src/lib/agentic/wallet.ts b/packages/core/src/lib/agentic/wallet.ts index ddecd3a6d..c891d4e52 100644 --- a/packages/core/src/lib/agentic/wallet.ts +++ b/packages/core/src/lib/agentic/wallet.ts @@ -1,3 +1,4 @@ +import { publicRules } from './public-rule'; import { getAddress, type Address } from 'viem'; import { AgenticCapability, requireCapability } from './capabilities'; import { resolveWalletGeneration, type AgenticGeneration } from './deployments'; @@ -55,12 +56,36 @@ export class AgenticWalletHandle implements AgenticWallet { this.runtime, await this.gen(), this.address, - rules, + publicRules(rules), opts?.progressHook ), - update: async (params) => - updateRules(this.runtime, await this.gen(), this.address, params), - revoke: (async ( + update: async (params) => { + if ( + !params || + !Array.isArray(params.rules) || + params.rules.length === 0 || + params.rules.some( + (p) => + !p || + typeof p.rulesId !== 'string' || + !/^0x[0-9a-fA-F]{64}$/.test(p.rulesId) + ) + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'rules.update needs { rulesId, rule } pairs with 32-byte rule IDs' + ); + } + const rules = params.rules.map((p) => ({ + ...p, + rule: publicRules([p.rule])[0], + })); + return updateRules(this.runtime, await this.gen(), this.address, { + ...params, + rules, + }); + }, + delete: (async ( target: | AgenticHex[] | { all: true; progressHook?: AgenticProgressHook }, @@ -75,7 +100,7 @@ export class AgenticWalletHandle implements AgenticWallet { target, opts?.progressHook ); - }) as AgenticWallet['rules']['revoke'], + }) as AgenticWallet['rules']['delete'], }; } diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 940cf4c48..1767a5228 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -71,7 +71,11 @@ export type { export { parseUEAVersion } from './orchestrator/orchestrator.types'; // Route detection utilities -export { TransactionRoute, detectRoute, isChainTarget } from './orchestrator/route-detector'; +export { + TransactionRoute, + detectRoute, + isChainTarget, +} from './orchestrator/route-detector'; // Outbound gas cap helpers export { @@ -137,7 +141,17 @@ export type { ReadResponseTuple, BatchReadResponse, } from './read-state/read-state.types'; -export type { ReadOptions, ReadPrepareOptions, ReadQueryOptions, ReadCallbackOptions, ReadValue, ValidateReadCall, ReadWeb2Options, ReadTrackOptions, ReadExecuteOptions } from './read-state/read-params'; +export type { + ReadOptions, + ReadPrepareOptions, + ReadQueryOptions, + ReadCallbackOptions, + ReadValue, + ValidateReadCall, + ReadWeb2Options, + ReadTrackOptions, + ReadExecuteOptions, +} from './read-state/read-params'; export { UNIVERSAL_READ_STATUS, READ_STATUS, @@ -195,7 +209,6 @@ export type { AgenticErrorCode, AgenticHex, AgenticNamespace, - AgenticNetworkConstants, AgenticProgressHook, AgenticTxMetadata, AgenticWallet, @@ -206,18 +219,26 @@ export type { CreateOptions, CreateResult, ForeignChainNamespace, - NativeRule, - SolanaRule, DecodedSolanaRule, - SvmInstructionRule, - SvmAccountRef, - SvmFieldConstraint, Rule, + RuleInput, + Pin, + Spent, + WalletMarker, RulesRecord, Selector, - UniversalRule, WalletInfo, WalletSummary, } from './agentic'; export { PushChain }; + +export type { + Preset, + PresetCategory, + PresetAsset, + PresetOptions, + ReadyPresetOptions, + PresetRuleTemplate, + AgenticPresets, +} from './agentic'; diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts index 3fa4d9798..ca82e7baf 100644 --- a/packages/core/src/lib/utils.ts +++ b/packages/core/src/lib/utils.ts @@ -1,3 +1,4 @@ +import { agenticPresets } from './agentic/presets'; import { convertOriginToExecutor, fromChainAgnostic, @@ -31,7 +32,6 @@ import { import { PC20WrapperNotRegisteredError } from './orchestrator/internals/pc20/errors'; import { getPushChainForNetwork } from './orchestrator/internals/helpers'; import { Buffer } from 'buffer'; -import { agenticUtils } from './agentic/utils'; import type { Idl } from '@coral-xyz/anchor'; import { encodeAnchorIxData, @@ -872,12 +872,8 @@ export class Utils { }, }; - /** - * Agentic wallet (AGW) pure helpers: rulesId, actionId, configId, - * deriveWallet, encodeRules, decodeRules, compileCard. Generation context is - * explicit (provisional signatures, A04); no RPC is performed. - */ - static agentic = agenticUtils; + /** Pure preset discovery/templates; internal encoders and identifiers stay private. */ + static readonly agentic = Object.freeze({ presets: agenticPresets }); /** * Internal: resolves a CHAIN enum from either a CHAIN value or a PushChain client instance. From 6eafa0043f2bb14c4ede49ea15b1e35aa1451665 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Oct 2026 17:46:17 +0000 Subject: [PATCH 03/27] release(core): bump to 6.0.27-alpha.0 [skip ci] --- packages/core/CHANGELOG.md | 8 ++++++++ packages/core/package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 05b105b26..5e5f06df6 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,11 @@ +@pushchain/core@6.0.27-alpha.0 (2026-10-12) + +- feat: align AGW rules and presets with October SDK feedback +- feat: add AGW v5 wallet and rules SDK +- test: mirror the restructured Universal Read docs playgrounds + +--- + @pushchain/core@6.0.26 (2026-09-23) - docs: changelog for read-state docs parity diff --git a/packages/core/package.json b/packages/core/package.json index 7572d206c..012472b99 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.26", + "version": "6.0.27-alpha.0", "homepage": "https://push.org", "keywords": [ "pushchain", From 700a52a7285fac4c95d9519b332ecc1caca02b3b Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Thu, 15 Oct 2026 06:49:33 +0530 Subject: [PATCH 04/27] feat(core): migrate AGW SDK to deployed v6 contracts --- packages/core/AGW.md | 22 +- .../__agw-local__/agw-local-advanced.spec.ts | 68 +- .../__agw-local__/agw-local-races.spec.ts | 21 +- packages/core/__agw-local__/agw-local.spec.ts | 4 +- packages/core/__agw-local__/harness.ts | 13 +- packages/core/__agw-local__/labels.spec.ts | 14 +- .../__agw-local__/native-multi-action.spec.ts | 6 +- .../core/__agw-local__/svm-backend.spec.ts | 59 +- .../__agw-local__/svm-differential.spec.ts | 18 +- .../core/__agw-local__/svm-internals.spec.ts | 32 +- .../core/__agw-local__/svm-public.spec.ts | 16 +- .../core/__agw-local__/v5-multiasset.spec.ts | 82 +- packages/core/__agw-local__/vectors.spec.ts | 11 +- .../__e2e__/agw-svm-wire/outbound.spec.ts | 466 +++++++++-- .../core/__e2e__/agw-svm-wire/replay.spec.ts | 83 +- packages/core/__e2e__/agw/_manifest.ts | 65 +- packages/core/__e2e__/agw/create.spec.ts | 14 +- packages/core/__e2e__/agw/labels.spec.ts | 26 +- packages/core/__e2e__/agw/lifecycle.spec.ts | 4 +- .../__e2e__/agw/management-extended.spec.ts | 10 +- .../__e2e__/agw/native-multi-action.spec.ts | 26 +- .../agw/native-policy-extended.spec.ts | 12 +- packages/core/__e2e__/agw/svm-public.spec.ts | 64 +- .../core/__e2e__/agw/svm-spl-transfer.spec.ts | 22 +- .../core/__e2e__/agw/universal-evm.spec.ts | 116 ++- .../core/__e2e__/agw/v5-multiasset.spec.ts | 16 +- packages/core/__e2e__/ci/suite.ts | 8 + packages/core/__e2e__/shared/agw-state.ts | 10 +- .../__e2e__/shared/agw-svm-policy-probe.ts | 77 +- .../core/scripts/agw-local/abis/factory.json | 391 +++++++++ .../core/scripts/agw-local/abis/manifest.json | 13 +- .../core/scripts/agw-local/abis/policy.json | 761 ++++++++--------- .../core/scripts/agw-local/abis/wallet.json | 127 ++- packages/core/scripts/agw-local/gen-abi.mjs | 11 +- packages/core/scripts/agw-local/prepare.sh | 4 +- .../{v5-vectors.json => v6-vectors.json} | 29 +- .../src/lib/agentic/__tests__/codec.spec.ts | 67 +- .../src/lib/agentic/__tests__/fake-chain.ts | 15 +- .../src/lib/agentic/__tests__/labels.spec.ts | 8 +- .../lib/agentic/__tests__/management.spec.ts | 163 +--- .../__tests__/native-batch-response.spec.ts | 8 +- .../__tests__/native-multi-action.spec.ts | 6 +- .../__tests__/outbound-response.spec.ts | 6 +- .../src/lib/agentic/__tests__/presets.spec.ts | 67 +- .../lib/agentic/__tests__/public-rule.spec.ts | 73 +- .../agentic/__tests__/reads-context.spec.ts | 12 +- .../__tests__/response-surface.spec.ts | 10 +- .../agentic/__tests__/rules-policy.spec.ts | 19 +- .../src/lib/agentic/__tests__/send.spec.ts | 46 +- .../agentic/__tests__/svm-internals.spec.ts | 2 +- .../core/src/lib/agentic/agentic.types.ts | 5 +- packages/core/src/lib/agentic/capabilities.ts | 2 +- .../core/src/lib/agentic/codec/defaults.ts | 6 +- packages/core/src/lib/agentic/codec/ids.ts | 2 +- packages/core/src/lib/agentic/codec/native.ts | 48 +- packages/core/src/lib/agentic/codec/pins.ts | 32 + packages/core/src/lib/agentic/codec/rules.ts | 4 +- .../core/src/lib/agentic/codec/session.ts | 6 +- .../core/src/lib/agentic/codec/svm-terms.ts | 10 +- .../src/lib/agentic/codec/universal-terms.ts | 8 +- .../core/src/lib/agentic/codec/universal.ts | 28 +- .../abi/{v5 => v6}/agent-validator.ts | 2 +- .../contracts/abi/{v5 => v6}/agw-factory.ts | 395 ++++++++- .../agentic/contracts/abi/{v5 => v6}/agw.ts | 131 ++- .../agentic/contracts/abi/{v5 => v6}/index.ts | 4 +- .../contracts/abi/{v5 => v6}/smart-session.ts | 2 +- .../abi/{v5 => v6}/universal-rules-policy.ts | 765 +++++++++--------- .../src/lib/agentic/contracts/donut-v5.ts | 13 - .../src/lib/agentic/contracts/donut-v6.ts | 13 + .../lib/agentic/contracts/{v5.ts => v6.ts} | 59 +- packages/core/src/lib/agentic/deployments.ts | 44 +- packages/core/src/lib/agentic/errors.ts | 12 +- .../src/lib/agentic/execution/outbound.ts | 10 +- .../core/src/lib/agentic/execution/send.ts | 23 +- .../src/lib/agentic/execution/svm-outbound.ts | 2 +- .../src/lib/agentic/execution/svm-payload.ts | 3 +- .../src/lib/agentic/execution/svm-public.ts | 11 +- .../src/lib/agentic/execution/svm-send.ts | 11 - .../core/src/lib/agentic/management/common.ts | 58 +- .../core/src/lib/agentic/management/create.ts | 238 +----- .../core/src/lib/agentic/management/svm.ts | 2 +- .../core/src/lib/agentic/presets/index.ts | 26 +- packages/core/src/lib/agentic/public-rule.ts | 70 +- packages/core/src/lib/agentic/reads/rules.ts | 47 +- packages/core/src/lib/agentic/reads/svm.ts | 4 +- packages/core/src/lib/agentic/response.ts | 10 +- packages/core/src/lib/agentic/revert.ts | 2 +- packages/core/src/lib/agentic/utils.ts | 4 +- 88 files changed, 3455 insertions(+), 1880 deletions(-) rename packages/core/src/lib/agentic/__fixtures__/{v5-vectors.json => v6-vectors.json} (84%) create mode 100644 packages/core/src/lib/agentic/codec/pins.ts rename packages/core/src/lib/agentic/contracts/abi/{v5 => v6}/agent-validator.ts (97%) rename packages/core/src/lib/agentic/contracts/abi/{v5 => v6}/agw-factory.ts (66%) rename packages/core/src/lib/agentic/contracts/abi/{v5 => v6}/agw.ts (89%) rename packages/core/src/lib/agentic/contracts/abi/{v5 => v6}/index.ts (69%) rename packages/core/src/lib/agentic/contracts/abi/{v5 => v6}/smart-session.ts (99%) rename packages/core/src/lib/agentic/contracts/abi/{v5 => v6}/universal-rules-policy.ts (97%) delete mode 100644 packages/core/src/lib/agentic/contracts/donut-v5.ts create mode 100644 packages/core/src/lib/agentic/contracts/donut-v6.ts rename packages/core/src/lib/agentic/contracts/{v5.ts => v6.ts} (88%) diff --git a/packages/core/AGW.md b/packages/core/AGW.md index b2d919980..3a92f727d 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -1,6 +1,6 @@ # Agentic wallets on Push Chain -This branch implements the SDK-only October 12 feedback against the deployed Donut v5 contracts. Contract-dependent changes remain explicit below. +This branch implements the SDK-only October 12 feedback against the deployed Donut v6 contracts. The runtime targets v6 only; existing v5 wallets are not silently adapted. ## Connect @@ -83,7 +83,7 @@ async function manage(owner: PushChain, address: Address, id: Hex) { `create` and `rules.add` reject a duplicate agent/chain pair with `DUPLICATE_RULE`. Put several permitted calls in the same rule. Existing overlapping grants can still cause `AMBIGUOUS_RULE` when sending; the owner must resolve the overlap. The contract validates every actual agent call. -On deployed v5, native rules allow at most **eight** calls, eight equality pins and one maximum pin per call. Public native lifetime budgets, `maxCalls`, `amount`, single-call shorthand and `actions[]` are retired. Native counters still accumulate for reporting and atomic update assertions. +On deployed v6, native rules allow at most **eight** calls, eight equality/maximum pins per call. Public native lifetime budgets, `maxCalls`, `amount`, single-call shorthand and `actions[]` are retired. Native value and call counters accumulate for reporting and atomic updates. A v6 MAX pin only bounds an individual call and does not increment amountSpent; that counter belongs to the separate internal amount meter. ## Pins and zero defaults @@ -112,7 +112,7 @@ function evmRule(agent: Address, target: Address): Rule { } ``` -The deployed policy supports one `WALLET` equality pin per EVM destination call. General destination equality/maximum pins need the new contract deployment and currently fail with `CAPABILITY_UNAVAILABLE` before signing. +The deployed v6 policy supports up to eight EQ/MAX pins per EVM destination call and 32 across the rule. Multiple entries for the same target/function are alternatives; every constraint in one entry must pass. The SDK rejects a bare alternative beside a pinned one because it would bypass the pins. When the caller omits destination `gasLimit`, a fresh EVM CEA gets a 2,000,000 gas default to cover first-use deployment. An existing CEA uses the normal quote default. An explicit caller value is preserved. The rule's gas/value caps still apply. @@ -153,15 +153,17 @@ Connect with `PushChain.initialize(signer, { agenticWallet: address })` to send Updates assert existing spend, delete the old rule and grant its replacement atomically. Native updates assert each action separately: a rule with N native actions records N+4 checkpoints on current v5. Agent execution records no owner checkpoint. Direct `setLabel` records none. -## Deployment-dependent behavior +## V6 deployment and creation -The October 12 contract feedback is not deployed. Until it is verified: +The runtime uses factory `0xFBBEB6a365178fAaA81A9789a5f64b300c37e5bc` and URP `0xBA32e7A1500cb34311a31127bcD40CA52C8CFd36`, from source `ffd567d`. URP still reports 3.1.0; verified deployment wiring and the v6 ABI identify the supported generation. -- Outbound sends on both doors require a separately owner-set gateway allowance; the SDK never silently creates or restores one. Insufficient allowance fails before signing. -- Creation uses the existing index-bound deploy/grant flow. If a signer cannot execute it atomically, `CREATE_PARTIAL` preserves committed receipts and recovery details. Do not blindly retry creation. -- General EVM destination pins and the new atomic factory `createWallet` are unavailable. +`create` uses one index-bound `createWalletWithSig` call from the owner's Push identity, with the contract's owner-caller empty-signature path. The wallet and up to eight initial rules are committed atomically. A concurrent slot change returns INDEX_RACE; a receipt-read failure retains the submitted hash for recovery. CREATE_PARTIAL is removed. No sequential creation fallback exists. -`rules.delete` is the public name; on-chain `revokeRules` names/events remain unchanged. Unsupported capabilities use `CAPABILITY_UNAVAILABLE`. On-chain errors retain revert details through `AgenticRevertError`/`PushChainExecutionError`. +For outbound sends on either door, the wallet approves the gateway inside the transaction after policy authorization, executes the outbound, and clears remaining allowance. No separate approval or SDK allowance check is needed; GATEWAY_ALLOWANCE_INSUFFICIENT is removed. Approval/cleanup uses the contract's best-effort token calls. To stop an agent, delete its rules; clearing the gateway allowance does not disable an otherwise valid rule. + +The wire encoder uses uint32 maximum for unlimited native calls and 255 for unspecified Solana account count. Neither uses the retired zero sentinel. The envelope remains version 1. Existing v5 wallets and addresses do not become v6 wallets. + +`rules.delete` is the public name; on-chain `revokeRules` names/events remain unchanged. Revert errors retain decoded details and cause. Core transaction response fields used by other SDK routes are not globally removed. ## Preset discovery and templates (section 7) @@ -185,7 +187,7 @@ function filledPreset(agent: Address) { With catalog entries, `get` groups calls into one rule per chain. With agent and expiry supplied, its return type is `Rule[]`; otherwise it is `PresetRuleTemplate[]`, requiring those fields before `create`. Assets must name their chain unless a single chain filter is supplied. Missing `maxPerCall` or `maxTotal` defaults to zero (user decision, October 12), so missing caps do not grant token movement. -Reviewed EVM catalog spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Native assets need no approval. Conflicting spenders for the same token are rejected until the policy representation is reviewed; the framework never replaces them with an unrestricted approval. General EVM approval pins still require the pending contract upgrade, and normal `create/add` validation enforces that boundary. Presets do not sign, fetch app ABIs or modify wallets. +Reviewed EVM catalog spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Native assets need no approval. Distinct spenders for the same token become separate fully pinned alternatives under v6; unrestricted alternatives are rejected. Presets do not sign, fetch app ABIs or modify wallets. The remaining catalog work includes selecting verified apps/functions/chains, struct-field indexing and checking approval combinations against the delivered policy. App ABIs remain with the UI. diff --git a/packages/core/__agw-local__/agw-local-advanced.spec.ts b/packages/core/__agw-local__/agw-local-advanced.spec.ts index 9e801d6d7..8dd9c3606 100644 --- a/packages/core/__agw-local__/agw-local-advanced.spec.ts +++ b/packages/core/__agw-local__/agw-local-advanced.spec.ts @@ -70,7 +70,7 @@ const fund = (h: Harness, to: Address, value: bigint) => account: h.wallets[4].account!, chain: h.wallets[4].chain, }); -describe('AGW advanced paths against real v5 contracts', () => { +describe('AGW advanced paths against real v6 contracts', () => { let h: Harness; let owner: PushChain; let agentAddr: Address; @@ -143,7 +143,7 @@ describe('AGW advanced paths against real v5 contracts', () => { const rec = await owner.agentic.wallet(wallet).rules.list(); expect(rec.rules[0].spent).toMatchObject({ kind: 'native', - amountSpent: BigInt(5), + amountSpent: BigInt(0), callsUsed: 1, }); expect( @@ -153,7 +153,7 @@ describe('AGW advanced paths against real v5 contracts', () => { wallet, rec.rules[0].rulesId ) - ).toMatchObject({ amountSpent: BigInt(5), callsUsed: 1 }); + ).toMatchObject({ amountSpent: BigInt(0), callsUsed: 1 }); }); it('per-call amounts are enforced and repeated valid calls remain usable', async () => { await expect(deposit(beneficiary, BigInt(11))).rejects.toBeInstanceOf( @@ -281,8 +281,9 @@ describe('AGW advanced paths against real v5 contracts', () => { expect(err.decodedError?.name).toBe('PolicyCheckReverted(RulesExpired)'); }); }); - describe('sequential create partial failure and recovery', () => { - it('reports CREATE_PARTIAL with the deployed wallet and granted IDs; rules.add finishes it', async () => { + describe('atomic create rollback', () => { + it('a rejected initial rule rolls back wallet creation and every grant', async () => { + const predicted = await owner.agentic.derive(); // Chain time runs ahead of the local clock: the SDK accepts validUntil, the policy does not. const skewedValidUntil = Math.floor(Date.now() / 1000) + 600; await warp(h, 7200); @@ -312,27 +313,11 @@ describe('AGW advanced paths against real v5 contracts', () => { ], }) .catch((e) => e); - expect(err).toBeInstanceOf(AgenticError); - expect(err.code).toBe(AGENTIC_ERROR_CODE.CREATE_PARTIAL); - expect(err.details.walletDeployed).toBe(true); - expect(err.details.grantedRulesIds).toHaveLength(1); - expect(err.details.confirmedHashes.length).toBeGreaterThanOrEqual(2); - const wallet = err.details.wallet as Address; - const added = await owner.agentic.wallet(wallet).rules.add([ - { - agent: h.wallets[3].account!.address, - validUntil: (await chainNow(h)) + 3600, - allowedCalls: [ - { - target: h.addresses.target, - selector: 'increment()', - }, - ], - }, - ]); - expect(added.rulesIds).toHaveLength(1); - const { rules } = await owner.agentic.wallet(wallet).rules.list(); - expect(rules).toHaveLength(2); + expect(err).toBeInstanceOf(AgenticRevertError); + expect(await owner.agentic.derive()).toEqual(predicted); + expect( + await h.publicClient.getCode({ address: predicted.address }) + ).toBeUndefined(); }); it('rules.add with several rules is one atomic owner batch: OWNER_ACTION + RULES_GRANTED per rule', async () => { const created = await owner.agentic.create('multi-add', { rules: [] }); @@ -378,7 +363,7 @@ describe('AGW advanced paths against real v5 contracts', () => { expect(before).toBe(BigInt(0)); }); }); - describe('agent EVM outbound (v5 multi-asset terms, stub gateway)', () => { + describe('agent EVM outbound (v6 multi-asset terms, stub gateway)', () => { const SEPOLIA = CHAIN.ETHEREUM_SEPOLIA; const CEA = getAddress('0x000000000000000000000000000000000000cea1'); const destTarget = getAddress('0x00000000000000000000000000000000000d0d0d'); @@ -399,7 +384,7 @@ describe('AGW advanced paths against real v5 contracts', () => { await h.publicClient.waitForTransactionReceipt({ hash: await fund(h, wallet, BigInt(10) ** BigInt(17)), }); - // Fixture setup (raw owner call): grant a v5 multi-asset universal rule. + // Fixture setup (raw owner call): grant a v6 multi-asset universal rule. const terms = encodeUniversalTerms({ validUntil: (await chainNow(h)) + 3600, expectedCEA: CEA, @@ -411,6 +396,7 @@ describe('AGW advanced paths against real v5 contracts', () => { { target: destTarget, selector: '0xd09de08a', + pins: [], beneficiaryOffset: 0, hasBeneficiary: false, maxValue: BigInt(0), @@ -479,10 +465,14 @@ describe('AGW advanced paths against real v5 contracts', () => { }, }, }); - it('fails before signing when the owner has not set a gateway allowance', async () => { - await expect(send(BigInt(100))).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, - }); + it('sends successfully without an owner gateway allowance', async () => { + expect( + ( + await h.publicClient.waitForTransactionReceipt({ + hash: (await send(BigInt(100))).hash as Hex, + }) + ).status + ).toBe('success'); }); it('owner sets a bounded allowance with an ordinary owner-door send; the agent outbound passes URP, pulls and burns', async () => { await ( @@ -526,7 +516,7 @@ describe('AGW advanced paths against real v5 contracts', () => { functionName: 'allowance', args: [wallet, h.addresses.gateway], }) - ).toBe(BigInt(200)); + ).toBe(BigInt(0)); // The request the wallet sent: empty recipient, wallet refund, nonzero gas cap, multicall payload. const payload = `0x2cc2842d${encodeAbiParameters( [ @@ -589,7 +579,7 @@ describe('AGW advanced paths against real v5 contracts', () => { }); expect(keccak256(toBytes(SEPOLIA))).toBeTruthy(); }); - it('owner can remove the allowance; the agent is stopped before signing again', async () => { + it('zero standing allowance does not revoke an otherwise valid rule', async () => { await ( await ownerAgw.universal.sendTransaction({ to: token, @@ -600,9 +590,13 @@ describe('AGW advanced paths against real v5 contracts', () => { }), }) ).wait(); - await expect(send(BigInt(10))).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, - }); + expect( + ( + await h.publicClient.waitForTransactionReceipt({ + hash: (await send(BigInt(10))).hash as Hex, + }) + ).status + ).toBe('success'); }); it('a call outside the allow-list is rejected by URP on-chain', async () => { await ( diff --git a/packages/core/__agw-local__/agw-local-races.spec.ts b/packages/core/__agw-local__/agw-local-races.spec.ts index efe87bae7..251b8ddd4 100644 --- a/packages/core/__agw-local__/agw-local-races.spec.ts +++ b/packages/core/__agw-local__/agw-local-races.spec.ts @@ -1,15 +1,15 @@ /** * Race regressions found during independent SDK review, - * against the real pinned v5 contracts on local anvil. + * against the real pinned v6 contracts on local anvil. * R1 — a concurrent creation must never let this create() grant on a wallet it * did not deploy (the deploy is index-bound through deployWalletWithSig). * R4 — an owner outbound must never restore an allowance revoked after the * SDK read it (the SDK never writes allowances). */ -import { encodeFunctionData, parseAbi, type Address } from 'viem'; +import { encodeFunctionData, parseAbi, type Address, type Hex } from 'viem'; import { AgenticRevertError, CHAIN, type PushChain } from '../src'; import type { AgenticRuntime } from '../src/lib/agentic/runtime'; -import { v5 } from '../src/lib/agentic/contracts/v5'; +import { v6 } from '../src/lib/agentic/contracts/v6'; import { AGENTIC_ERROR_CODE } from '../src/lib/agentic/errors'; import { startHarness, type Harness } from './harness'; const TOKEN = parseAbi([ @@ -23,7 +23,7 @@ const internalsOf = (c: PushChain) => agenticRuntime: AgenticRuntime; } ).agenticRuntime; -describe('AGW race regressions (real v5 contracts)', () => { +describe('AGW race regressions (real v6 contracts)', () => { let h: Harness; let owner: PushChain; beforeAll(async () => { @@ -87,7 +87,7 @@ describe('AGW race regressions (real v5 contracts)', () => { const retried = await owner.agentic.create('intended', { rules: [] }); expect(retried.index).toBe(next.index); }); - it('R4 an allowance revoked after the SDK read it stays revoked; the outbound fails on-chain', async () => { + it('v6 outbound succeeds after a standing allowance is revoked and leaves zero allowance', async () => { const { wallet } = await owner.agentic.create('allowance-race', { rules: [], }); @@ -129,7 +129,7 @@ describe('AGW race regressions (real v5 contracts)', () => { const execute = rt.execute; rt.execute = async (params, options) => { rt.execute = execute; - const revoke = v5.encodeExecute([ + const revoke = v6.encodeExecute([ { target: token, value: BigInt(0), @@ -156,8 +156,13 @@ describe('AGW race regressions (real v5 contracts)', () => { .finally(() => { rt.execute = execute; }); - expect(err).toBeInstanceOf(AgenticRevertError); - expect(err.message).toContain('0x10bad147'); // LowAllowance() — the token refused the pull + expect( + ( + await h.publicClient.waitForTransactionReceipt({ + hash: err.hash as Hex, + }) + ).status + ).toBe('success'); expect( await h.publicClient.readContract({ address: token, diff --git a/packages/core/__agw-local__/agw-local.spec.ts b/packages/core/__agw-local__/agw-local.spec.ts index d3ae46d2a..efab53b98 100644 --- a/packages/core/__agw-local__/agw-local.spec.ts +++ b/packages/core/__agw-local__/agw-local.spec.ts @@ -1,6 +1,6 @@ import { readNativeCounters } from '../__e2e__/shared/agw-state'; /** - * Real-contract checks of the AGW SDK against the pinned v5 contracts on a + * Real-contract checks of the AGW SDK against the pinned v6 contracts on a * local anvil chain (see harness.ts for scope and limits). Every assertion * about authorization, atomicity, counters and checkpoints is decided by the * real contracts; the only stubbed component is the gateway. @@ -32,7 +32,7 @@ const WALLET_ABI = parseAbi([ 'function owner() view returns (address)', ]); const inAnHour = () => Math.floor(Date.now() / 1000) + 3600; -describe('AGW SDK against real v5 contracts (local anvil)', () => { +describe('AGW SDK against real v6 contracts (local anvil)', () => { let h: Harness; let owner: PushChain; let ownerAddr: Address; diff --git a/packages/core/__agw-local__/harness.ts b/packages/core/__agw-local__/harness.ts index 22ad93283..ce2ac8943 100644 --- a/packages/core/__agw-local__/harness.ts +++ b/packages/core/__agw-local__/harness.ts @@ -220,6 +220,7 @@ async function deployHarness( ); const clients = new Set(); + let svmLibrary: Address | undefined; const deploy = async ( walletIndex: number, name: string, @@ -227,6 +228,14 @@ async function deployHarness( args: readonly unknown[] = [] ) => { const art = artifact(dir, file, name); + if (name === 'UniversalRulesPolicy') { + if (!svmLibrary) + svmLibrary = await deploy(0, 'SvmRulebook', 'SvmRulebook.sol'); + art.bytecode = art.bytecode.replace( + /__\$[0-9a-fA-F]{34}\$__/g, + svmLibrary.slice(2) + ) as Hex; + } const hash = await wallets[walletIndex].deployContract({ abi: art.abi, bytecode: art.bytecode, @@ -353,8 +362,8 @@ async function deployHarness( resetAgenticGenerations(); const generation = registerAgenticGeneration({ - id: 'v5', - sourceCommit: '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb', + id: 'v6', + sourceCommit: 'ffd567d3e49b2d27c6f50ad36093274dbac3090f', network: PUSH_NETWORK.LOCALNET, advertised: false, addresses: { diff --git a/packages/core/__agw-local__/labels.spec.ts b/packages/core/__agw-local__/labels.spec.ts index 1f377f13d..d03f51b5a 100644 --- a/packages/core/__agw-local__/labels.spec.ts +++ b/packages/core/__agw-local__/labels.spec.ts @@ -1,11 +1,11 @@ /** Real v5 wallet/factory label state; no bridge or destination simulation. */ import { decodeEventLog, type Address } from 'viem'; import { PushChain } from '../src'; -import { v5 } from '../src/lib/agentic/contracts/v5'; +import { v6 } from '../src/lib/agentic/contracts/v6'; import { AGENTIC_ERROR_CODE } from '../src/lib/agentic/errors'; import { startHarness, type Harness } from './harness'; -describe('v5 label SDK against real contracts', () => { +describe('v6 label SDK against real contracts', () => { let h: Harness; let owner: PushChain; beforeAll(async () => { @@ -18,7 +18,7 @@ describe('v5 label SDK against real contracts', () => { const count = (address: Address) => h.publicClient.readContract({ address, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); @@ -50,7 +50,7 @@ describe('v5 label SDK against real contracts', () => { if (log.address.toLowerCase() !== made.wallet.toLowerCase()) return []; try { const ev = decodeEventLog({ - abi: v5.abis.wallet, + abi: v6.abis.wallet, data: log.data, topics: log.topics, }); @@ -78,7 +78,7 @@ describe('v5 label SDK against real contracts', () => { it('direct contract rename changes no checkpoint; subsequent SDK reads show it', async () => { const made = await owner.agentic.create('before', { rules: [] }); const before = await count(made.wallet); - await h.write(0, made.wallet, v5.abis.wallet, 'setLabel', ['direct']); + await h.write(0, made.wallet, v6.abis.wallet, 'setLabel', ['direct']); expect(await count(made.wallet)).toBe(before); expect((await owner.agentic.wallet(made.wallet).info()).label).toBe( 'direct' @@ -102,7 +102,7 @@ describe('v5 label SDK against real contracts', () => { await expect( h.publicClient.simulateContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'setLabel', args: [invalid], account: h.wallets[0].account!.address, @@ -127,7 +127,7 @@ describe('v5 label SDK against real contracts', () => { await expect( h.publicClient.simulateContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'setLabel', args: ['attack'], account: h.wallets[1].account!.address, diff --git a/packages/core/__agw-local__/native-multi-action.spec.ts b/packages/core/__agw-local__/native-multi-action.spec.ts index ecd23af52..f49dddc61 100644 --- a/packages/core/__agw-local__/native-multi-action.spec.ts +++ b/packages/core/__agw-local__/native-multi-action.spec.ts @@ -1,5 +1,5 @@ /** - * Multi-action native rules against the real pinned v5 contracts: one rule ID + * Multi-action native rules against the real pinned v6 contracts: one rule ID * allows several Push functions, each metered by its own stored terms. * Unified section-6 public shape; the contract support * (AGW MAX_NATIVE_ACTIONS = 8) is what these tests exercise. @@ -73,7 +73,7 @@ async function rule(): Promise { ], }; } -describe('multi-action native rules (real v5 contracts)', () => { +describe('multi-action native rules (real v6 contracts)', () => { let wallet: Address; let rulesId: Hex; let agent: PushChain; @@ -186,7 +186,7 @@ describe('multi-action native rules (real v5 contracts)', () => { kind: 'native', actions: [ { callsUsed: 3, amountSpent: BigInt(0) }, - { callsUsed: 2, amountSpent: BigInt(10) }, + { callsUsed: 2, amountSpent: BigInt(0) }, ], }); }); diff --git a/packages/core/__agw-local__/svm-backend.spec.ts b/packages/core/__agw-local__/svm-backend.spec.ts index 57046e94a..c51453c55 100644 --- a/packages/core/__agw-local__/svm-backend.spec.ts +++ b/packages/core/__agw-local__/svm-backend.spec.ts @@ -1,4 +1,4 @@ -/** Internal SVM lifecycle/execution on actual v5 contracts; no Solana/TSS simulation. */ +/** Internal SVM lifecycle/execution on actual v6 contracts; no Solana/TSS simulation. */ import { PublicKey } from '@solana/web3.js'; import { encodeFunctionData, @@ -29,7 +29,7 @@ import { registerIdl, clearRegistry, } from '../src/lib/orchestrator/svm-idl/registry'; -import { v5 } from '../src/lib/agentic/contracts/v5'; +import { v6 } from '../src/lib/agentic/contracts/v6'; import { wrapSendError } from '../src/lib/agentic/management/common'; import { startHarness, type Harness } from './harness'; @@ -41,6 +41,7 @@ const GATEWAY = key('22'), const SPL = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', TOKEN2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; const TOKEN = parseAbi([ + 'function allowance(address,address) view returns(uint256)', 'function mint(address,uint256)', 'function approve(address,uint256) returns (bool)', 'function setSourceTokenAddress(string)', @@ -60,7 +61,7 @@ const metadata: SvmMetadataProvider = { }), }; -describe('internal prepared SVM backend on v5', () => { +describe('internal prepared SVM backend on v6', () => { let h: Harness, owner: PushChain, tokens: Address[]; beforeAll(async () => { h = await startHarness(18554); @@ -173,9 +174,9 @@ describe('internal prepared SVM backend on v5', () => { }); for (const token of tokens) { await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(100)]); - await h.write(0, wallet, v5.abis.wallet, 'execute', [ + await h.write(0, wallet, v6.abis.wallet, 'execute', [ key('00'), - v5.packSingle({ + v6.packSingle({ target: token, value: BigInt(0), data: encodeFunctionData({ @@ -241,11 +242,11 @@ describe('internal prepared SVM backend on v5', () => { ).rejects.toMatchObject({ code: 'NOT_OWNER_OR_AGENT' }); expect(execute).not.toHaveBeenCalled(); }); - it('requires separate allowance and never writes an approval on the agent path', async () => { + it('automatically approves and clears the gateway on the agent path', async () => { const s = await setup(); - await h.write(0, s.wallet, v5.abis.wallet, 'execute', [ + await h.write(0, s.wallet, v6.abis.wallet, 'execute', [ key('00'), - v5.packSingle({ + v6.packSingle({ target: tokens[2], value: BigInt(0), data: encodeFunctionData({ @@ -256,10 +257,22 @@ describe('internal prepared SVM backend on v5', () => { }), ]); const execute = jest.spyOn(rt(s.agent), 'execute'); - await expect( - sendSvmAgentWire(rt(s.agent), h.generation, s.request(), metadata) - ).rejects.toMatchObject({ code: 'GATEWAY_ALLOWANCE_INSUFFICIENT' }); - expect(execute).not.toHaveBeenCalled(); + const sent = await sendSvmAgentWire( + rt(s.agent), + h.generation, + s.request(), + metadata + ); + expect((await sent.tx.wait()).status).toBe(1); + expect(execute).toHaveBeenCalledTimes(1); + expect( + await h.publicClient.readContract({ + address: tokens[2], + abi: TOKEN, + functionName: 'allowance', + args: [s.wallet, h.addresses.gateway], + }) + ).toBe(BigInt(0)); }); it('atomically replaces and resets every asset, recording five checkpoints', async () => { const s = await setup(); @@ -268,7 +281,7 @@ describe('internal prepared SVM backend on v5', () => { ).tx.wait(); const before = await h.publicClient.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); const next = await replaceSvmWire( @@ -287,7 +300,7 @@ describe('internal prepared SVM backend on v5', () => { expect( await h.publicClient.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) ).toBe(before + BigInt(5)); @@ -311,7 +324,7 @@ describe('internal prepared SVM backend on v5', () => { ).tx.wait(); const before = await h.publicClient.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); const error = await rt(owner) @@ -324,7 +337,7 @@ describe('internal prepared SVM backend on v5', () => { expect( await h.publicClient.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) ).toBe(before); @@ -394,7 +407,7 @@ describe('internal prepared SVM backend on v5', () => { rt(owner).execute = execute; } }); - it.each(['allowance', 'balance', 'revocation'])( + it.each(['balance', 'revocation'])( 'on-chain %s change after preflight refuses submission without metering', async (kind) => { const s = await setup(), @@ -412,9 +425,9 @@ describe('internal prepared SVM backend on v5', () => { await owner.agentic.wallet(s.wallet).rules.delete([s.id]) ).wait(); else - await h.write(0, s.wallet, v5.abis.wallet, 'execute', [ + await h.write(0, s.wallet, v6.abis.wallet, 'execute', [ key('00'), - v5.packSingle({ + v6.packSingle({ target: tokens[2], value: BigInt(0), data: encodeFunctionData({ @@ -434,7 +447,7 @@ describe('internal prepared SVM backend on v5', () => { ]); ticks = await h.publicClient.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); return original(params, options); @@ -459,7 +472,7 @@ describe('internal prepared SVM backend on v5', () => { } const stored = await h.publicClient.readContract({ address: h.addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getSvmConfig', args: [before.configId, s.wallet], }); @@ -477,7 +490,7 @@ describe('internal prepared SVM backend on v5', () => { expect( await h.publicClient.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) ).toBe(ticks); @@ -537,7 +550,7 @@ describe('internal prepared SVM backend on v5', () => { expect( await h.publicClient.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'grantNonce', }) ).toBe(BigInt(2)); diff --git a/packages/core/__agw-local__/svm-differential.spec.ts b/packages/core/__agw-local__/svm-differential.spec.ts index 520261555..53385abba 100644 --- a/packages/core/__agw-local__/svm-differential.spec.ts +++ b/packages/core/__agw-local__/svm-differential.spec.ts @@ -16,7 +16,7 @@ import { encodeAgwSvmPayload, validateAgwSvmPayload, } from '../src/lib/agentic/execution/svm-payload'; -import { v5 } from '../src/lib/agentic/contracts/v5'; +import { v6 } from '../src/lib/agentic/contracts/v6'; import { deriveAgwSvmValueAccounts } from '../src/lib/agentic/codec/svm-accounts'; import { decodeAgenticRevert } from '../src/lib/agentic/revert'; import { startHarness, type Harness } from './harness'; @@ -208,7 +208,7 @@ describe('seeded SVM differential validation', () => { try { await h.publicClient.call({ to: wallet, - data: v5.encodeGrantRules(session(c.terms)), + data: v6.encodeGrantRules(session(c.terms)), account: h.wallets[0].account!.address, }); } catch (e) { @@ -237,7 +237,7 @@ describe('seeded SVM differential validation', () => { it('payload mutation corpus agrees with actual URP authorization and rejects without metering', async () => { const t = clone(terms); - t.programs[0].maxAccounts = 0; + t.programs[0].maxAccounts = 255; t.dataPins = [ { ruleIndex: 0, @@ -251,10 +251,10 @@ describe('seeded SVM differential validation', () => { den: BigInt(0), }, ]; - await h.write(0, wallet, v5.abis.wallet, 'grantRules', [session(t)]); + await h.write(0, wallet, v6.abis.wallet, 'grantRules', [session(t)]); const [id] = await h.publicClient.readContract({ address: h.addresses.engine, - abi: v5.abis.engine, + abi: v6.abis.engine, functionName: 'getPermissionIDs', args: [wallet], }); @@ -267,9 +267,9 @@ describe('seeded SVM differential validation', () => { }), }); await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(100)]); - await h.write(0, wallet, v5.abis.wallet, 'execute', [ + await h.write(0, wallet, v6.abis.wallet, 'execute', [ key('00'), - v5.packSingle({ + v6.packSingle({ target: token, value: BigInt(0), data: encodeFunctionData({ @@ -370,12 +370,12 @@ describe('seeded SVM differential validation', () => { try { await h.publicClient.simulateContract({ address: wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'executeAsAgent', args: [ id, key('00'), - v5.packSingle({ + v6.packSingle({ target: h.addresses.gateway, value: BigInt(11) * BigInt(10) ** BigInt(14), data, diff --git a/packages/core/__agw-local__/svm-internals.spec.ts b/packages/core/__agw-local__/svm-internals.spec.ts index 24163e4d3..9145a861f 100644 --- a/packages/core/__agw-local__/svm-internals.spec.ts +++ b/packages/core/__agw-local__/svm-internals.spec.ts @@ -1,4 +1,4 @@ -/** Actual v5 SVM URP checks on Anvil. No Solana execution/TSS is simulated. */ +/** Actual v6 SVM URP checks on Anvil. No Solana execution/TSS is simulated. */ import { encodeFunctionData, parseAbi, type Address, type Hex } from 'viem'; import { CHAIN } from '../src/lib/constants/enums'; import { buildSession, encodeEnvelope } from '../src/lib/agentic/codec/session'; @@ -12,7 +12,7 @@ import { import { deriveAgwSvmValueAccounts } from '../src/lib/agentic/codec/svm-accounts'; import { encodeAgwSvmPayload } from '../src/lib/agentic/execution/svm-payload'; import { composeAgwSvmAction } from '../src/lib/agentic/execution/svm-outbound'; -import { v5, type SvmConfigRead } from '../src/lib/agentic/contracts/v5'; +import { v6, type SvmConfigRead } from '../src/lib/agentic/contracts/v6'; import { startHarness, type Harness } from './harness'; const key = (b: string) => `0x${b.repeat(32)}` as Hex; @@ -25,7 +25,7 @@ const TOKEN = parseAbi([ ]); const UINT256_MAX = BigInt(2) ** BigInt(256) - BigInt(1); -describe('internal SVM codec/payloads against actual v5 contracts', () => { +describe('internal SVM codec/payloads against actual v6 contracts', () => { let h: Harness, token: Address; beforeAll(async () => { h = await startHarness(18552); @@ -57,7 +57,7 @@ describe('internal SVM codec/payloads against actual v5 contracts', () => { discriminator: '0x0100000000000000', discriminatorLen: options.dataless ? 0 : 1, dataless: options.dataless ?? false, - maxAccounts: options.maxAccounts ?? 0, + maxAccounts: options.maxAccounts ?? 255, }, ], pins: [ @@ -92,23 +92,23 @@ describe('internal SVM codec/payloads against actual v5 contracts', () => { }, ], }); - await h.write(0, wallet, v5.abis.wallet, 'grantRules', [session]); + await h.write(0, wallet, v6.abis.wallet, 'grantRules', [session]); const [id] = await h.publicClient.readContract({ address: h.addresses.engine, - abi: v5.abis.engine, + abi: v6.abis.engine, functionName: 'getPermissionIDs', args: [wallet], }); const [action] = await h.publicClient.readContract({ address: h.addresses.engine, - abi: v5.abis.engine, + abi: v6.abis.engine, functionName: 'getEnabledActions', args: [wallet, id], }); const cid = configId(wallet, id, action); const cfg = (await h.publicClient.readContract({ address: h.addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getSvmConfig', args: [cid, wallet], })) as SvmConfigRead; @@ -121,9 +121,9 @@ describe('internal SVM codec/payloads against actual v5 contracts', () => { }), }); await h.write(0, token, TOKEN, 'mint', [wallet, BigInt(100)]); - await h.write(0, wallet, v5.abis.wallet, 'execute', [ + await h.write(0, wallet, v6.abis.wallet, 'execute', [ key('00'), - v5.packSingle({ + v6.packSingle({ target: token, value: BigInt(0), data: encodeFunctionData({ @@ -175,12 +175,12 @@ describe('internal SVM codec/payloads against actual v5 contracts', () => { }); return h.publicClient.simulateContract({ address: wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'executeAsAgent', args: [ id, key('00'), - v5.packSingle({ + v6.packSingle({ target: h.addresses.gateway, value: call.value, data, @@ -242,14 +242,14 @@ describe('internal SVM codec/payloads against actual v5 contracts', () => { }); it('the internal composed call passes actual S1–S18 and meters its token', async () => { const { wallet, id, cid, call } = await setup(); - await h.write(1, wallet, v5.abis.wallet, 'executeAsAgent', [ + await h.write(1, wallet, v6.abis.wallet, 'executeAsAgent', [ id, key('00'), - v5.packSingle(call), + v6.packSingle(call), ]); const cfg = await h.publicClient.readContract({ address: h.addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getSvmConfig', args: [cid, wallet], }); @@ -319,7 +319,7 @@ describe('internal SVM codec/payloads against actual v5 contracts', () => { ); const cfg = await h.publicClient.readContract({ address: h.addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getSvmConfig', args: [s.cid, s.wallet], }); diff --git a/packages/core/__agw-local__/svm-public.spec.ts b/packages/core/__agw-local__/svm-public.spec.ts index d28448e27..bd78096e9 100644 --- a/packages/core/__agw-local__/svm-public.spec.ts +++ b/packages/core/__agw-local__/svm-public.spec.ts @@ -13,7 +13,7 @@ import { } from 'viem'; import { CHAIN, PushChain, AgenticRevertError, type Rule } from '../src'; import type { AgenticRuntime } from '../src/lib/agentic/runtime'; -import { v5 } from '../src/lib/agentic/contracts/v5'; +import { v6 } from '../src/lib/agentic/contracts/v6'; import { clearRegistry } from '../src/lib/orchestrator/svm-idl/registry'; import { startHarness, type Harness } from './harness'; const program = new PublicKey(Buffer.alloc(32, 51)).toBase58(); @@ -40,7 +40,7 @@ const TOKEN = parseAbi([ const CORE = parseAbi(['function setGasToken(string,address)']); const rt = (c: PushChain) => (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; -describe('public Solana rules on actual v5', () => { +describe('public Solana rules on actual v6', () => { let h: Harness, owner: PushChain, token: Address; beforeAll(async () => { h = await startHarness(18561); @@ -136,9 +136,9 @@ describe('public Solana rules on actual v5', () => { }), }); await h.write(0, token, TOKEN, 'mint', [made.wallet, BigInt(20)]); - await h.write(0, made.wallet, v5.abis.wallet, 'execute', [ + await h.write(0, made.wallet, v6.abis.wallet, 'execute', [ `0x${'00'.repeat(32)}`, - v5.packSingle({ + v6.packSingle({ target: token, value: BigInt(0), data: encodeFunctionData({ @@ -232,9 +232,9 @@ describe('public Solana rules on actual v5', () => { }), }); await h.write(0, token, TOKEN, 'mint', [made.wallet, BigInt(10)]); - await h.write(0, made.wallet, v5.abis.wallet, 'execute', [ + await h.write(0, made.wallet, v6.abis.wallet, 'execute', [ `0x${'00'.repeat(32)}`, - v5.packSingle({ + v6.packSingle({ target: token, value: BigInt(0), data: encodeFunctionData({ @@ -401,9 +401,9 @@ describe('public Solana rules on actual v5', () => { }), }); await h.write(0, spl, TOKEN, 'mint', [made.wallet, BigInt(10)]); - await h.write(0, made.wallet, v5.abis.wallet, 'execute', [ + await h.write(0, made.wallet, v6.abis.wallet, 'execute', [ `0x${'00'.repeat(32)}`, - v5.packSingle({ + v6.packSingle({ target: spl, value: BigInt(0), data: encodeFunctionData({ diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 2fe6edf43..40659c47b 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -1,6 +1,6 @@ import { buildPresetApi } from '../src/lib/agentic/presets'; import { publicRules } from '../src/lib/agentic/public-rule'; -/** Actual v5 AGW/URP/engine. Token/core/gateway and destination lookup are local fixtures; no bridge/TSS proof. */ +/** Actual v6 AGW/URP/engine. Token/core/gateway and destination lookup are local fixtures; no bridge/TSS proof. */ import { encodeFunctionData, getAddress, @@ -36,7 +36,7 @@ const DEST = getAddress('0x0000000000000000000000000000000000001234'); const internal = (c: PushChain) => (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; -describe('v5 multi-asset SDK management and execution', () => { +describe('v6 multi-asset SDK management and execution', () => { let h: Harness, owner: PushChain, a: Address, b: Address; beforeAll(async () => { h = await startHarness(18551); @@ -152,6 +152,82 @@ describe('v5 multi-asset SDK management and execution', () => { args: [configId(wallet, id, action), wallet], }); } + it('v6 EVM alternatives enforce both spender and MAX, and reads update losslessly', async () => { + const input = rule(); + input.allowedCalls = [DEST, CEA].map((spender) => ({ + target: a, + selector: 'approve(address,uint256)', + pins: [ + { arg: 0, expected: spender }, + { arg: 1, max: BigInt(5) }, + ], + })); + const { wallet, id, agent } = await setup(input); + const send = (spender: Address, amount: bigint) => + agent.universal.sendTransaction({ + to: { address: a, chain: CHAIN_NS }, + data: [ + { + to: a, + value: BigInt(0), + data: encodeFunctionData({ + abi: TOKEN, + functionName: 'approve', + args: [spender, amount], + }), + }, + ], + }); + await waitPush(send(DEST, BigInt(5))); + await waitPush(send(CEA, BigInt(4))); + await expect( + send(h.wallets[3].account!.address, BigInt(1)) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect(send(DEST, BigInt(6))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + const handle = owner.agentic.wallet(wallet), + record = await handle.rules.get(id); + expect((record.rule as Rule).allowedCalls).toHaveLength(2); + const updated = await handle.rules.update({ + rules: [ + { + rulesId: id, + rule: { ...record.rule, validUntil: record.validUntil + 60 }, + }, + ], + }); + expect((await handle.rules.get(updated.rules[0].rulesId)).rule).toEqual({ + ...record.rule, + validUntil: record.validUntil + 60, + }); + }); + it('v6 refuses a bare alternative that bypasses a pinned function and too many initial rules', async () => { + const input = rule(); + input.allowedCalls = [ + { target: DEST, selector: 'increment()', pins: [] }, + { + target: DEST, + selector: 'increment()', + pins: [{ arg: 0, max: BigInt(1) }], + }, + ]; + await expect( + owner.agentic.create('bypass', { rules: [input] }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + const rules = Array.from({ length: 9 }, (_, i) => ({ + ...rule(), + agent: getAddress(`0x${(100 + i).toString(16).padStart(40, '0')}`), + })); + const before = await owner.agentic.derive(); + await expect( + owner.agentic.create('too-many', { rules }) + ).rejects.toMatchObject({ code: 'INVALID_RULE' }); + expect(await owner.agentic.derive()).toEqual(before); + }); + it('a fixture preset produces rules accepted by public create and enforced by real contracts', async () => { const presets = buildPresetApi([ { @@ -374,7 +450,7 @@ describe('v5 multi-asset SDK management and execution', () => { }) ).rejects.toMatchObject({ code: 'AMBIGUOUS_RULE' }); }); - it('a legacy two-field envelope is refused by the actual v5 grant path', async () => { + it('a legacy two-field envelope is refused by the actual v6 grant path', async () => { const bare = await owner.agentic.create('old-envelope', { rules: [] }); const { encodeAbiParameters } = await import('viem'); const terms = nativeRuleToTerms( diff --git a/packages/core/__agw-local__/vectors.spec.ts b/packages/core/__agw-local__/vectors.spec.ts index dcc5d2915..f75b3e808 100644 --- a/packages/core/__agw-local__/vectors.spec.ts +++ b/packages/core/__agw-local__/vectors.spec.ts @@ -4,7 +4,7 @@ import type { AgenticRuntime } from '../src/lib/agentic/runtime'; * below is computed BY THE PINNED CONTRACTS (pure engine/factory views, or * state read back after a real grant), never by the SDK under test. * - * AGW_WRITE_VECTORS=1 → (re)write src/lib/agentic/__fixtures__/v5-vectors.json + * AGW_WRITE_VECTORS=1 → (re)write src/lib/agentic/__fixtures__/v6-vectors.json * otherwise → assert the committed file still matches the contracts */ import { execSync } from 'node:child_process'; @@ -31,7 +31,7 @@ const FIXTURE = join( 'lib', 'agentic', '__fixtures__', - 'v5-vectors.json' + 'v6-vectors.json' ); const bigintJson = (v: unknown) => JSON.stringify( @@ -40,7 +40,7 @@ const bigintJson = (v: unknown) => 2 ); -describe('contract-generated AGW vectors (v5)', () => { +describe('contract-generated AGW vectors (v6)', () => { let h: Harness; beforeAll(async () => { h = await startHarness(18548); @@ -171,7 +171,7 @@ describe('contract-generated AGW vectors (v5)', () => { initialized: boolean; target: Address; selector: Hex; - pins: readonly { offset: number; expected: Hex }[]; + pins: readonly { offset: number; op: number; value: Hex }[]; amount: { offset: number }; }; const native = { @@ -186,7 +186,8 @@ describe('contract-generated AGW vectors (v5)', () => { storedSelector: stored.selector, storedPins: stored.pins.map((p) => ({ offset: Number(p.offset), - expected: p.expected, + op: p.op, + value: p.value, })), storedAmountOffset: Number(stored.amount.offset), encodedSession: encodeSession(session), diff --git a/packages/core/__e2e__/agw-svm-wire/outbound.spec.ts b/packages/core/__e2e__/agw-svm-wire/outbound.spec.ts index 673594983..19aea3c0a 100644 --- a/packages/core/__e2e__/agw-svm-wire/outbound.spec.ts +++ b/packages/core/__e2e__/agw-svm-wire/outbound.spec.ts @@ -1,9 +1,23 @@ /** Internal wire acceptance alongside public Solana coverage. Policy refusals come from URP simulation. */ import '@e2e/shared/setup'; import { PushChain } from '../../src'; -import { Keypair, PublicKey, type ParsedTransactionWithMeta } from '@solana/web3.js'; +import { + Keypair, + PublicKey, + type ParsedTransactionWithMeta, +} from '@solana/web3.js'; import bs58 from 'bs58'; -import { bytesToHex, createPublicClient, http, encodeFunctionData, erc20Abi, parseAbi, parseEther, type Address, type Hex } from 'viem'; +import { + bytesToHex, + createPublicClient, + http, + encodeFunctionData, + erc20Abi, + parseAbi, + parseEther, + type Address, + type Hex, +} from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; @@ -14,161 +28,441 @@ import { readSvmRule } from '../../src/lib/agentic/reads/svm'; import { grantSvmWire } from '../../src/lib/agentic/management/svm'; import { createSvmMetadataProvider } from '../../src/lib/agentic/management/svm-metadata'; import { resolveSvmContext } from '../../src/lib/agentic/management/svm-context'; -import { sendSvmAgentWire, prepareSvmAgentExecution, type PreparedSvmRequest } from '../../src/lib/agentic/execution/svm-send'; -import { SvmDataPinMode, type SvmTermsWire } from '../../src/lib/agentic/codec/svm-terms'; +import { + sendSvmAgentWire, + prepareSvmAgentExecution, + type PreparedSvmRequest, +} from '../../src/lib/agentic/execution/svm-send'; +import { + SvmDataPinMode, + type SvmTermsWire, +} from '../../src/lib/agentic/codec/svm-terms'; import { svmKey } from '../../src/lib/agentic/codec/svm-accounts'; import { adaptTrackedResponse } from '../../src/lib/agentic/response'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; -import { setupAgw, evmClient, inSeconds, type AgwFixture } from '../agw/_fixture'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; +import { + setupAgw, + evmClient, + inSeconds, + type AgwFixture, +} from '../agw/_fixture'; import { loadAgwManifest, verifyAgwManifest } from '../agw/_manifest'; -import { inspectSvmWirePrograms, svmWireConnection, SVM_WIRE_CHAIN, SVM_WIRE_GATEWAY, SVM_WIRE_PROGRAM, SVM_WIRE_COUNTER, decodeWireCounter } from '../shared/agw-svm-preflight'; +import { + inspectSvmWirePrograms, + svmWireConnection, + SVM_WIRE_CHAIN, + SVM_WIRE_GATEWAY, + SVM_WIRE_PROGRAM, + SVM_WIRE_COUNTER, + decodeWireCounter, +} from '../shared/agw-svm-preflight'; import { probeSvmWirePolicies } from '../shared/agw-svm-policy-probe'; const d = process.env['AGW_SVM_WIRE_E2E'] === '1' ? describe : describe.skip; // Load the public entry point before internal modules (the existing harness // does the same); keeping this runtime use prevents TypeScript erasing it. -if (PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET !== SVM_WIRE_CHAIN) throw new Error('Solana fixture chain mismatch'); +if (PushChain.CONSTANTS.CHAIN.SOLANA_DEVNET !== SVM_WIRE_CHAIN) + throw new Error('Solana fixture chain mismatch'); const AMOUNT = BigInt(10_000); // 0.00001 SOL, twice at most per run. const MAX_PC = parseEther('20'); const RECEIVE = '0x79f4fa0308e5e101' as Hex; const DECREMENT = '0x6ae3a83bf81b9665' as Hex; const ZERO = `0x${'00'.repeat(32)}` as Hex; -const rt = (c: PushChain) => (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; +const rt = (c: PushChain) => + (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; const ixData = (disc: Hex, amount: bigint) => { - const b = Buffer.alloc(16); Buffer.from(disc.slice(2), 'hex').copy(b); b.writeBigUInt64LE(amount, 8); return b; + const b = Buffer.alloc(16); + Buffer.from(disc.slice(2), 'hex').copy(b); + b.writeBigUInt64LE(amount, 8); + return b; }; -function transferFromCea(tx: ParsedTransactionWithMeta, cea: string, recipient: string, amount: bigint) { - return tx.meta?.innerInstructions?.some((group) => group.instructions.some((ix) => - 'parsed' in ix && ix.program === 'system' && ix.parsed.type === 'transfer' && - ix.parsed.info.source === cea && ix.parsed.info.destination === recipient && - BigInt(ix.parsed.info.lamports) === amount - )); +function transferFromCea( + tx: ParsedTransactionWithMeta, + cea: string, + recipient: string, + amount: bigint +) { + return tx.meta?.innerInstructions?.some((group) => + group.instructions.some( + (ix) => + 'parsed' in ix && + ix.program === 'system' && + ix.parsed.type === 'transfer' && + ix.parsed.info.source === cea && + ix.parsed.info.destination === recipient && + BigInt(ix.parsed.info.lamports) === amount + ) + ); } d('agw svm wire', () => { - let f: AgwFixture, wallet: Address, token: Address, rulesId: Hex, agent: PushChain; + let f: AgwFixture, + wallet: Address, + token: Address, + rulesId: Hex, + agent: PushChain; let gen: Awaited>; let recipient: PublicKey, cea: PublicKey; const sol = svmWireConnection(); - const metadata = createSvmMetadataProvider({ [SVM_WIRE_CHAIN]: { - gatewayProgram: SVM_WIRE_GATEWAY, rpcUrls: CHAIN_INFO[SVM_WIRE_CHAIN].defaultRPC, - } }, (url) => svmWireConnection(url)); - const accounts = (failure = false) => failure - ? [{ pubkey: svmKey(SVM_WIRE_COUNTER.toBase58()), isWritable: true }, { pubkey: svmKey(cea.toBase58()), isWritable: false }] - : [{ pubkey: svmKey(SVM_WIRE_COUNTER.toBase58()), isWritable: true }, { pubkey: svmKey(recipient.toBase58()), isWritable: true }, { pubkey: svmKey(cea.toBase58()), isWritable: true }, { pubkey: ZERO, isWritable: false }]; - const request = (failure = false): PreparedSvmRequest => ({ wallet, rulesId, token, amount: AMOUNT, gasLimit: BigInt(0), - program: SVM_WIRE_PROGRAM, instructionData: ixData(failure ? DECREMENT : RECEIVE, failure ? BigInt(1) : AMOUNT), accounts: accounts(failure) }); - const stored = () => Snapshot.at(rt(f.owner).reader).then((snap) => readSvmRule(snap, gen, wallet, rulesId, rt(f.owner).pushChainNamespace)); + const metadata = createSvmMetadataProvider( + { + [SVM_WIRE_CHAIN]: { + gatewayProgram: SVM_WIRE_GATEWAY, + rpcUrls: CHAIN_INFO[SVM_WIRE_CHAIN].defaultRPC, + }, + }, + (url) => svmWireConnection(url) + ); + const accounts = (failure = false) => + failure + ? [ + { pubkey: svmKey(SVM_WIRE_COUNTER.toBase58()), isWritable: true }, + { pubkey: svmKey(cea.toBase58()), isWritable: false }, + ] + : [ + { pubkey: svmKey(SVM_WIRE_COUNTER.toBase58()), isWritable: true }, + { pubkey: svmKey(recipient.toBase58()), isWritable: true }, + { pubkey: svmKey(cea.toBase58()), isWritable: true }, + { pubkey: ZERO, isWritable: false }, + ]; + const request = (failure = false): PreparedSvmRequest => ({ + wallet, + rulesId, + token, + amount: AMOUNT, + gasLimit: BigInt(0), + program: SVM_WIRE_PROGRAM, + instructionData: ixData( + failure ? DECREMENT : RECEIVE, + failure ? BigInt(1) : AMOUNT + ), + accounts: accounts(failure), + }); + const stored = () => + Snapshot.at(rt(f.owner).reader).then((snap) => + readSvmRule(snap, gen, wallet, rulesId, rt(f.owner).pushChainNamespace) + ); beforeAll(async () => { // Every cluster/program check runs before setupAgw can fund anything. const preflight = await inspectSvmWirePrograms(sol); const key = process.env['SOLANA_PRIVATE_KEY']; - if (!key) throw new Error('SOLANA_PRIVATE_KEY is needed only to identify the owned recipient'); - try { recipient = Keypair.fromSecretKey(bs58.decode(key)).publicKey; } - catch { recipient = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(key))).publicKey; } + if (!key) + throw new Error( + 'SOLANA_PRIVATE_KEY is needed only to identify the owned recipient' + ); + try { + recipient = Keypair.fromSecretKey(bs58.decode(key)).publicKey; + } catch { + recipient = Keypair.fromSecretKey( + Uint8Array.from(JSON.parse(key)) + ).publicKey; + } const destination = await sol.getAccountInfo(recipient); - if (!destination || destination.executable || !destination.owner.equals(PublicKey.default)) throw new Error('Owned recipient must be an existing System account'); - if (parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5') > parseEther('0.5')) throw new Error('Wire suite agent funding is bounded to 0.5 PC'); + if ( + !destination || + destination.executable || + !destination.owner.equals(PublicKey.default) + ) + throw new Error('Owned recipient must be an existing System account'); + if ( + parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5') > parseEther('0.5') + ) + throw new Error('Wire suite agent funding is bounded to 0.5 PC'); const manifest = loadAgwManifest(); - if (manifest.network !== PUSH_NETWORK.TESTNET_DONUT) throw new Error('Wire suite is restricted to Donut'); - const push = createPublicClient({ chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]) }); + if (manifest.network !== PUSH_NETWORK.TESTNET_DONUT) + throw new Error('Wire suite is restricted to Donut'); + const push = createPublicClient({ + chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), + transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]), + }); await verifyAgwManifest(push, manifest); const ownerKey = process.env['PUSH_PRIVATE_KEY'] as Hex | undefined; if (!ownerKey) throw new Error('PUSH_PRIVATE_KEY is required'); const ownerAddress = privateKeyToAccount(ownerKey).address; - token = await push.readContract({ address: '0x00000000000000000000000000000000000000C0', - abi: parseAbi(['function gasTokenPRC20ByChainNamespace(string) view returns (address)']), - functionName: 'gasTokenPRC20ByChainNamespace', args: [SVM_WIRE_CHAIN] }); - const ownerTokens = await push.readContract({ address: token, abi: erc20Abi, functionName: 'balanceOf', args: [ownerAddress] }); - if (ownerTokens < AMOUNT * BigInt(2)) throw new Error('Push owner needs 0.00002 pSOL for this bounded suite'); - if (await push.getBalance({ address: ownerAddress }) < parseEther('45')) throw new Error('Push owner needs 45 PC to cover bounded setup and outbound budgets'); + token = await push.readContract({ + address: '0x00000000000000000000000000000000000000C0', + abi: parseAbi([ + 'function gasTokenPRC20ByChainNamespace(string) view returns (address)', + ]), + functionName: 'gasTokenPRC20ByChainNamespace', + args: [SVM_WIRE_CHAIN], + }); + const ownerTokens = await push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [ownerAddress], + }); + if (ownerTokens < AMOUNT * BigInt(2)) + throw new Error('Push owner needs 0.00002 pSOL for this bounded suite'); + if ((await push.getBalance({ address: ownerAddress })) < parseEther('45')) + throw new Error( + 'Push owner needs 45 PC to cover bounded setup and outbound budgets' + ); f = await setupAgw(); f.evidence('svm-wire-program-preflight', preflight); - wallet = (await f.owner.agentic.create('e2e-internal-svm-wire', { rules: [] })).wallet; - gen = await resolveWalletGeneration(rt(f.owner).reader, f.manifest.network, wallet); - const resolved = await resolveSvmContext(await Snapshot.at(rt(f.owner).reader), wallet, SVM_WIRE_CHAIN, - [{ token, maxPerCall: AMOUNT, maxTotal: AMOUNT * BigInt(2) }], metadata); + wallet = ( + await f.owner.agentic.create('e2e-internal-svm-wire', { rules: [] }) + ).wallet; + gen = await resolveWalletGeneration( + rt(f.owner).reader, + f.manifest.network, + wallet + ); + const resolved = await resolveSvmContext( + await Snapshot.at(rt(f.owner).reader), + wallet, + SVM_WIRE_CHAIN, + [{ token, maxPerCall: AMOUNT, maxTotal: AMOUNT * BigInt(2) }], + metadata + ); cea = new PublicKey(Buffer.from(resolved.expectedCEA.slice(2), 'hex')); - if (cea.toBase58() === preflight.counter.authority) throw new Error('Failure fixture requires a counter authority different from the fresh AGW CEA'); - const terms: SvmTermsWire = { ...resolved, validUntil: inSeconds(7200), maxGasPerCall: MAX_PC, - programs: [RECEIVE, DECREMENT].map((discriminator, i) => ({ program: svmKey(SVM_WIRE_PROGRAM), discriminator, - discriminatorLen: 8, dataless: false, maxAccounts: i === 0 ? 4 : 2 })), - pins: [false, true].flatMap((failure, ruleIndex) => accounts(failure).map((a, accountIndex) => ({ ruleIndex, accountIndex, expected: a.pubkey }))), - dataPins: [AMOUNT, BigInt(1)].map((limit, ruleIndex) => ({ ruleIndex, fromEnd: false, offset: 8, offsetB: 0, len: 8, - mode: SvmDataPinMode.LTE_LE, expected: `0x${limit.toString(16).padStart(64, '0')}`, num: BigInt(0), den: BigInt(0) })), + if (cea.toBase58() === preflight.counter.authority) + throw new Error( + 'Failure fixture requires a counter authority different from the fresh AGW CEA' + ); + const terms: SvmTermsWire = { + ...resolved, + validUntil: inSeconds(7200), + maxGasPerCall: MAX_PC, + programs: [RECEIVE, DECREMENT].map((discriminator, i) => ({ + program: svmKey(SVM_WIRE_PROGRAM), + discriminator, + discriminatorLen: 8, + dataless: false, + maxAccounts: i === 0 ? 4 : 2, + })), + pins: [false, true].flatMap((failure, ruleIndex) => + accounts(failure).map((a, accountIndex) => ({ + ruleIndex, + accountIndex, + expected: a.pubkey, + })) + ), + dataPins: [AMOUNT, BigInt(1)].map((limit, ruleIndex) => ({ + ruleIndex, + fromEnd: false, + offset: 8, + offsetB: 0, + len: 8, + mode: SvmDataPinMode.LTE_LE, + expected: `0x${limit.toString(16).padStart(64, '0')}`, + num: BigInt(0), + den: BigInt(0), + })), }; - const grant = await grantSvmWire(rt(f.owner), gen, wallet, { agent: f.agentAddress, chainNamespace: SVM_WIRE_CHAIN, terms }); + const grant = await grantSvmWire(rt(f.owner), gen, wallet, { + agent: f.agentAddress, + chainNamespace: SVM_WIRE_CHAIN, + terms, + }); rulesId = grant.rulesId; await f.fundPC(wallet, MAX_PC * BigInt(2) + parseEther('1')); - await (await f.owner.universal.sendTransaction({ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [wallet, AMOUNT * BigInt(2)] }) })).wait(); - const ownerWallet = await evmClient(process.env['PUSH_PRIVATE_KEY'] as Hex, CHAIN.PUSH_TESTNET_DONUT, f.manifest.network, wallet); - await (await ownerWallet.universal.sendTransaction({ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [f.manifest.addresses.gateway, AMOUNT * BigInt(2)] }) })).wait(); + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [wallet, AMOUNT * BigInt(2)], + }), + }) + ).wait(); + const ownerWallet = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); + await ( + await ownerWallet.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [f.manifest.addresses.gateway, AMOUNT * BigInt(2)], + }), + }) + ).wait(); agent = await f.agent(wallet); - f.evidence('svm-wire-setup', { wallet, rulesId, grantHash: grant.tx.hash, agent: f.agentAddress, recipient: recipient.toBase58(), cea: cea.toBase58(), token, terms, walletPCFunded: MAX_PC * BigInt(2) + parseEther('1'), tokenFunded: AMOUNT * BigInt(2) }); + f.evidence('svm-wire-setup', { + wallet, + rulesId, + grantHash: grant.tx.hash, + agent: f.agentAddress, + recipient: recipient.toBase58(), + cea: cea.toBase58(), + token, + terms, + walletPCFunded: MAX_PC * BigInt(2) + parseEther('1'), + tokenFunded: AMOUNT * BigInt(2), + }); }, 900_000); afterAll(() => f?.teardown()); it('1. positive outbound: actual CEA transfer and counter execution, receipt and replay', async () => { - const before = decodeWireCounter((await sol.getAccountInfo(SVM_WIRE_COUNTER))!.data).value; - const count = await f.push.readContract({ address: wallet, abi: v5.abis.wallet, functionName: 'checkpointCount' }); + const before = decodeWireCounter( + (await sol.getAccountInfo(SVM_WIRE_COUNTER))!.data + ).value; + const count = await f.push.readContract({ + address: wallet, + abi: v6.abis.wallet, + functionName: 'checkpointCount', + }); const { tx } = await sendSvmAgentWire(rt(agent), gen, request(), metadata); - f.evidence('svm-wire-positive-submitted', { wallet, rulesId, hash: tx.hash }); + f.evidence('svm-wire-positive-submitted', { + wallet, + rulesId, + hash: tx.hash, + }); // Existing verified-wallet replay adaptation enables normal outbound wait; // it uses the gateway wire call as the logical summary, not a new SVM API. await adaptTrackedResponse(rt(agent), tx); - expect(tx.from).toBe(wallet); expect(tx.route).toBe('UOA_TO_CEA'); + expect(tx.from).toBe(wallet); + expect(tx.route).toBe('UOA_TO_CEA'); const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); f.evidence('svm-wire-positive-receipt', { hash: tx.hash, receipt }); - expect(receipt.status).toBe(1); expect(receipt.externalStatus).toBe('success'); - expect(receipt.from).toBe(wallet); expect(receipt.externalChain).toBe(SVM_WIRE_CHAIN); - const destination = await sol.getParsedTransaction(receipt.externalTxHash!, { commitment: 'confirmed', maxSupportedTransactionVersion: 0 }); - expect(destination).not.toBeNull(); expect(destination!.meta!.err).toBeNull(); - expect(transferFromCea(destination!, cea.toBase58(), recipient.toBase58(), AMOUNT)).toBe(true); - expect(destination!.meta!.innerInstructions?.some((g) => g.instructions.some((ix) => - 'data' in ix && ix.programId.toBase58() === SVM_WIRE_PROGRAM && bytesToHex(bs58.decode(ix.data)) === bytesToHex(ixData(RECEIVE, AMOUNT)) && - ix.accounts.some((a) => a.equals(cea)) - ))).toBe(true); - expect(decodeWireCounter((await sol.getAccountInfo(SVM_WIRE_COUNTER))!.data).value).toBeGreaterThanOrEqual(before + AMOUNT); + expect(receipt.status).toBe(1); + expect(receipt.externalStatus).toBe('success'); + expect(receipt.from).toBe(wallet); + expect(receipt.externalChain).toBe(SVM_WIRE_CHAIN); + const destination = await sol.getParsedTransaction( + receipt.externalTxHash!, + { commitment: 'confirmed', maxSupportedTransactionVersion: 0 } + ); + expect(destination).not.toBeNull(); + expect(destination!.meta!.err).toBeNull(); + expect( + transferFromCea( + destination!, + cea.toBase58(), + recipient.toBase58(), + AMOUNT + ) + ).toBe(true); + expect( + destination!.meta!.innerInstructions?.some((g) => + g.instructions.some( + (ix) => + 'data' in ix && + ix.programId.toBase58() === SVM_WIRE_PROGRAM && + bytesToHex(bs58.decode(ix.data)) === + bytesToHex(ixData(RECEIVE, AMOUNT)) && + ix.accounts.some((a) => a.equals(cea)) + ) + ) + ).toBe(true); + expect( + decodeWireCounter((await sol.getAccountInfo(SVM_WIRE_COUNTER))!.data) + .value + ).toBeGreaterThanOrEqual(before + AMOUNT); expect((await stored()).config.assets[0].spent).toBe(AMOUNT); - expect(await f.push.readContract({ address: wallet, abi: v5.abis.wallet, functionName: 'checkpointCount' })).toBe(count); + expect( + await f.push.readContract({ + address: wallet, + abi: v6.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(count); const replay = await f.owner.universal.trackTransaction(tx.hash); - expect(replay.from).toBe(wallet); expect(replay.route).toBe('UOA_TO_CEA'); + expect(replay.from).toBe(wallet); + expect(replay.route).toBe('UOA_TO_CEA'); expect(replay.agentic).toMatchObject({ door: 'agent', rulesId }); const replayReceipt = await replay.wait({ outboundTimeoutMs: 600_000 }); expect(replayReceipt.externalTxHash).toBe(receipt.externalTxHash); - f.evidence('svm-wire-positive-destination', { hash: tx.hash, externalTxHash: receipt.externalTxHash, destination, replayReceipt }); + f.evidence('svm-wire-positive-destination', { + hash: tx.hash, + externalTxHash: receipt.externalTxHash, + destination, + replayReceipt, + }); }, 900_000); it('2. substituted account and oversized instruction amount fail before submission', async () => { const base = request(); - const wrong = [...base.accounts!]; wrong[2] = { ...wrong[2], pubkey: svmKey(recipient.toBase58()) }; + const wrong = [...base.accounts!]; + wrong[2] = { ...wrong[2], pubkey: svmKey(recipient.toBase58()) }; const nonce = await f.push.getTransactionCount({ address: f.agentAddress }); - await expect(sendSvmAgentWire(rt(agent), gen, { ...base, accounts: wrong }, metadata)).rejects.toMatchObject({ name: 'AgenticRevertError', decodedError: { name: 'PolicyCheckReverted(SvmAccountPinMismatch)' } }); - await expect(sendSvmAgentWire(rt(agent), gen, { ...base, instructionData: ixData(RECEIVE, AMOUNT + BigInt(1)) }, metadata)).rejects.toMatchObject({ name: 'AgenticRevertError', decodedError: { name: 'PolicyCheckReverted(SvmDataCeilingExceeded)' } }); - const prepared = await prepareSvmAgentExecution(rt(agent), gen, base, metadata); - const policy = await probeSvmWirePolicies(f.push, { from: f.agentAddress, wallet, rulesId, call: prepared.call, blockNumber: await f.push.getBlockNumber() }); - expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe(nonce); - f.evidence('svm-wire-preflight-refusals', { wallet, rulesId, nonce, policy, transactionsSent: 0 }); + await expect( + sendSvmAgentWire(rt(agent), gen, { ...base, accounts: wrong }, metadata) + ).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmAccountPinMismatch)' }, + }); + await expect( + sendSvmAgentWire( + rt(agent), + gen, + { ...base, instructionData: ixData(RECEIVE, AMOUNT + BigInt(1)) }, + metadata + ) + ).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmDataCeilingExceeded)' }, + }); + const prepared = await prepareSvmAgentExecution( + rt(agent), + gen, + base, + metadata + ); + const policy = await probeSvmWirePolicies(f.push, { + from: f.agentAddress, + wallet, + rulesId, + call: prepared.call, + blockNumber: await f.push.getBlockNumber(), + }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + nonce + ); + f.evidence('svm-wire-preflight-refusals', { + wallet, + rulesId, + nonce, + policy, + transactionsSent: 0, + }); }); it('3. destination program failure is classified and does not restore policy spend', async () => { const prior = (await stored()).config.assets[0].spent; - const { tx } = await sendSvmAgentWire(rt(agent), gen, request(true), metadata); - f.evidence('svm-wire-failure-submitted', { wallet, rulesId, hash: tx.hash }); + const { tx } = await sendSvmAgentWire( + rt(agent), + gen, + request(true), + metadata + ); + f.evidence('svm-wire-failure-submitted', { + wallet, + rulesId, + hash: tx.hash, + }); await adaptTrackedResponse(rt(agent), tx); // The observed node rejection lifecycle exceeded ten minutes. Keep the // failed assertion strict while allowing the bounded terminalization window. const receipt = await tx.wait({ outboundTimeoutMs: 1_200_000 }); f.evidence('svm-wire-failure-receipt', { hash: tx.hash, receipt }); - const pushReceipt = await f.push.getTransactionReceipt({ hash: tx.hash as Hex }); - expect(pushReceipt.status).toBe('success'); expect(receipt.externalStatus).toBe('failed'); + const pushReceipt = await f.push.getTransactionReceipt({ + hash: tx.hash as Hex, + }); + expect(pushReceipt.status).toBe('success'); + expect(receipt.externalStatus).toBe('failed'); expect((await stored()).config.assets[0].spent).toBe(prior + AMOUNT); if (receipt.externalTxHash) { - const destination = await sol.getParsedTransaction(receipt.externalTxHash, { commitment: 'confirmed', maxSupportedTransactionVersion: 0 }); - expect(destination).not.toBeNull(); expect(destination!.meta!.err).not.toBeNull(); - f.evidence('svm-wire-failure-destination', { hash: tx.hash, externalTxHash: receipt.externalTxHash, destination }); + const destination = await sol.getParsedTransaction( + receipt.externalTxHash, + { commitment: 'confirmed', maxSupportedTransactionVersion: 0 } + ); + expect(destination).not.toBeNull(); + expect(destination!.meta!.err).not.toBeNull(); + f.evidence('svm-wire-failure-destination', { + hash: tx.hash, + externalTxHash: receipt.externalTxHash, + destination, + }); } }, 1_500_000); }); diff --git a/packages/core/__e2e__/agw-svm-wire/replay.spec.ts b/packages/core/__e2e__/agw-svm-wire/replay.spec.ts index 3ea2196cc..a13313638 100644 --- a/packages/core/__e2e__/agw-svm-wire/replay.spec.ts +++ b/packages/core/__e2e__/agw-svm-wire/replay.spec.ts @@ -3,57 +3,100 @@ import { PushChain } from '../../src'; import { createPublicClient, http, type Hex } from 'viem'; import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { chainReaderFromPublicClient } from '../../src/lib/agentic/contracts/reader'; import { resolveWalletGeneration } from '../../src/lib/agentic/deployments'; import { Snapshot } from '../../src/lib/agentic/reads/snapshot'; import { readSvmRule } from '../../src/lib/agentic/reads/svm'; import { probeSvmWirePolicies } from '../shared/agw-svm-policy-probe'; -const d = process.env['AGW_SVM_WIRE_REPLAY_E2E'] === '1' ? describe : describe.skip; -const POSITIVE = '0x50a96ff863722ced3464e3e8e5e451621903396c9bc83fcc010756dc1aee73b8' as Hex; -const REJECTED = '0x51e2227e833e8090844f151c2d5d959225d3ec24828bf11a1c6467e2a74410fc' as Hex; -const EXTERNAL = 'kGUaPUfASg9Poo93aMsztHJjUk27MKuRJBfDZK1WP3FFSaXSYcoPcxjUMFyDxaK6aU6mQkZeowSu3MtvK5PXKgK'; +const d = + process.env['AGW_SVM_WIRE_REPLAY_E2E'] === '1' ? describe : describe.skip; +const POSITIVE = + '0x50a96ff863722ced3464e3e8e5e451621903396c9bc83fcc010756dc1aee73b8' as Hex; +const REJECTED = + '0x51e2227e833e8090844f151c2d5d959225d3ec24828bf11a1c6467e2a74410fc' as Hex; +const EXTERNAL = + 'kGUaPUfASg9Poo93aMsztHJjUk27MKuRJBfDZK1WP3FFSaXSYcoPcxjUMFyDxaK6aU6mQkZeowSu3MtvK5PXKgK'; d('agw svm recorded replay', () => { - const push = createPublicClient({ chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]) }); + const push = createPublicClient({ + chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), + transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]), + }); let source: Awaited>; - let decoded: Extract>, { kind: 'executeAsAgent' }>; + let decoded: Extract< + NonNullable>, + { kind: 'executeAsAgent' } + >; let client: PushChain; beforeAll(async () => { source = await push.getTransaction({ hash: POSITIVE }); - const call = v5.decodeWalletCall(source.input); - if (!source.to || call?.kind !== 'executeAsAgent') throw new Error('Recorded fixture is not an AGW agent call'); + const call = v6.decodeWalletCall(source.input); + if (!source.to || call?.kind !== 'executeAsAgent') + throw new Error('Recorded fixture is not an AGW agent call'); decoded = call; - client = await PushChain.initialize({ chain: PushChain.CONSTANTS.CHAIN.PUSH_TESTNET_DONUT, address: source.from }, { network: PUSH_NETWORK.TESTNET_DONUT }); + client = await PushChain.initialize( + { + chain: PushChain.CONSTANTS.CHAIN.PUSH_TESTNET_DONUT, + address: source.from, + }, + { network: PUSH_NETWORK.TESTNET_DONUT } + ); expect(client.isReadMode).toBe(true); }); it('1. positive replay retains wallet, signer origin and actual Solana signature', async () => { const tx = await client.universal.trackTransaction(POSITIVE); expect(tx.from.toLowerCase()).toBe(source.to!.toLowerCase()); - expect(tx.origin.toLowerCase()).toBe(`eip155:42101:${source.from}`.toLowerCase()); - expect(tx.agentic?.rulesId).toBe(decoded.rulesId); expect(tx.route).toBe('UOA_TO_CEA'); + expect(tx.origin.toLowerCase()).toBe( + `eip155:42101:${source.from}`.toLowerCase() + ); + expect(tx.agentic?.rulesId).toBe(decoded.rulesId); + expect(tx.route).toBe('UOA_TO_CEA'); const receipt = await tx.wait({ outboundTimeoutMs: 30_000 }); - expect(receipt.status).toBe(1); expect(receipt.externalStatus).toBe('success'); expect(receipt.externalTxHash).toBe(EXTERNAL); + expect(receipt.status).toBe(1); + expect(receipt.externalStatus).toBe('success'); + expect(receipt.externalTxHash).toBe(EXTERNAL); }, 120_000); it('2. later terminal rejection is failed, while the source succeeded and spend remains charged', async () => { const tx = await client.universal.trackTransaction(REJECTED); const receipt = await tx.wait({ outboundTimeoutMs: 30_000 }); - expect(receipt.status).toBe(1); expect(receipt.externalStatus).toBe('failed'); - expect(receipt.externalError).toContain('tx not executed on destination chain'); + expect(receipt.status).toBe(1); + expect(receipt.externalStatus).toBe('failed'); + expect(receipt.externalError).toContain( + 'tx not executed on destination chain' + ); expect(tx.from.toLowerCase()).toBe(source.to!.toLowerCase()); // Historical snapshot after the actual refund block, immune to later owner changes. const reader = chainReaderFromPublicClient(push); - const gen = await resolveWalletGeneration(reader, PUSH_NETWORK.TESTNET_DONUT, source.to!); + const gen = await resolveWalletGeneration( + reader, + PUSH_NETWORK.TESTNET_DONUT, + source.to! + ); const snapshot = new Snapshot(reader, BigInt(23950160)); - const rule = await readSvmRule(snapshot, gen, source.to!, decoded.rulesId, 'eip155:42101'); + const rule = await readSvmRule( + snapshot, + gen, + source.to!, + decoded.rulesId, + 'eip155:42101' + ); expect(rule.config.assets[0].spent).toBe(BigInt(20_000)); }, 120_000); it('3. pinned live policy calls refuse account and data mutations without submitting', async () => { const receipt = await push.getTransactionReceipt({ hash: POSITIVE }); - const result = await probeSvmWirePolicies(push, { from: source.from, wallet: source.to!, rulesId: decoded.rulesId, call: decoded.calls[0], blockNumber: receipt.blockNumber }); - expect(result.transactionsSent).toBe(0); expect(result.observations.map((r) => r.decoded.name)).toEqual([ - 'PolicyCheckReverted(SvmAccountPinMismatch)', 'PolicyCheckReverted(SvmDataCeilingExceeded)', + const result = await probeSvmWirePolicies(push, { + from: source.from, + wallet: source.to!, + rulesId: decoded.rulesId, + call: decoded.calls[0], + blockNumber: receipt.blockNumber, + }); + expect(result.transactionsSent).toBe(0); + expect(result.observations.map((r) => r.decoded.name)).toEqual([ + 'PolicyCheckReverted(SvmAccountPinMismatch)', + 'PolicyCheckReverted(SvmDataCeilingExceeded)', ]); }); }); diff --git a/packages/core/__e2e__/agw/_manifest.ts b/packages/core/__e2e__/agw/_manifest.ts index d3dc1efc5..fa3ac52ae 100644 --- a/packages/core/__e2e__/agw/_manifest.ts +++ b/packages/core/__e2e__/agw/_manifest.ts @@ -1,6 +1,6 @@ /** * AGW deployment manifest for live E2E — READ-ONLY. Loaded from the JSON file - * optionally named by AGW_DEPLOYMENT_MANIFEST, otherwise the built-in Donut v5 + * optionally named by AGW_DEPLOYMENT_MANIFEST, otherwise the built-in Donut v6 * manifest. Code/wiring/version are verified before tests or preflight funding. * * Manifest shape: @@ -18,6 +18,7 @@ import { readFileSync } from 'node:fs'; import { getAddress, + getAbiItem, isAddress, type Address, type PublicClient, @@ -25,21 +26,21 @@ import { } from 'viem'; import { PUSH_NETWORK } from '../../src/lib/constants/enums'; import { AgenticCapability } from '../../src/lib/agentic/capabilities'; -import { V5_CAPABILITIES } from '../../src/lib/agentic/deployments'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { V6_CAPABILITIES } from '../../src/lib/agentic/deployments'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { - DONUT_V5_ADDRESSES, - DONUT_V5_START_BLOCK, - V5_SOURCE_COMMIT, -} from '../../src/lib/agentic/contracts/donut-v5'; + DONUT_V6_ADDRESSES, + DONUT_V6_START_BLOCK, + V6_SOURCE_COMMIT, +} from '../../src/lib/agentic/contracts/donut-v6'; export const AGW_PREREQ_HINT = 'AGW live E2E needs checked v5 wiring, signer keys and testnet funding. ' + - 'The default is the checked Donut v5 manifest; custom manifests must match the v5 source.'; + 'The default is the checked Donut v6 manifest; custom manifests must match the v5 source.'; export interface AgwManifest { network: PUSH_NETWORK; - generation: 'v5'; + generation: 'v6'; sourceCommit: string; startBlock: bigint; accountId: string; @@ -67,12 +68,12 @@ export function loadAgwManifest( if (!path) return { network: PUSH_NETWORK.TESTNET_DONUT, - generation: 'v5', - sourceCommit: V5_SOURCE_COMMIT, - startBlock: DONUT_V5_START_BLOCK, + generation: 'v6', + sourceCommit: V6_SOURCE_COMMIT, + startBlock: DONUT_V6_START_BLOCK, accountId: 'push.agw.1.0.0', - addresses: DONUT_V5_ADDRESSES, - capabilities: V5_CAPABILITIES, + addresses: DONUT_V6_ADDRESSES, + capabilities: V6_CAPABILITIES, }; let raw: Record; try { @@ -85,14 +86,14 @@ export function loadAgwManifest( const network = raw['network'] as PUSH_NETWORK; if (!Object.values(PUSH_NETWORK).includes(network)) throw new AgwPrerequisiteError(`unknown network ${String(network)}`); - if (raw['generation'] !== v5.id) { + if (raw['generation'] !== v6.id) { throw new AgwPrerequisiteError( `generation ${String(raw['generation'])} has no SDK adapter` ); } - if (raw['sourceCommit'] !== V5_SOURCE_COMMIT) { + if (raw['sourceCommit'] !== V6_SOURCE_COMMIT) { throw new AgwPrerequisiteError( - 'sourceCommit must match the supported v5 source pin' + 'sourceCommit must match the supported v6 source pin' ); } const a = (raw['addresses'] ?? {}) as Record; @@ -111,15 +112,15 @@ export function loadAgwManifest( const caps = Array.isArray(raw['capabilities']) ? new Set( (raw['capabilities'] as string[]).map((c) => { - if (!V5_CAPABILITIES.has(c as AgenticCapability)) + if (!V6_CAPABILITIES.has(c as AgenticCapability)) throw new AgwPrerequisiteError(`unknown capability ${c}`); return c as AgenticCapability; }) ) - : V5_CAPABILITIES; + : V6_CAPABILITIES; return { network, - generation: 'v5', + generation: 'v6', sourceCommit: raw['sourceCommit'], startBlock: BigInt(String(raw['startBlock'] ?? '0')), accountId: String(raw['accountId'] ?? 'push.agw.1.0.0'), @@ -154,7 +155,7 @@ export async function verifyAgwManifest( const impl = getAddress( (await read( m.addresses.factory, - v5.abis.factory, + v6.abis.factory, 'walletImplementation' )) as string ); @@ -164,17 +165,21 @@ export async function verifyAgwManifest( ); } const implementationCode = await client.getCode({ address: impl }); - for (const signature of [ - 'label()', - 'setLabel(string)', - 'initializeAccount(string)', - ]) { + for (const signature of ['label()', 'setLabel(string)']) { if (!implementationCode?.includes(toFunctionSelector(signature).slice(2))) { throw new AgwPrerequisiteError( - `implementation is missing the v5 ${signature} selector` + `implementation is missing the v6 ${signature} selector` ); } } + const initialize = getAbiItem({ + abi: v6.abis.wallet, + name: 'initializeAccount', + }); + if (!implementationCode?.includes(toFunctionSelector(initialize).slice(2))) + throw new AgwPrerequisiteError( + 'wallet implementation lacks the v6 initial-rules initializer' + ); const wiring: [string, Address][] = [ ['SESSION_ENGINE', m.addresses.sessionEngine], ['RULES_POLICY', m.addresses.rulesPolicy], @@ -182,7 +187,7 @@ export async function verifyAgwManifest( ['UNIVERSAL_GATEWAY_PC', m.addresses.gateway], ]; for (const [fn, expected] of wiring) { - const got = getAddress((await read(impl, v5.abis.wallet, fn)) as string); + const got = getAddress((await read(impl, v6.abis.wallet, fn)) as string); if (got !== expected) throw new AgwPrerequisiteError( `implementation ${fn} is ${got}, manifest says ${expected}` @@ -190,14 +195,14 @@ export async function verifyAgwManifest( } const policyVersion = await read( m.addresses.rulesPolicy, - v5.abis.policy, + v6.abis.policy, 'version' ); if (policyVersion !== '3.1.0') throw new AgwPrerequisiteError( `unsupported URP version ${String(policyVersion)}` ); - const accountId = (await read(impl, v5.abis.wallet, 'accountId')) as string; + const accountId = (await read(impl, v6.abis.wallet, 'accountId')) as string; if (accountId !== m.accountId) throw new AgwPrerequisiteError(`implementation accountId is ${accountId}`); } diff --git a/packages/core/__e2e__/agw/create.spec.ts b/packages/core/__e2e__/agw/create.spec.ts index bd05db7c2..f05d8b846 100644 --- a/packages/core/__e2e__/agw/create.spec.ts +++ b/packages/core/__e2e__/agw/create.spec.ts @@ -3,8 +3,8 @@ * verified AGW deployment manifest (see _manifest.ts); beforeAll throws when * it is missing so a selected run cannot pass vacuously. */ -import { getAddress, type Address } from 'viem'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { decodeFunctionData, getAddress, type Address, type Hex } from 'viem'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { AGW_E2E_ENABLED, inSeconds, @@ -24,7 +24,7 @@ d('agw create', () => { expect(created.wallet).toBe(before.address); expect(created.index).toBe(before.index); expect(created.rulesIds).toEqual([]); - const factoryAbi = v5.abis.factory; + const factoryAbi = v6.abis.factory; const [ownerOf, indexOf, balance] = await Promise.all([ f.push.readContract({ address: f.manifest.addresses.factory, @@ -74,6 +74,14 @@ d('agw create', () => { ], }); expect(created.rulesIds).toHaveLength(1); + const raw = await f.push.getTransaction({ hash: created.tx.hash as Hex }); + expect(getAddress(raw.to as Address)).toBe( + getAddress(f.manifest.addresses.factory) + ); + const call = decodeFunctionData({ abi: v6.abis.factory, data: raw.input }); + expect(call.functionName).toBe('createWalletWithSig'); + expect(call.args[3]).toHaveLength(1); + expect(created.tx.transactionHashes ?? [created.tx.hash]).toHaveLength(1); const { rules } = await f.owner.agentic.wallet(created.wallet).rules.list(); expect(rules.map((r) => r.rulesId)).toEqual(created.rulesIds); expect(rules[0].agent).toBe(getAddress(f.agentAddress)); diff --git a/packages/core/__e2e__/agw/labels.spec.ts b/packages/core/__e2e__/agw/labels.spec.ts index a5ca52d43..b46d50952 100644 --- a/packages/core/__e2e__/agw/labels.spec.ts +++ b/packages/core/__e2e__/agw/labels.spec.ts @@ -15,7 +15,7 @@ import { readNativeCounters } from '@e2e/shared/agw-state'; import { privateKeyToAccount } from 'viem/accounts'; import { PushChain } from '../../src'; import { CHAIN } from '../../src/lib/constants/enums'; -import { v5, MODE_SINGLE } from '../../src/lib/agentic/contracts/v5'; +import { v6, MODE_SINGLE } from '../../src/lib/agentic/contracts/v6'; import { decodeAgenticRevert } from '../../src/lib/agentic/revert'; import { AGW_E2E_ENABLED, @@ -34,7 +34,7 @@ d('agw labels', () => { const count = (address: Address) => f.push.readContract({ address, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); it('1. default and custom deployment labels are stored and listed', async () => { @@ -81,7 +81,7 @@ d('agw labels', () => { if (log.address.toLowerCase() !== made.wallet.toLowerCase()) return []; try { const ev = decodeEventLog({ - abi: v5.abis.wallet, + abi: v6.abis.wallet, data: log.data, topics: log.topics, }); @@ -127,7 +127,7 @@ d('agw labels', () => { await expect( f.push.simulateContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'setLabel', args: [label], account: f.ownerAddress, @@ -166,7 +166,7 @@ d('agw labels', () => { await expect( f.push.simulateContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'setLabel', args: ['attack'], account: f.agentAddress, @@ -175,15 +175,15 @@ d('agw labels', () => { const agentDoorError = await f.push .simulateContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'executeAsAgent', args: [ made.rulesIds[0], MODE_SINGLE, - v5.packSingle({ + v6.packSingle({ target: made.wallet, value: BigInt(0), - data: v5.encodeSetLabel('attack'), + data: v6.encodeSetLabel('attack'), }), ], account: f.agentAddress, @@ -332,7 +332,7 @@ d('agw labels', () => { const checkpointsBefore = await count(made.wallet); const nonceBefore = await f.push.readContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'grantNonce', }); const rename = await w.setLabel('active-after'); @@ -349,7 +349,7 @@ d('agw labels', () => { expect( await f.push.readContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'grantNonce', }) ).toBe(nonceBefore); @@ -411,12 +411,12 @@ d('agw labels', () => { { to: made.wallet, value: BigInt(0), - data: v5.encodeSetLabel('temporary'), + data: v6.encodeSetLabel('temporary'), }, { to: made.wallet, value: BigInt(0), - data: v5.encodeSetLabel('a'.repeat(65)), + data: v6.encodeSetLabel('a'.repeat(65)), }, ]; await expect( @@ -434,7 +434,7 @@ d('agw labels', () => { chain: client.chain, to: made.wallet, gas: BigInt(400000), - data: v5.encodeExecute( + data: v6.encodeExecute( calls.map((call) => ({ target: call.to, value: call.value, diff --git a/packages/core/__e2e__/agw/lifecycle.spec.ts b/packages/core/__e2e__/agw/lifecycle.spec.ts index 77b3cc1b7..f0d065216 100644 --- a/packages/core/__e2e__/agw/lifecycle.spec.ts +++ b/packages/core/__e2e__/agw/lifecycle.spec.ts @@ -5,7 +5,7 @@ import { readNativeCounters } from '@e2e/shared/agw-state'; * The stale-spend race (intervening agent spend) is verified against the * real contracts in the local harness; it cannot be timed reliably live. */ -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { getAddress, parseEther, type Address, type Hex } from 'viem'; import { AGENTIC_ERROR_CODE } from '../../src/lib/agentic/errors'; import { @@ -34,7 +34,7 @@ d('agw lifecycle', () => { const count = () => f.push.readContract({ address: wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) as Promise; beforeAll(async () => { diff --git a/packages/core/__e2e__/agw/management-extended.spec.ts b/packages/core/__e2e__/agw/management-extended.spec.ts index 0a0b12106..014dac5bc 100644 --- a/packages/core/__e2e__/agw/management-extended.spec.ts +++ b/packages/core/__e2e__/agw/management-extended.spec.ts @@ -1,7 +1,7 @@ import { PushChain } from '../../src'; import { getAddress, type Address } from 'viem'; import type { AgenticRuntime } from '../../src/lib/agentic/runtime'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { setupAgw, inSeconds, type AgwFixture } from './_fixture'; import { readNativeCounters } from '../shared/agw-state'; const d = process.env['AGW_E2E'] === '1' ? describe : describe.skip; @@ -31,7 +31,7 @@ d('agw extended management', () => { const count = (wallet: Address) => f.push.readContract({ address: wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); it('1. prefunding a derived address survives deployment and info/list keep owner index and label', async () => { @@ -166,7 +166,7 @@ d('agw extended management', () => { const before = await count(made.wallet), grantNonce = await f.push.readContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'grantNonce', }); rt.execute = async (params, opts) => { @@ -200,7 +200,7 @@ d('agw extended management', () => { expect( await f.push.readContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'grantNonce', }) ).toBe(grantNonce); @@ -234,7 +234,7 @@ d('agw extended management', () => { | undefined, injected = false; rt.execute = async (params, opts) => { - if (!injected && Array.isArray(params.data)) { + if (!injected && params.to === f.manifest.addresses.factory) { injected = true; competing = await f.owner.agentic.create('e2e-race-winner', { rules: [], diff --git a/packages/core/__e2e__/agw/native-multi-action.spec.ts b/packages/core/__e2e__/agw/native-multi-action.spec.ts index d5624ebe5..4c3e9af20 100644 --- a/packages/core/__e2e__/agw/native-multi-action.spec.ts +++ b/packages/core/__e2e__/agw/native-multi-action.spec.ts @@ -19,7 +19,7 @@ import { } from '../../src/lib/constants/chain'; import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; import type { AgenticRuntime } from '../../src/lib/agentic/runtime'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { actionId } from '../../src/lib/agentic/codec/ids'; import { readNativeActionConfigs } from '@e2e/shared/agw-state'; import { @@ -145,13 +145,13 @@ d('agw native multi-action', () => { }), f.push.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', blockNumber, }), f.push.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'grantNonce', blockNumber, }), @@ -240,7 +240,7 @@ d('agw native multi-action', () => { callsUsed: 1, }); expect(config(after, token, '0xa9059cbb')).toMatchObject({ - amountSpent: BigInt(2), + amountSpent: BigInt(0), callsUsed: 1, }); expect(after.checkpointCount).toBe(before.checkpointCount); @@ -268,7 +268,7 @@ d('agw native multi-action', () => { await refused( s, () => sendToken(s, BigInt(4)), - 'PolicyCheckReverted(NativeAmountExceedsCap)' + 'PolicyCheckReverted(ArgPinAboveMax)' ); await refused( s, @@ -306,7 +306,7 @@ d('agw native multi-action', () => { callsUsed: 3, }); expect(config(final, token, '0xa9059cbb')).toMatchObject({ - amountSpent: BigInt(6), + amountSpent: BigInt(0), callsUsed: 3, }); f.evidence('native-multi-independent-counters', { @@ -336,7 +336,7 @@ d('agw native multi-action', () => { }); expect(config(after, token, '0xa9059cbb')).toMatchObject({ callsUsed: 1, - amountSpent: BigInt(2), + amountSpent: BigInt(0), }); expect(after.checkpointCount).toBe(before.checkpointCount); const replay = await f.owner.universal.trackTransaction(tx.hash); @@ -367,7 +367,7 @@ d('agw native multi-action', () => { to: f.ownerAddress, data: calls(BigInt(4)), }), - 'PolicyCheckReverted(NativeAmountExceedsCap)' + 'PolicyCheckReverted(ArgPinAboveMax)' ); const executor = PUSH_BATCH_EXECUTOR_ADDRESS[CHAIN.PUSH_TESTNET_DONUT]; if (!executor) throw new Error('Missing verified native batch executor'); @@ -381,11 +381,11 @@ d('agw native multi-action', () => { chain: getPushViemChain(CHAIN.PUSH_TESTNET_DONUT), transport: http(CHAIN_INFO[CHAIN.PUSH_TESTNET_DONUT].defaultRPC[0]), }); - const data = v5.encodeExecute( + const data = v6.encodeExecute( calls(BigInt(4)).map((c) => ({ target: s.wallet, value: ZERO, - data: v5.encodeExecuteAsAgent(s.id, { + data: v6.encodeExecuteAsAgent(s.id, { target: c.to, value: c.value, data: c.data, @@ -434,7 +434,7 @@ d('agw native multi-action', () => { expect( await f.push.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) ).toBe(before.checkpointCount + BigInt(6)); @@ -518,7 +518,7 @@ d('agw native multi-action', () => { config(before, f.ownerAddress, '0xffffffff') ); expect(config(after, token, '0xa9059cbb')).toMatchObject({ - amountSpent: ONE, + amountSpent: ZERO, callsUsed: 1, }); expect(after.walletToken).toBe(before.walletToken - ONE); @@ -531,7 +531,7 @@ d('agw native multi-action', () => { expect( await f.push.readContract({ address: s.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) ).toBe(before.checkpointCount + BigInt(6)); diff --git a/packages/core/__e2e__/agw/native-policy-extended.spec.ts b/packages/core/__e2e__/agw/native-policy-extended.spec.ts index c98c74355..0211b4f77 100644 --- a/packages/core/__e2e__/agw/native-policy-extended.spec.ts +++ b/packages/core/__e2e__/agw/native-policy-extended.spec.ts @@ -132,7 +132,7 @@ d('agw extended native policy', () => { expect(await s.tokenBalance()).toBe(BigInt(40)); expect(await s.counters()).toMatchObject({ valueSpent: BigInt(0), - amountSpent: BigInt(10), + amountSpent: BigInt(0), callsUsed: 1, }); expect( @@ -158,15 +158,15 @@ d('agw extended native policy', () => { }); it('3. per-call amount cap refuses cap plus one', async () => { const s = await fixture(); - await refused(s, () => s.send(BigInt(11)), 'NativeAmountExceedsCap'); + await refused(s, () => s.send(BigInt(11)), 'ArgPinAboveMax'); }); it('4. repeated per-call amounts accumulate spend without a removed lifetime cap', async () => { const s = await fixture(BigInt(50), 10); await (await s.send(BigInt(10))).wait(); await (await s.send(BigInt(5))).wait(); - expect((await s.counters()).amountSpent).toBe(BigInt(15)); + expect((await s.counters()).amountSpent).toBe(BigInt(0)); await (await s.send(BigInt(1))).wait(); - expect((await s.counters()).amountSpent).toBe(BigInt(16)); + expect((await s.counters()).amountSpent).toBe(BigInt(0)); }); it('5. repeated calls have no removed maxCalls limit', async () => { const s = await fixture(); @@ -178,7 +178,7 @@ d('agw extended native policy', () => { }); it('6. explicit zero MAX pin forbids positive amount but permits a metered zero call', async () => { const s = await fixture(BigInt(50), 10, BigInt(0)); - await refused(s, () => s.send(BigInt(1)), 'NativeAmountExceedsCap'); + await refused(s, () => s.send(BigInt(1)), 'ArgPinAboveMax'); await (await s.send(BigInt(0))).wait(); expect(await s.counters()).toMatchObject({ amountSpent: BigInt(0), @@ -234,7 +234,7 @@ d('agw extended native policy', () => { expect((await sent.wait()).status).toBe(1); expect(await s.counters()).toMatchObject({ valueSpent: BigInt(0), - amountSpent: BigInt(1), + amountSpent: BigInt(0), }); f.evidence('zero-pc-defaults', { wallet: s.wallet, txHash: sent.hash }); }); diff --git a/packages/core/__e2e__/agw/svm-public.spec.ts b/packages/core/__e2e__/agw/svm-public.spec.ts index a135ea64b..d548b8fef 100644 --- a/packages/core/__e2e__/agw/svm-public.spec.ts +++ b/packages/core/__e2e__/agw/svm-public.spec.ts @@ -20,7 +20,7 @@ import { privateKeyToAccount } from 'viem/accounts'; import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; import { CHAIN_INFO, getPushViemChain } from '../../src/lib/constants/chain'; import counterIdl from '../../src/lib/orchestrator/svm-idl/__fixtures__/test_counter.idl.json'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { setupAgw, evmClient, inSeconds, type AgwFixture } from './_fixture'; import { loadAgwManifest, verifyAgwManifest } from './_manifest'; import { @@ -148,22 +148,14 @@ d('agw public svm', () => { }), }) ).wait(); - const owner = await evmClient( - ownerKey, - CHAIN.PUSH_TESTNET_DONUT, - manifest.network, - wallet - ); - await ( - await owner.universal.sendTransaction({ - to: token, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [manifest.addresses.gateway, amount * BigInt(2)], - }), + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [wallet, manifest.addresses.gateway], }) - ).wait(); + ).toBe(BigInt(0)); agent = await f.agent(wallet); f.evidence('public-svm-setup', { wallet, @@ -264,12 +256,12 @@ d('agw public svm', () => { f.push.getBalance({ address: wallet }), f.push.readContract({ address: wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }), f.push.readContract({ address: f.manifest.addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getSvmConfig', args: [config, wallet], }), @@ -403,34 +395,14 @@ d('agw public svm', () => { expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( agentNonce ); - // Both funded sends have consumed the bounded allowance. Funding only the - // token lets this case isolate the separate allowance guard. - await ( - await f.owner.universal.sendTransaction({ - to: token, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [wallet, BigInt(1)], - }), - }) - ).wait(); - const sender = await evmClient( - process.env['PUSH_PRIVATE_KEY'] as Hex, - CHAIN.PUSH_TESTNET_DONUT, - f.manifest.network, - wallet - ); - const nonce = await f.push.getTransactionCount({ address: f.ownerAddress }); - await expect( - sender.universal.sendTransaction({ - to: { chain: SVM_WIRE_CHAIN, address: recipient.toBase58() }, - value: BigInt(1), - gasLimit: BigInt(0), + // The owner path no longer requires a standing gateway allowance in v6. + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [wallet, f.manifest.addresses.gateway], }) - ).rejects.toMatchObject({ code: 'GATEWAY_ALLOWANCE_INSUFFICIENT' }); - expect(await f.push.getTransactionCount({ address: f.ownerAddress })).toBe( - nonce - ); + ).toBe(BigInt(0)); }); }); diff --git a/packages/core/__e2e__/agw/svm-spl-transfer.spec.ts b/packages/core/__e2e__/agw/svm-spl-transfer.spec.ts index e35a1110e..cb66a938d 100644 --- a/packages/core/__e2e__/agw/svm-spl-transfer.spec.ts +++ b/packages/core/__e2e__/agw/svm-spl-transfer.spec.ts @@ -6,7 +6,7 @@ import { encodeFunctionData, erc20Abi, parseEther, type Hex } from 'viem'; import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; import { getPRC20Address } from '../../src/lib/universal/prc20-address'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { AGW_E2E_ENABLED, evmClient, @@ -102,20 +102,18 @@ d('agw svm spl transfer', () => { f.manifest.network, made.wallet ); - await ( - await owner.universal.sendTransaction({ - to: token, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [f.manifest.addresses.gateway, amount], - }), + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [made.wallet, f.manifest.addresses.gateway], }) - ).wait(); + ).toBe(BigInt(0)); const beforeRecipient = await recipientBalance(); const beforeCheckpoints = await f.push.readContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); const tx = await owner.universal.sendTransaction({ @@ -164,7 +162,7 @@ d('agw svm spl transfer', () => { expect( await f.push.readContract({ address: made.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) ).toBe(beforeCheckpoints + BigInt(1)); diff --git a/packages/core/__e2e__/agw/universal-evm.spec.ts b/packages/core/__e2e__/agw/universal-evm.spec.ts index f69d6c18f..b2d7fd78a 100644 --- a/packages/core/__e2e__/agw/universal-evm.spec.ts +++ b/packages/core/__e2e__/agw/universal-evm.spec.ts @@ -21,12 +21,13 @@ import { type Hex, } from 'viem'; import { sepolia } from 'viem/chains'; +import type { Rule } from '../../src'; import { CHAIN } from '../../src/lib/constants/enums'; import { CHAIN_INFO } from '../../src/lib/constants/chain'; import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { EVM_CHAIN_FIXTURES } from '@e2e/shared/chain-fixtures'; import { COUNTER_ABI } from '@e2e/shared/outbound-helpers'; import { verifyExternalTransaction } from '@e2e/shared/external-tx-verifier'; @@ -97,23 +98,14 @@ d('agw universal evm', () => { }), }) ).wait(); - // Bounded allowance through the owner door. - const ownerAgw = await evmClient( - process.env['PUSH_PRIVATE_KEY'] as Hex, - CHAIN.PUSH_TESTNET_DONUT, - f.manifest.network, - wallet - ); - await ( - await ownerAgw.universal.sendTransaction({ - to: token, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [f.manifest.addresses.gateway, AMOUNT * BigInt(2)], - }), + expect( + await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [wallet, f.manifest.addresses.gateway], }) - ).wait(); + ).toBe(BigInt(0)); }, 900_000); afterAll(() => f?.teardown()); @@ -202,7 +194,7 @@ d('agw universal evm', () => { functionName: 'allowance', args: [wallet, f.manifest.addresses.gateway], }) - ).toBe(AMOUNT); + ).toBe(BigInt(0)); expect( (await sep.readContract({ address: counter, @@ -241,4 +233,92 @@ d('agw universal evm', () => { externalStatus: receipt.externalStatus, }); }, 900_000); + it('3. v6 destination EQ/MAX alternatives authorize only the pinned spender and amount', async () => { + const target = MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.USDC + .address as Address; + const handle = f.owner.agentic.wallet(wallet); + const record = await handle.rules.get(rulesId); + const updated = await handle.rules.update({ + rules: [ + { + rulesId, + rule: { + ...record.rule, + allowedCalls: [f.ownerAddress, f.agentAddress].map((spender) => ({ + target, + selector: 'approve(address,uint256)', + pins: [ + { arg: 0, expected: spender }, + { arg: 1, max: BigInt(1) }, + ], + })), + } as Rule, + }, + ], + }); + rulesId = updated.rules[0].rulesId; + const agent = await f.agent(wallet); + const send = (spender: Address, amount: bigint) => + agent.universal.sendTransaction({ + to: { address: target, chain: SEPOLIA }, + data: [ + { + to: target, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender, amount], + }), + }, + ], + }); + const nonce = await f.push.getTransactionCount({ address: f.agentAddress }); + await expect( + send('0x000000000000000000000000000000000000dEaD', BigInt(1)) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect(send(f.ownerAddress, BigInt(2))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + expect(await f.push.getTransactionCount({ address: f.agentAddress })).toBe( + nonce + ); + // Use the SECOND alternative and a zero approval: no persistent spending authority is granted. + const tx = await send(f.agentAddress, BigInt(0)); + const receipt = await tx.wait({ outboundTimeoutMs: 600_000 }); + expect(receipt.externalStatus).toMatch(/success/i); + if (!receipt.externalTxHash) throw Error('missing destination receipt'); + const sep = createPublicClient({ + chain: sepolia, + transport: fallback( + CHAIN_INFO[SEPOLIA].defaultRPC.map((url) => http(url)) + ), + }); + const external = await sep.getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + const events = parseEventLogs({ + abi: erc20Abi, + eventName: 'Approval', + logs: external.logs, + }); + expect( + events.some( + (e) => + e.address.toLowerCase() === target.toLowerCase() && + e.args.owner.toLowerCase() === cea.toLowerCase() && + e.args.spender.toLowerCase() === f.agentAddress.toLowerCase() && + e.args.value === BigInt(0) + ) + ).toBe(true); + f.evidence('v6-destination-pins', { + wallet, + cea, + rulesId, + pushTx: tx.hash, + destinationTx: receipt.externalTxHash, + }); + }, 900_000); }); diff --git a/packages/core/__e2e__/agw/v5-multiasset.spec.ts b/packages/core/__e2e__/agw/v5-multiasset.spec.ts index 25bb48281..10ec0f976 100644 --- a/packages/core/__e2e__/agw/v5-multiasset.spec.ts +++ b/packages/core/__e2e__/agw/v5-multiasset.spec.ts @@ -19,7 +19,7 @@ import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; import { configId } from '../../src/lib/agentic/codec/ids'; import { CHAIN } from '../../src/lib/constants/enums'; import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { EVM_CHAIN_FIXTURES } from '@e2e/shared/chain-fixtures'; import { verifyExternalTransaction } from '@e2e/shared/external-tx-verifier'; import { @@ -83,7 +83,7 @@ d('agw v5 multi-asset', () => { }); const count = await f.push.readContract({ address: created.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }); const replacement = await w.rules.update({ @@ -92,7 +92,7 @@ d('agw v5 multi-asset', () => { expect( await f.push.readContract({ address: created.wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'checkpointCount', }) ).toBe(count + BigInt(5)); @@ -138,14 +138,14 @@ d('agw v5 multi-asset', () => { await verifyExternalTransaction(receipt.externalTxHash as string, chain); const [action] = await f.push.readContract({ address: f.manifest.addresses.sessionEngine, - abi: v5.abis.engine, + abi: v6.abis.engine, functionName: 'getEnabledActions', args: [created.wallet, created.rulesIds[0]], }); const { configId } = await import('../../src/lib/agentic/codec/ids'); const cfg = await f.push.readContract({ address: f.manifest.addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getConfig', args: [ configId(created.wallet, created.rulesIds[0], action), @@ -230,7 +230,7 @@ d('agw v5 multi-asset', () => { const agent = await f.agent(made.wallet); const [action] = await f.push.readContract({ address: f.manifest.addresses.sessionEngine, - abi: v5.abis.engine, + abi: v6.abis.engine, functionName: 'getEnabledActions', args: [made.wallet, made.rulesIds[0]], }); @@ -239,7 +239,7 @@ d('agw v5 multi-asset', () => { ( await f.push.readContract({ address: f.manifest.addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getConfig', args: [key, made.wallet], }) @@ -321,7 +321,7 @@ d('agw v5 multi-asset', () => { functionName: 'allowance', args: [made.wallet, f.manifest.addresses.gateway], }) - ).toBe(BigInt(1)); + ).toBe(BigInt(0)); const expected = i === 0 ? [amounts[0], BigInt(0)] : amounts; expect(await spent()).toEqual(expected); const nonce = await f.push.getTransactionCount({ diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index 9e08598ef..8ad742d2b 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -984,6 +984,14 @@ export const SCENARIOS: Scenario[] = [ needs: { masterPC: '0.5' }, env: { AGW_E2E: '1' }, }, + { + id: 'agw-v6-destination-pins', + group: 'agw', + file: '__e2e__/agw/universal-evm.spec.ts', + grep: 'agw universal evm 3\\. v6 destination', + needs: { masterPC: '25' }, + env: { AGW_E2E: '1' }, + }, { id: 'agw-universal-outbound', group: 'agw', diff --git a/packages/core/__e2e__/shared/agw-state.ts b/packages/core/__e2e__/shared/agw-state.ts index 09d834434..32753cffa 100644 --- a/packages/core/__e2e__/shared/agw-state.ts +++ b/packages/core/__e2e__/shared/agw-state.ts @@ -1,6 +1,6 @@ /** Independent contract-state assertions for AGW tests; never part of the public SDK. */ import type { Address, Hex, PublicClient } from 'viem'; -import { v5 } from '../../src/lib/agentic/contracts/v5'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; import { configId } from '../../src/lib/agentic/codec/ids'; export async function readNativeCounters( @@ -11,7 +11,7 @@ export async function readNativeCounters( ) { const actions = await client.readContract({ address: addresses.sessionEngine, - abi: v5.abis.engine, + abi: v6.abis.engine, functionName: 'getEnabledActions', args: [wallet, rulesId], }); @@ -19,7 +19,7 @@ export async function readNativeCounters( throw new Error('Native counter fixture expects one action'); const cfg = await client.readContract({ address: addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getNativeConfig', args: [configId(wallet, rulesId, actions[0]), wallet], }); @@ -41,7 +41,7 @@ export async function readNativeActionConfigs( const block = blockNumber ?? (await client.getBlockNumber({ cacheTime: 0 })); const actions = await client.readContract({ address: addresses.sessionEngine, - abi: v5.abis.engine, + abi: v6.abis.engine, functionName: 'getEnabledActions', args: [wallet, rulesId], blockNumber: block, @@ -52,7 +52,7 @@ export async function readNativeActionConfigs( actionId: action, config: await client.readContract({ address: addresses.rulesPolicy, - abi: v5.abis.policy, + abi: v6.abis.policy, functionName: 'getNativeConfig', args: [configId(wallet, rulesId, action), wallet], blockNumber: block, diff --git a/packages/core/__e2e__/shared/agw-svm-policy-probe.ts b/packages/core/__e2e__/shared/agw-svm-policy-probe.ts index 8b5b0d6c0..51461174d 100644 --- a/packages/core/__e2e__/shared/agw-svm-policy-probe.ts +++ b/packages/core/__e2e__/shared/agw-svm-policy-probe.ts @@ -1,15 +1,35 @@ /** Bypass SDK validation to verify the deployed URP refuses the same mutations. eth_call only. */ -import { decodeFunctionData, encodeFunctionData, type Address, type Hex, type PublicClient } from 'viem'; +import { + decodeFunctionData, + encodeFunctionData, + type Address, + type Hex, + type PublicClient, +} from 'viem'; import { UNIVERSAL_GATEWAY_PC } from '../../src/lib/constants/abi'; -import { v5, type Call } from '../../src/lib/agentic/contracts/v5'; -import { parseAgwSvmPayload, encodeAgwSvmPayload } from '../../src/lib/agentic/execution/svm-payload'; +import { v6, type Call } from '../../src/lib/agentic/contracts/v6'; +import { + parseAgwSvmPayload, + encodeAgwSvmPayload, +} from '../../src/lib/agentic/execution/svm-payload'; import { decodeAgenticRevert } from '../../src/lib/agentic/revert'; -export async function probeSvmWirePolicies(push: PublicClient, input: { - from: Address; wallet: Address; rulesId: Hex; call: Call; blockNumber: bigint; -}) { - const decodedCall = decodeFunctionData({ abi: UNIVERSAL_GATEWAY_PC, data: input.call.data }); - if (decodedCall.functionName !== 'sendUniversalTxOutbound') throw new Error('Expected a gateway outbound request'); +export async function probeSvmWirePolicies( + push: PublicClient, + input: { + from: Address; + wallet: Address; + rulesId: Hex; + call: Call; + blockNumber: bigint; + } +) { + const decodedCall = decodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + data: input.call.data, + }); + if (decodedCall.functionName !== 'sendUniversalTxOutbound') + throw new Error('Expected a gateway outbound request'); const request = decodedCall.args[0]; const parsed = parseAgwSvmPayload(request.payload); const observations = []; @@ -21,14 +41,41 @@ export async function probeSvmWirePolicies(push: PublicClient, input: { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); view.setBigUint64(8, view.getBigUint64(8, true) + BigInt(1), true); } - const altered = { ...request, payload: encodeAgwSvmPayload(parsed.program, accounts, data) }; - const packet = v5.encodeExecuteAsAgent(input.rulesId, { ...input.call, - data: encodeFunctionData({ abi: UNIVERSAL_GATEWAY_PC, functionName: 'sendUniversalTxOutbound', args: [altered] }) }); + const altered = { + ...request, + payload: encodeAgwSvmPayload(parsed.program, accounts, data), + }; + const packet = v6.encodeExecuteAsAgent(input.rulesId, { + ...input.call, + data: encodeFunctionData({ + abi: UNIVERSAL_GATEWAY_PC, + functionName: 'sendUniversalTxOutbound', + args: [altered], + }), + }); let decoded; - try { await push.call({ account: input.from, to: input.wallet, data: packet, blockNumber: input.blockNumber }); } - catch (error) { decoded = decodeAgenticRevert(error); } - if (!decoded?.name?.includes('Svm')) throw new Error(`Expected a decoded SVM policy refusal for ${kind}, got ${decoded?.name ?? 'no decoded refusal'}`); + try { + await push.call({ + account: input.from, + to: input.wallet, + data: packet, + blockNumber: input.blockNumber, + }); + } catch (error) { + decoded = decodeAgenticRevert(error); + } + if (!decoded?.name?.includes('Svm')) + throw new Error( + `Expected a decoded SVM policy refusal for ${kind}, got ${ + decoded?.name ?? 'no decoded refusal' + }` + ); observations.push({ kind, reverted: true, decoded }); } - return { chainId: 42101, block: input.blockNumber.toString(), observations, transactionsSent: 0 }; + return { + chainId: 42101, + block: input.blockNumber.toString(), + observations, + transactionsSent: 0, + }; } diff --git a/packages/core/scripts/agw-local/abis/factory.json b/packages/core/scripts/agw-local/abis/factory.json index 304355405..483490b2c 100644 --- a/packages/core/scripts/agw-local/abis/factory.json +++ b/packages/core/scripts/agw-local/abis/factory.json @@ -96,6 +96,364 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "createWallet", + "inputs": [ + { + "name": "label", + "type": "string", + "internalType": "string" + }, + { + "name": "rules", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "rulesIds", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createWalletWithSig", + "inputs": [ + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "label", + "type": "string", + "internalType": "string" + }, + { + "name": "rules", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "rulesIds", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "defaultAdmin", @@ -1012,6 +1370,28 @@ } ] }, + { + "type": "error", + "name": "IntentGrantNonceNotZero", + "inputs": [ + { + "name": "provided", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "IntentRulesMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, { "type": "error", "name": "IntentWalletMismatch", @@ -1081,6 +1461,17 @@ } ] }, + { + "type": "error", + "name": "TooManyInitialRules", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "UUPSUnauthorizedCallContext", diff --git a/packages/core/scripts/agw-local/abis/manifest.json b/packages/core/scripts/agw-local/abis/manifest.json index 7c13c7e09..206d25bd5 100644 --- a/packages/core/scripts/agw-local/abis/manifest.json +++ b/packages/core/scripts/agw-local/abis/manifest.json @@ -1,12 +1,13 @@ { "sourceRepository": "https://github.com/pushchain/push-agentic-wallets", - "sourceCommit": "2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb", - "deployedAbiEvidence": "Donut v5 source compiled in an isolated tree; deployment wiring checked at block 23991912; URP/engine/validator reused from v4", + "sourceCommit": "ffd567d3e49b2d27c6f50ad36093274dbac3090f", + "deployedAbiEvidence": "v6 isolated source compilation; initial live code sizes/wiring checked at block 24244801", "files": { - "wallet.json": "6a145964d6c43eaa6223dc8f3d59a540ac0c2f447f25c3a99007eea25de9c695", - "factory.json": "b65f66a1556d2a9ddf9aabe9f595c685c2aada436772892f5a40fb1eec846397", - "policy.json": "c7e3cd5c7f8e47be217c77ebebde4c090903cea7db63024838e434eb6f1fd77b", + "wallet.json": "76705e5a25b10d3a84f867f983c3e4f5adfc881fb0d65710fb8684df2ea7abd3", + "factory.json": "08588f8870335492c5abeee32eec6a9bb050f81e234fdd58bbf389c6e9074a12", + "policy.json": "a07661d19b19656afdea560275d45669a908cd96646817884295e0017560f299", "engine.json": "6e5d9cea4dbed01f06af913c1e093743b86b7432195f4a4bf90fb94b282c51b2", "validator.json": "a4570ddc373241580de8742cbb3d9f2c837094c65bfd1cdd52243ca0c38196fd" - } + }, + "note": "Policy ABI includes linked SvmRulebook errors for revert decoding; callable ABI is unchanged." } diff --git a/packages/core/scripts/agw-local/abis/policy.json b/packages/core/scripts/agw-local/abis/policy.json index 88e39505a..65270f95b 100644 --- a/packages/core/scripts/agw-local/abis/policy.json +++ b/packages/core/scripts/agw-local/abis/policy.json @@ -281,6 +281,28 @@ "name": "maxValue", "type": "uint256", "internalType": "uint256" + }, + { + "name": "pins", + "type": "tuple[]", + "internalType": "struct ArgPin[]", + "components": [ + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "op", + "type": "uint8", + "internalType": "enum PinOp" + }, + { + "name": "value", + "type": "bytes32", + "internalType": "bytes32" + } + ] } ] } @@ -444,7 +466,12 @@ "internalType": "uint16" }, { - "name": "expected", + "name": "op", + "type": "uint8", + "internalType": "enum PinOp" + }, + { + "name": "value", "type": "bytes32", "internalType": "bytes32" } @@ -981,15 +1008,15 @@ }, { "type": "error", - "name": "AmbiguousRule", + "name": "AmountExceedsCap", "inputs": [ { - "name": "i", + "name": "amount", "type": "uint256", "internalType": "uint256" }, { - "name": "j", + "name": "cap", "type": "uint256", "internalType": "uint256" } @@ -997,26 +1024,20 @@ }, { "type": "error", - "name": "AmountExceedsCap", + "name": "ArgPinAboveMax", "inputs": [ { - "name": "amount", + "name": "actual", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", + "name": "index", "type": "uint256", "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "AmountExceedsU64", - "inputs": [ + }, { - "name": "amount", + "name": "max", "type": "uint256", "internalType": "uint256" } @@ -1097,22 +1118,6 @@ } ] }, - { - "type": "error", - "name": "BeneficiaryMismatch", - "inputs": [ - { - "name": "expected", - "type": "address", - "internalType": "address" - }, - { - "name": "actual", - "type": "address", - "internalType": "address" - } - ] - }, { "type": "error", "name": "CallLimitReached", @@ -1204,27 +1209,6 @@ } ] }, - { - "type": "error", - "name": "CeaAccountAtUnpinnedIndex", - "inputs": [ - { - "name": "accountIndex", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "account", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "CeaAccountsMissExpectedCEA", - "inputs": [] - }, { "type": "error", "name": "ChainMismatch", @@ -1241,22 +1225,6 @@ } ] }, - { - "type": "error", - "name": "DiscriminatorLenOutOfRange", - "inputs": [ - { - "name": "rule", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "len", - "type": "uint8", - "internalType": "uint8" - } - ] - }, { "type": "error", "name": "DuplicateAsset", @@ -1268,22 +1236,6 @@ } ] }, - { - "type": "error", - "name": "DuplicateSvmPin", - "inputs": [ - { - "name": "first", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "second", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "EmptyChain", @@ -1300,54 +1252,6 @@ } ] }, - { - "type": "error", - "name": "ForbiddenProgramInAllowList", - "inputs": [ - { - "name": "rule", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "program", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "ForbiddenTargetProgram", - "inputs": [ - { - "name": "program", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "InnerValueExceedsAllowance", - "inputs": [ - { - "name": "index", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "value", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "maxValue", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "InvalidAsset", @@ -1359,17 +1263,6 @@ } ] }, - { - "type": "error", - "name": "InvalidCeaAccount", - "inputs": [ - { - "name": "index", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "InvalidConfigField", @@ -1418,11 +1311,6 @@ } ] }, - { - "type": "error", - "name": "InvalidSvmConfigField", - "inputs": [] - }, { "type": "error", "name": "InvalidTarget", @@ -1450,17 +1338,6 @@ } ] }, - { - "type": "error", - "name": "MalformedSvmPayload", - "inputs": [ - { - "name": "code", - "type": "uint8", - "internalType": "uint8" - } - ] - }, { "type": "error", "name": "NativeAmountExceedsCap", @@ -1556,76 +1433,11 @@ } ] }, - { - "type": "error", - "name": "ProgramListOutOfRange", - "inputs": [ - { - "name": "length", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "ProgramNotAllowed", - "inputs": [ - { - "name": "program", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "discriminator", - "type": "bytes8", - "internalType": "bytes8" - } - ] - }, { "type": "error", "name": "RecipientMustBeEmpty", "inputs": [] }, - { - "type": "error", - "name": "RecipientNotPubkey", - "inputs": [ - { - "name": "length", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "RecipientTargetMismatch", - "inputs": [ - { - "name": "recipient", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "target", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "RuleWithoutPin", - "inputs": [ - { - "name": "rule", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "RulesExpired", @@ -1687,73 +1499,85 @@ }, { "type": "error", - "name": "SvmAccountCountBelowPin", + "name": "TargetMismatch", "inputs": [ { - "name": "pin", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "count", - "type": "uint256", - "internalType": "uint256" + "name": "actual", + "type": "address", + "internalType": "address" }, { - "name": "needed", - "type": "uint8", - "internalType": "uint8" + "name": "expected", + "type": "address", + "internalType": "address" } ] }, { "type": "error", - "name": "SvmAccountCountMismatch", + "name": "TooManyAllowedCallPins", "inputs": [ { - "name": "count", + "name": "total", "type": "uint256", "internalType": "uint256" - }, - { - "name": "expected", - "type": "uint8", - "internalType": "uint8" } ] }, { "type": "error", - "name": "SvmAccountPinMismatch", + "name": "TooManyPins", "inputs": [ { - "name": "pin", + "name": "count", "type": "uint256", "internalType": "uint256" - }, + } + ] + }, + { + "type": "error", + "name": "TotalNativeAmountExceeded", + "inputs": [ { - "name": "accountIndex", - "type": "uint8", - "internalType": "uint8" + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" }, { - "name": "expected", - "type": "bytes32", - "internalType": "bytes32" + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalSpendCapExceeded", + "inputs": [ + { + "name": "wouldBeTotal", + "type": "uint256", + "internalType": "uint256" }, { - "name": "actual", - "type": "bytes32", - "internalType": "bytes32" + "name": "cap", + "type": "uint256", + "internalType": "uint256" } ] }, { "type": "error", - "name": "SvmAccountsOutOfRange", + "name": "TotalValueExceeded", "inputs": [ { - "name": "count", + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", "type": "uint256", "internalType": "uint256" } @@ -1761,20 +1585,42 @@ }, { "type": "error", - "name": "SvmDataCeilingExceeded", + "name": "UncappedGasSwapRejected", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedEnvelopeVersion", "inputs": [ { - "name": "pin", + "name": "version", "type": "uint256", "internalType": "uint256" - }, + } + ] + }, + { + "type": "error", + "name": "UnsupportedNamespace", + "inputs": [ { - "name": "actual", + "name": "chainHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ValueExceedsCap", + "inputs": [ + { + "name": "value", "type": "uint256", "internalType": "uint256" }, { - "name": "ceiling", + "name": "cap", "type": "uint256", "internalType": "uint256" } @@ -1782,20 +1628,68 @@ }, { "type": "error", - "name": "SvmDataFloorNotMet", + "name": "ValueOnlyCalldataNotEmpty", "inputs": [ { - "name": "pin", + "name": "length", "type": "uint256", "internalType": "uint256" - }, + } + ] + }, + { + "type": "error", + "name": "ValueOnlyWithAmountRule", + "inputs": [] + }, + { + "type": "error", + "name": "ValueOnlyWithPins", + "inputs": [] + }, + { + "type": "error", + "name": "WrongModeForCall", + "inputs": [ + { + "name": "actual", + "type": "uint8", + "internalType": "enum RulesType" + } + ] + }, + { + "type": "error", + "name": "WrongVmForCall", + "inputs": [ { "name": "actual", + "type": "uint8", + "internalType": "enum VmFamily" + } + ] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "ZeroMaxCalls", + "inputs": [] + }, + { + "type": "error", + "name": "AmbiguousRule", + "inputs": [ + { + "name": "i", "type": "uint256", "internalType": "uint256" }, { - "name": "floor", + "name": "j", "type": "uint256", "internalType": "uint256" } @@ -1803,10 +1697,10 @@ }, { "type": "error", - "name": "SvmDataPinInvalid", + "name": "AmountExceedsU64", "inputs": [ { - "name": "pin", + "name": "amount", "type": "uint256", "internalType": "uint256" } @@ -1814,20 +1708,15 @@ }, { "type": "error", - "name": "SvmDataPinMismatch", + "name": "CeaAccountAtUnpinnedIndex", "inputs": [ { - "name": "pin", + "name": "accountIndex", "type": "uint256", "internalType": "uint256" }, { - "name": "expected", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "actual", + "name": "account", "type": "bytes32", "internalType": "bytes32" } @@ -1835,46 +1724,74 @@ }, { "type": "error", - "name": "SvmDataRatioNotMet", + "name": "CeaAccountsMissExpectedCEA", + "inputs": [] + }, + { + "type": "error", + "name": "DiscriminatorLenOutOfRange", "inputs": [ { - "name": "pin", + "name": "rule", "type": "uint256", "internalType": "uint256" }, { - "name": "a", + "name": "len", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "DuplicateSvmPin", + "inputs": [ + { + "name": "first", "type": "uint256", "internalType": "uint256" }, { - "name": "b", + "name": "second", "type": "uint256", "internalType": "uint256" - }, - { - "name": "num", - "type": "uint64", - "internalType": "uint64" - }, - { - "name": "den", - "type": "uint64", - "internalType": "uint64" } ] }, { "type": "error", - "name": "SvmDataTooShortForPin", + "name": "ForbiddenProgramInAllowList", "inputs": [ { - "name": "pin", + "name": "rule", "type": "uint256", "internalType": "uint256" }, { - "name": "ixLen", + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ForbiddenTargetProgram", + "inputs": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidCeaAccount", + "inputs": [ + { + "name": "index", "type": "uint256", "internalType": "uint256" } @@ -1882,10 +1799,15 @@ }, { "type": "error", - "name": "SvmInstructionNotExecute", + "name": "InvalidSvmConfigField", + "inputs": [] + }, + { + "type": "error", + "name": "MalformedSvmPayload", "inputs": [ { - "name": "instructionId", + "name": "code", "type": "uint8", "internalType": "uint8" } @@ -1893,7 +1815,7 @@ }, { "type": "error", - "name": "SvmIxDataTooLong", + "name": "ProgramListOutOfRange", "inputs": [ { "name": "length", @@ -1904,28 +1826,61 @@ }, { "type": "error", - "name": "SvmMaxAccountsOutOfRange", + "name": "ProgramNotAllowed", "inputs": [ { - "name": "rule", + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "discriminator", + "type": "bytes8", + "internalType": "bytes8" + } + ] + }, + { + "type": "error", + "name": "RecipientNotPubkey", + "inputs": [ + { + "name": "length", "type": "uint256", "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "RecipientTargetMismatch", + "inputs": [ + { + "name": "recipient", + "type": "bytes32", + "internalType": "bytes32" }, { - "name": "maxAccounts", - "type": "uint8", - "internalType": "uint8" + "name": "target", + "type": "bytes32", + "internalType": "bytes32" } ] }, { "type": "error", - "name": "SvmPayloadEmpty", - "inputs": [] + "name": "RuleWithoutPin", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + } + ] }, { "type": "error", - "name": "SvmPinIndexOutOfRange", + "name": "SvmAccountCountBelowPin", "inputs": [ { "name": "pin", @@ -1933,7 +1888,12 @@ "internalType": "uint256" }, { - "name": "accountIndex", + "name": "count", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", "type": "uint8", "internalType": "uint8" } @@ -1941,15 +1901,15 @@ }, { "type": "error", - "name": "SvmPinRuleOutOfRange", + "name": "SvmAccountCountMismatch", "inputs": [ { - "name": "pin", + "name": "count", "type": "uint256", "internalType": "uint256" }, { - "name": "rule", + "name": "expected", "type": "uint8", "internalType": "uint8" } @@ -1957,23 +1917,33 @@ }, { "type": "error", - "name": "TargetMismatch", + "name": "SvmAccountPinMismatch", "inputs": [ { - "name": "actual", - "type": "address", - "internalType": "address" + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" }, { "name": "expected", - "type": "address", - "internalType": "address" + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" } ] }, { "type": "error", - "name": "TooManyCeaAccounts", + "name": "SvmAccountsOutOfRange", "inputs": [ { "name": "count", @@ -1984,10 +1954,20 @@ }, { "type": "error", - "name": "TooManyPins", + "name": "SvmDataCeilingExceeded", "inputs": [ { - "name": "count", + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "ceiling", "type": "uint256", "internalType": "uint256" } @@ -1995,10 +1975,20 @@ }, { "type": "error", - "name": "TooManySvmDataPins", + "name": "SvmDataFloorNotMet", "inputs": [ { - "name": "count", + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "floor", "type": "uint256", "internalType": "uint256" } @@ -2006,10 +1996,10 @@ }, { "type": "error", - "name": "TooManySvmPins", + "name": "SvmDataPinInvalid", "inputs": [ { - "name": "count", + "name": "pin", "type": "uint256", "internalType": "uint256" } @@ -2017,47 +2007,67 @@ }, { "type": "error", - "name": "TotalNativeAmountExceeded", + "name": "SvmDataPinMismatch", "inputs": [ { - "name": "newSpent", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", - "type": "uint256", - "internalType": "uint256" + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" } ] }, { "type": "error", - "name": "TotalSpendCapExceeded", + "name": "SvmDataRatioNotMet", "inputs": [ { - "name": "wouldBeTotal", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", + "name": "a", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "b", "type": "uint256", "internalType": "uint256" + }, + { + "name": "num", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "den", + "type": "uint64", + "internalType": "uint64" } ] }, { "type": "error", - "name": "TotalValueExceeded", + "name": "SvmDataTooShortForPin", "inputs": [ { - "name": "newSpent", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", + "name": "ixLen", "type": "uint256", "internalType": "uint256" } @@ -2065,15 +2075,21 @@ }, { "type": "error", - "name": "UncappedGasSwapRejected", - "inputs": [] + "name": "SvmInstructionNotExecute", + "inputs": [ + { + "name": "instructionId", + "type": "uint8", + "internalType": "uint8" + } + ] }, { "type": "error", - "name": "UnsupportedEnvelopeVersion", + "name": "SvmIxDataTooLong", "inputs": [ { - "name": "version", + "name": "length", "type": "uint256", "internalType": "uint256" } @@ -2081,77 +2097,88 @@ }, { "type": "error", - "name": "UnsupportedNamespace", + "name": "SvmMaxAccountsOutOfRange", "inputs": [ { - "name": "chainHash", - "type": "bytes32", - "internalType": "bytes32" + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxAccounts", + "type": "uint8", + "internalType": "uint8" } ] }, { "type": "error", - "name": "ValueExceedsCap", + "name": "SvmPayloadEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "SvmPinIndexOutOfRange", "inputs": [ { - "name": "value", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", - "type": "uint256", - "internalType": "uint256" + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" } ] }, { "type": "error", - "name": "ValueOnlyCalldataNotEmpty", + "name": "SvmPinRuleOutOfRange", "inputs": [ { - "name": "length", + "name": "pin", "type": "uint256", "internalType": "uint256" + }, + { + "name": "rule", + "type": "uint8", + "internalType": "uint8" } ] }, { "type": "error", - "name": "ValueOnlyWithAmountRule", - "inputs": [] - }, - { - "type": "error", - "name": "ValueOnlyWithPins", - "inputs": [] - }, - { - "type": "error", - "name": "WrongModeForCall", + "name": "TooManyCeaAccounts", "inputs": [ { - "name": "actual", - "type": "uint8", - "internalType": "enum RulesType" + "name": "count", + "type": "uint256", + "internalType": "uint256" } ] }, { "type": "error", - "name": "WrongVmForCall", + "name": "TooManySvmDataPins", "inputs": [ { - "name": "actual", - "type": "uint8", - "internalType": "enum VmFamily" + "name": "count", + "type": "uint256", + "internalType": "uint256" } ] }, { "type": "error", - "name": "ZeroAddress", - "inputs": [] + "name": "TooManySvmPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] } ] diff --git a/packages/core/scripts/agw-local/abis/wallet.json b/packages/core/scripts/agw-local/abis/wallet.json index 5d5f66125..311c9a6fa 100644 --- a/packages/core/scripts/agw-local/abis/wallet.json +++ b/packages/core/scripts/agw-local/abis/wallet.json @@ -667,9 +667,134 @@ "name": "label_", "type": "string", "internalType": "string" + }, + { + "name": "rules", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "rulesIds", + "type": "bytes32[]", + "internalType": "bytes32[]" } ], - "outputs": [], "stateMutability": "nonpayable" }, { diff --git a/packages/core/scripts/agw-local/gen-abi.mjs b/packages/core/scripts/agw-local/gen-abi.mjs index 0430e31a6..a8660f784 100644 --- a/packages/core/scripts/agw-local/gen-abi.mjs +++ b/packages/core/scripts/agw-local/gen-abi.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Regenerates packages/core/src/lib/agentic/contracts/abi/v5/*.ts from the +// Regenerates packages/core/src/lib/agentic/contracts/abi/v6/*.ts from the // pinned ABI snapshots in ./abis/. The JSON files are the source artifact set; // this script only re-serialises them as // `as const` TypeScript so viem can infer types. Run from the repo root: @@ -12,8 +12,8 @@ import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const repo = resolve(here, '../../../..'); const src = join(here, 'abis'); -const dst = join(repo, 'packages/core/src/lib/agentic/contracts/abi/v5'); -const PIN = '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb'; +const dst = join(repo, 'packages/core/src/lib/agentic/contracts/abi/v6'); +const PIN = 'ffd567d3e49b2d27c6f50ad36093274dbac3090f'; const sourceNames = { AGW: 'wallet', @@ -37,7 +37,6 @@ if (manifest.sourceCommit !== PIN) { throw new Error('ABI source commit does not match the supported generation'); } - mkdirSync(dst, { recursive: true }); const exports = []; for (const [name, [constName, file]] of Object.entries(files)) { @@ -64,8 +63,8 @@ for (const [name, [constName, file]] of Object.entries(files)) { writeFileSync( join(dst, 'index.ts'), `// GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit.\n` + - `// Review generation v5: Donut v5 deployment; source 2e61e13.\n` + - `export const V5_SOURCE_COMMIT = '${PIN}' as const;\n` + + `// Review generation v6: Donut v6 deployment; source ffd567d.\n` + + `export const V6_SOURCE_COMMIT = '${PIN}' as const;\n` + exports.join('\n') + '\n' ); diff --git a/packages/core/scripts/agw-local/prepare.sh b/packages/core/scripts/agw-local/prepare.sh index 9cd1923cb..6e3d487e8 100755 --- a/packages/core/scripts/agw-local/prepare.sh +++ b/packages/core/scripts/agw-local/prepare.sh @@ -5,7 +5,7 @@ # AGW_REPO path to a push-agentic-wallets clone (default: ../../../../push-agentic-wallets # relative to this script). Only `git archive` / `git ls-tree` are run against it, # so the clone's working tree, branches and index are never touched. -# AGW_PIN commit to extract (default: the reviewed v5). +# AGW_PIN commit to extract (default: the reviewed v6). # OUT destination directory (default: $TMPDIR/push-agw-local-). # # The script extracts the pinned tree, fetches each git submodule at the exact @@ -15,7 +15,7 @@ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AGW_REPO="${AGW_REPO:-$HERE/../../../../../push-agentic-wallets}" -AGW_PIN="${AGW_PIN:-2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb}" +AGW_PIN="${AGW_PIN:-ffd567d3e49b2d27c6f50ad36093274dbac3090f}" OUT="${OUT:-${TMPDIR:-/tmp}/push-agw-local-${AGW_PIN:0:7}}" command -v forge >/dev/null || { echo "forge not found (install Foundry)" >&2; exit 1; } diff --git a/packages/core/src/lib/agentic/__fixtures__/v5-vectors.json b/packages/core/src/lib/agentic/__fixtures__/v6-vectors.json similarity index 84% rename from packages/core/src/lib/agentic/__fixtures__/v5-vectors.json rename to packages/core/src/lib/agentic/__fixtures__/v6-vectors.json index 3f037d15d..b1ecfd808 100644 --- a/packages/core/src/lib/agentic/__fixtures__/v5-vectors.json +++ b/packages/core/src/lib/agentic/__fixtures__/v6-vectors.json @@ -1,6 +1,6 @@ { "provenance": { - "agwCommit": "2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb", + "agwCommit": "ffd567d3e49b2d27c6f50ad36093274dbac3090f", "generatedBy": "packages/core/__agw-local__/vectors.spec.ts", "toolchain": "forge Version: 1.5.1-stable", "chainId": 9000, @@ -118,24 +118,24 @@ ], "deriveWallet": [ { - "factory": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", - "walletImplementation": "0x0165878A594ca255338adfa4d48449f69242Eb8F", + "factory": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "walletImplementation": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", "owner": "0x90F79bf6EB2c4f870365E785982E1f101E93b906", "index": 0, - "expected": "0xE68bD7D2E9b2F21bA9e4e09cE2D20A3d681Ec6e1" + "expected": "0x030A9Ef421d9fEd942Fc5C3629A61FD0E7826c95" }, { - "factory": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", - "walletImplementation": "0x0165878A594ca255338adfa4d48449f69242Eb8F", + "factory": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "walletImplementation": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", "owner": "0x1111111111111111111111111111111111111111", "index": 0, - "expected": "0x3066c721A477afBF4c3732F39A0048684e4641cF" + "expected": "0xaa19c76fe8534a2c859733Bfb11016C375b0302e" } ], "native": { "rule": { "agent": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "target": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "target": "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", "selector": "deposit(address,uint256)", "validUntil": 4102444800, "pins": [ @@ -162,20 +162,21 @@ "maxCalls": 3 }, "nativeChain": "eip155:9000", - "wallet": "0x3e8FE21F60352CE4e632537113451A6990b9b7e6", + "wallet": "0x619ce4E44bBe9a0EDAb3420848E756FFC6274Fe1", "rulesId": "0xed16789aedd36446aa851df227d231dfdaab75ab95d022e251d179e27fdcee59", - "actionId": "0x95eb75f6723928adc7fd1b4edeec4a558cc76cef41e06b7a16448bf74364d791", - "configId": "0x939681af8de835b5dde52d6056defa47df21b9325892b5945f113a8112179e69", + "actionId": "0x3fc42bf28b699aaf7e81050814910333318858878011ce9ab981ca13b7183afb", + "configId": "0xa813ee3036f4b5f260cad87886da068f472515ed393fa5d797c5ace14fcb2adf", "configInitialized": true, - "storedTarget": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "storedTarget": "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", "storedSelector": "0x47e7ef24", "storedPins": [ { "offset": 4, - "expected": "0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc" + "op": 0, + "value": "0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc" } ], "storedAmountOffset": 36, - "encodedSession": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f051200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002047e7ef24000000000000000000000000000000000000000000000000000000000000000000000000000000008a791620dd6260079bf849dc5567adc3f2fdc318000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000cf7ed3acca5a467e9e704c703e8d87f634fb0fc9000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000b6569703135353a3930303000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000f48657000000000000000000000000008a791620dd6260079bf849dc5567adc3f2fdc31847e7ef24000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000040000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc" + "encodedSession": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f051200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002047e7ef2400000000000000000000000000000000000000000000000000000000000000000000000000000000610178da211fef7d417bc0e6fed39f05609ad788000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dc64a140aa3e981100a9beca4e685f962f0cf6c9000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002c00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000b6569703135353a393030300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000f4865700000000000000000000000000610178da211fef7d417bc0e6fed39f05609ad78847e7ef24000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc" } } diff --git a/packages/core/src/lib/agentic/__tests__/codec.spec.ts b/packages/core/src/lib/agentic/__tests__/codec.spec.ts index 6721ad685..a13680f09 100644 --- a/packages/core/src/lib/agentic/__tests__/codec.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/codec.spec.ts @@ -1,5 +1,5 @@ import { decodeFunctionData, getAddress, type Address, type Hex } from 'viem'; -import vectorsJson from '../__fixtures__/v5-vectors.json'; +import vectorsJson from '../__fixtures__/v6-vectors.json'; import { actionId, agentConfig, @@ -23,8 +23,8 @@ import { import { parseSelector, VALUE_ONLY_SELECTOR } from '../codec/selectors'; import { argumentOffset, headSize } from '../codec/abi-layout'; import { UINT256_MAX } from '../codec/defaults'; -import { v5 } from '../contracts/v5'; -import { AGW_ABI } from '../contracts/abi/v5'; +import { v6 } from '../contracts/v6'; +import { AGW_ABI } from '../contracts/abi/v6'; import { AGENTIC_ERROR_CODE } from '../errors'; import type { NativeRule } from '../agentic.types'; @@ -59,7 +59,7 @@ const vectors = revive(vectorsJson) as unknown as { configInitialized: boolean; storedTarget: Address; storedSelector: Hex; - storedPins: { offset: number; expected: Hex }[]; + storedPins: { offset: number; op: number; value: Hex }[]; storedAmountOffset: number; encodedSession: Hex; }; @@ -141,8 +141,8 @@ describe('native rule codec', () => { expect(back).toEqual({ ...terms, validUntil: terms.validUntil }); const rule = nativeTermsToRule(back, v.rule.agent); // Stored terms carry offsets, not an ABI: decoded rules report the raw offset form. - expect(rule.pins).toEqual([ - { offset: 4, expected: terms.pins[0].expected.toLowerCase() }, + expect(rule.wirePins).toEqual([ + { offset: 4, op: 0, value: terms.pins[0].value.toLowerCase() }, ]); expect(rule.amount).toEqual({ offset: 36, @@ -165,8 +165,8 @@ describe('native rule codec', () => { const { terms } = nativeRuleToTerms(input, { nowSeconds: NOW }); expect(terms.pins[0].offset).toBe(4 + 64); const decoded = nativeTermsToRule(terms, agent); - expect(decoded.pins).toEqual([ - { offset: 68, expected: terms.pins[0].expected }, + expect(decoded.wirePins).toEqual([ + { offset: 68, op: 0, value: terms.pins[0].value }, ]); expect(nativeRuleToTerms(decoded, { nowSeconds: NOW }).terms).toEqual( terms @@ -182,7 +182,7 @@ describe('native rule codec', () => { pins: [{ arg: 0, expected: agent }], }; const { terms } = nativeRuleToTerms(input, { nowSeconds: NOW }); - const word = terms.pins[0].expected; + const word = terms.pins[0].value; expect( nativeRuleToTerms( { ...input, pins: [{ arg: 0, expected: word }] }, @@ -221,7 +221,7 @@ describe('native rule codec', () => { { ...base, pins: [{ offset: 36, expected: `0x${'00'.repeat(31)}01` }] }, { nowSeconds: NOW } ).terms.pins - ).toEqual([{ offset: 36, expected: `0x${'00'.repeat(31)}01` }]); + ).toEqual([{ offset: 36, op: 0, value: `0x${'00'.repeat(31)}01` }]); }); it('applies confirmed zero PC defaults and independent token/call defaults', () => { @@ -235,18 +235,39 @@ describe('native rule codec', () => { }, { nowSeconds: NOW } ); - expect(terms.maxValuePerCall).toBe( - BigInt(0) - ); + expect(terms.maxValuePerCall).toBe(BigInt(0)); expect(terms.maxValueTotal).toBe(BigInt(0)); - expect(terms.maxCalls).toBe(0); + expect(terms.maxCalls).toBe(0xffffffff); expect(terms.amount.maxTotal).toBe(UINT256_MAX); }); it('rejects ambiguous arg-plus-offset authoring for both pins and amount', () => { - const rule = { agent, target, selector: 'transfer(address,uint256)' as const, validUntil: NOW + 10 }; - expect(() => nativeRuleToTerms({ ...rule, pins: [{ arg: 0, offset: 4, expected: `0x${'00'.repeat(32)}` } as never] }, { nowSeconds: NOW })).toThrow('mutually exclusive'); - expect(() => nativeRuleToTerms({ ...rule, amount: { arg: 1, offset: 36, maxPerCall: BigInt(1) } as never }, { nowSeconds: NOW })).toThrow('mutually exclusive'); + const rule = { + agent, + target, + selector: 'transfer(address,uint256)' as const, + validUntil: NOW + 10, + }; + expect(() => + nativeRuleToTerms( + { + ...rule, + pins: [ + { arg: 0, offset: 4, expected: `0x${'00'.repeat(32)}` } as never, + ], + }, + { nowSeconds: NOW } + ) + ).toThrow('mutually exclusive'); + expect(() => + nativeRuleToTerms( + { + ...rule, + amount: { arg: 1, offset: 36, maxPerCall: BigInt(1) } as never, + }, + { nowSeconds: NOW } + ) + ).toThrow('mutually exclusive'); }); it('keeps an explicit zero as zero', () => { @@ -403,12 +424,12 @@ describe('wallet call encoders decode back to the same calls', () => { const call = { target, value: BigInt(7), data: '0xd09de08a' as Hex }; it('execute single uses packed ERC-7579 calldata', () => { - const data = v5.encodeExecute([call]); + const data = v6.encodeExecute([call]); const { functionName, args } = decodeFunctionData({ abi: AGW_ABI, data }); expect(functionName).toBe('execute'); expect(args[0]).toBe(`0x${'00'.repeat(32)}`); expect((args[1] as Hex).length).toBe(2 + 2 * (20 + 32 + 4)); - expect(v5.decodeWalletCall(data)).toEqual({ + expect(v6.decodeWalletCall(data)).toEqual({ kind: 'execute', mode: args[0], calls: [call], @@ -416,8 +437,8 @@ describe('wallet call encoders decode back to the same calls', () => { }); it('execute batch uses abi.encode(Execution[])', () => { - const data = v5.encodeExecute([call, { ...call, value: BigInt(0) }]); - const decoded = v5.decodeWalletCall(data); + const data = v6.encodeExecute([call, { ...call, value: BigInt(0) }]); + const decoded = v6.decodeWalletCall(data); expect(decoded).toMatchObject({ kind: 'execute', mode: `0x01${'00'.repeat(31)}`, @@ -430,7 +451,7 @@ describe('wallet call encoders decode back to the same calls', () => { it('executeAsAgent carries the rule ID and one packed call', () => { const id = `0x${'42'.repeat(32)}` as Hex; - expect(v5.decodeWalletCall(v5.encodeExecuteAsAgent(id, call))).toEqual({ + expect(v6.decodeWalletCall(v6.encodeExecuteAsAgent(id, call))).toEqual({ kind: 'executeAsAgent', rulesId: id, mode: `0x${'00'.repeat(32)}`, @@ -439,6 +460,6 @@ describe('wallet call encoders decode back to the same calls', () => { }); it('unknown calldata is not mistaken for a wallet call', () => { - expect(v5.decodeWalletCall('0xdeadbeef')).toBeNull(); + expect(v6.decodeWalletCall('0xdeadbeef')).toBeNull(); }); }); diff --git a/packages/core/src/lib/agentic/__tests__/fake-chain.ts b/packages/core/src/lib/agentic/__tests__/fake-chain.ts index 1b38300ea..d4f824a7f 100644 --- a/packages/core/src/lib/agentic/__tests__/fake-chain.ts +++ b/packages/core/src/lib/agentic/__tests__/fake-chain.ts @@ -18,7 +18,7 @@ import { type TransactionReceipt, } from 'viem'; import { PUSH_NETWORK } from '../../constants/enums'; -import { v5 } from '../contracts/v5'; +import { v6 } from '../contracts/v6'; import type { ChainReader } from '../contracts/reader'; import { actionId, configId, deriveWallet } from '../codec/ids'; import { @@ -26,7 +26,7 @@ import { resetAgenticGenerations, type AgenticGeneration, } from '../deployments'; -import type { NativeConfigRead } from '../contracts/v5'; +import type { NativeConfigRead } from '../contracts/v6'; export const ADDR = { factory: getAddress('0x00000000000000000000000000000000000fac70'), @@ -113,7 +113,7 @@ export class FakeChain implements ChainReader { this.extraLogs.push( log( ADDR.factory, - v5.events.walletDeployed, + v6.events.walletDeployed, { owner, index, wallet: address }, { label }, BigInt(10) @@ -253,7 +253,7 @@ export class FakeChain implements ChainReader { maxTotal: BigInt(0), }, amountSpent: BigInt(0), - maxCalls: 0, + maxCalls: 0xffffffff, callsUsed: 0, pins: [], ...n, @@ -279,6 +279,7 @@ export class FakeChain implements ChainReader { selector: '0xd09de08a', beneficiaryOffset: 0, hasBeneficiary: false, + pins: [], maxValue: BigInt(0), }, ], @@ -386,7 +387,7 @@ export function checkpointLog( ): Log { return log( wallet, - v5.events.checkpointed, + v6.events.checkpointed, { seq: BigInt(seq) }, { kind, ref: `0x${'11'.repeat(32)}`, blockNumber: block }, block, @@ -400,8 +401,8 @@ export function registerFakeGeneration( ): AgenticGeneration { resetAgenticGenerations(); return registerAgenticGeneration({ - id: 'v5', - sourceCommit: '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb', + id: 'v6', + sourceCommit: 'ffd567d3e49b2d27c6f50ad36093274dbac3090f', network, advertised: false, addresses: { diff --git a/packages/core/src/lib/agentic/__tests__/labels.spec.ts b/packages/core/src/lib/agentic/__tests__/labels.spec.ts index 84527c65c..c4f88cbac 100644 --- a/packages/core/src/lib/agentic/__tests__/labels.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/labels.spec.ts @@ -1,7 +1,7 @@ import { decodeFunctionData, encodeErrorResult } from 'viem'; import { PUSH_NETWORK } from '../../constants/enums'; import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; -import { v5 } from '../contracts/v5'; +import { v6 } from '../contracts/v6'; import { currentGeneration, resetAgenticGenerations } from '../deployments'; import { AGENTIC_ERROR_CODE } from '../errors'; import { createWallet } from '../management/create'; @@ -48,7 +48,7 @@ describe('v5 wallet labels', () => { const sent = rt.executeMock.mock.calls[0][0]; expect(sent).toMatchObject({ to: w.address, value: BigInt(0) }); expect( - decodeFunctionData({ abi: v5.abis.wallet, data: sent.data }) + decodeFunctionData({ abi: v6.abis.wallet, data: sent.data }) ).toMatchObject({ functionName: 'setLabel', args: [''] }); expect(hook).toHaveBeenCalledWith( expect.objectContaining({ id: PROGRESS_HOOK.AGENTIC_TX_106 }) @@ -84,7 +84,7 @@ describe('v5 wallet labels', () => { await setLabel(rt, gen(), w.address, 'UEA label'); expect( decodeFunctionData({ - abi: v5.abis.wallet, + abi: v6.abis.wallet, data: rt.executeMock.mock.calls[0][0].data, }).functionName ).toBe('setLabel'); @@ -94,7 +94,7 @@ describe('v5 wallet labels', () => { const chain = new FakeChain(); const w = chain.addWallet(ADDR.owner, 'x'); const data = encodeErrorResult({ - abi: v5.abis.wallet, + abi: v6.abis.wallet, errorName: 'LabelTooLong', args: [BigInt(65)], }); diff --git a/packages/core/src/lib/agentic/__tests__/management.spec.ts b/packages/core/src/lib/agentic/__tests__/management.spec.ts index f7d305e1d..5982d691f 100644 --- a/packages/core/src/lib/agentic/__tests__/management.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/management.spec.ts @@ -14,8 +14,8 @@ import { AGW_ABI, AGW_FACTORY_ABI, UNIVERSAL_RULES_POLICY_ABI, -} from '../contracts/abi/v5'; -import { v5 } from '../contracts/v5'; +} from '../contracts/abi/v6'; +import { v6 } from '../contracts/v6'; import { deriveWallet, rulesId as computeRulesId } from '../codec/ids'; import { AGENTIC_ERROR_CODE } from '../errors'; import { createWallet } from '../management/create'; @@ -41,13 +41,13 @@ const rule = (agent = ADDR.agent): NativeRule => ({ const granted = (wallet: `0x${string}`, id: Hex, block = BigInt(101)) => log( wallet, - v5.events.rulesGranted, + v6.events.rulesGranted, { rulesId: id, chainHash: `0x${'00'.repeat(32)}` }, { mode: 1, chainNamespace: PUSH_NS }, block ); const revoked = (wallet: `0x${string}`, id: Hex) => - log(wallet, v5.events.rulesRevoked, { rulesId: id }, {}, BigInt(101)); + log(wallet, v6.events.rulesRevoked, { rulesId: id }, {}, BigInt(101)); function withLogs(logs: Log[], over = {}) { const r = fakeResponse(over); @@ -82,7 +82,7 @@ describe('agentic.create', () => { withLogs([ log( ADDR.factory, - v5.events.walletDeployed, + v6.events.walletDeployed, { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, { label: 'ops' }, BigInt(101) @@ -98,15 +98,14 @@ describe('agentic.create', () => { progressHook: perCall, }); expect(res).toMatchObject({ wallet: nextWallet, index: 0, rulesIds: ids }); - const calls = (execute.mock.calls[0][0] as { data: MultiCall[] }).data; - expect(calls.map((c) => c.to)).toEqual([ - ADDR.factory, - nextWallet, - nextWallet, - ]); - expect( - decodeFunctionData({ abi: AGW_ABI, data: calls[1].data }).functionName - ).toBe('grantRules'); + const sent = execute.mock.calls[0][0] as { to: string; data: Hex }; + expect(sent.to).toBe(ADDR.factory); + const decoded = decodeFunctionData({ + abi: AGW_FACTORY_ABI, + data: sent.data, + }); + expect(decoded.functionName).toBe('createWalletWithSig'); + expect(decoded.args[3]).toHaveLength(2); expect(rt.events.map((e) => e.id)).toEqual([ PROGRESS_HOOK.AGENTIC_TX_101, PROGRESS_HOOK.AGENTIC_TX_102, @@ -127,7 +126,7 @@ describe('agentic.create', () => { return withLogs([ log( ADDR.factory, - v5.events.walletDeployed, + v6.events.walletDeployed, { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, { label: '' }, BigInt(101) @@ -170,7 +169,7 @@ describe('agentic.create', () => { expect(rt.executeMock).not.toHaveBeenCalled(); }); - it('a different wallet in the receipt is an INDEX_RACE with full details', async () => { + it('a different wallet in the receipt is RECEIPT_MISMATCH, not a safe-to-retry race', async () => { const fake = new FakeChain(); const elsewhere = deriveWallet({ factory: ADDR.factory, @@ -184,7 +183,7 @@ describe('agentic.create', () => { withLogs([ log( ADDR.factory, - v5.events.walletDeployed, + v6.events.walletDeployed, { owner: ADDR.owner, index: BigInt(1), wallet: elsewhere }, { label: '' }, BigInt(101) @@ -192,7 +191,7 @@ describe('agentic.create', () => { ])) as never, }); await expect(createWallet(rt, '', { rules: [] })).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.INDEX_RACE, + code: AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, }); }); @@ -204,7 +203,7 @@ describe('agentic.create', () => { withLogs([ log( ADDR.factory, - v5.events.walletDeployed, + v6.events.walletDeployed, { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, { label: '' }, BigInt(101) @@ -223,83 +222,18 @@ describe('agentic.create', () => { const receipt = (logs: Log[], status: 'success' | 'reverted' = 'success') => ({ status, logs } as unknown as TransactionReceipt); - it('a sequential failure after deployment is CREATE_PARTIAL; what committed comes from this call’s receipts', async () => { + it('creation never uses the batch transport or a sequential fallback', async () => { const fake = new FakeChain(); - const h1 = `0x${'01'.repeat(32)}` as Hex; - const h2 = `0x${'02'.repeat(32)}` as Hex; - fake.receipts.set( - h1, - receipt([ - log( - ADDR.factory, - v5.events.walletDeployed, - { owner: ADDR.owner, index: BigInt(0), wallet: nextWallet }, - { label: 'ops' }, - BigInt(101) - ), - ]) - ); - fake.receipts.set(h2, receipt([granted(nextWallet, ids[0])])); const rt = mockRuntime(fake, { signer: ADDR.owner, - execute: (async () => { - throw new PushChainBatchExecutionError('grant 2 reverted', [h1, h2]); - }) as never, - }); - const err = await createWallet(rt, 'ops', { - rules: [rule(), rule(ADDR.other)], - }).catch((e) => e); - expect(err.code).toBe(AGENTIC_ERROR_CODE.CREATE_PARTIAL); - expect(err.details).toMatchObject({ - wallet: nextWallet, - walletDeployed: true, - grantedRulesIds: [ids[0]], - confirmedHashes: [h1, h2], - }); - expect(err.hint).toMatch(/rules\.add/); - expect(rt.events.at(-1)?.id).toBe(PROGRESS_HOOK.AGENTIC_TX_199_02); - }); - - it('a pending hash keeps an unconfirmed deployment unknown', async () => { - const fake = new FakeChain(); - const rt = mockRuntime(fake, { - signer: ADDR.owner, - execute: (async () => { - throw new PushChainBatchExecutionError( - 'timed out', - [], - `0x${'03'.repeat(32)}` - ); - }) as never, - }); - const err = await createWallet(rt, 'ops', { rules: [rule()] }).catch( - (e) => e - ); - expect(err.details).toMatchObject({ - walletDeployed: 'unknown', - pendingHash: `0x${'03'.repeat(32)}`, - }); - expect(err.hint).toMatch(/Do not retry/); - }); - - it('failed recovery reads report unknown state, never "not deployed"', async () => { - const fake = new FakeChain(); - const rt = mockRuntime(fake, { - signer: ADDR.owner, - execute: (async () => { - fake.addWallet(ADDR.owner, 'committed'); - throw new PushChainBatchExecutionError('grant failed', [ - `0x${'11'.repeat(32)}`, - ]); - }) as never, + execute: async () => { + throw new Error('simulation refused'); + }, }); - const err = await createWallet(rt, 'review', { rules: [rule()] }).catch( - (e) => e - ); - expect(err.code).toBe('CREATE_PARTIAL'); - expect(err.details.walletDeployed).toBe('unknown'); - expect(err.details.recoveryError).toBeTruthy(); - expect(err.hint).toMatch(/Do not retry/); + await expect( + createWallet(rt, 'atomic', { rules: [rule()] }) + ).rejects.toThrow('simulation refused'); + expect(rt.atomicMock).not.toHaveBeenCalled(); }); it('a receipt that cannot be read after submission keeps the hash and reports unknown state', async () => { @@ -315,10 +249,9 @@ describe('agentic.create', () => { }) as never, }); const err = await createWallet(rt, 'x', { rules: [] }).catch((e) => e); - expect(err.code).toBe(AGENTIC_ERROR_CODE.CREATE_PARTIAL); + expect(err.code).toBe(AGENTIC_ERROR_CODE.RECEIPT_UNAVAILABLE); expect(err.details).toMatchObject({ - walletDeployed: 'unknown', - submitted: { txHash: `0x${'44'.repeat(32)}` }, + txHash: `0x${'44'.repeat(32)}`, }); }); @@ -331,12 +264,12 @@ describe('agentic.create', () => { }); const rt = mockRuntime(fake, { signer: ADDR.owner, - execute: (async (p: { data: MultiCall[] }) => { + execute: (async (p: { data: Hex }) => { const deploy = decodeFunctionData({ abi: AGW_FACTORY_ABI, - data: p.data[0].data, + data: p.data, }); - expect(deploy.functionName).toBe('deployWalletWithSig'); + expect(deploy.functionName).toBe('createWalletWithSig'); expect( (deploy.args[0] as { index: bigint; wallet: string }).index ).toBe(BigInt(0)); @@ -353,31 +286,7 @@ describe('agentic.create', () => { (e) => e ); expect(err.code).toBe(AGENTIC_ERROR_CODE.INDEX_RACE); - expect(err.details).toMatchObject({ committed: false }); - }); - - it('a generic send error is still identified as a race when the fresh slot label proves another create won', async () => { - const fake = new FakeChain(); - const rt = mockRuntime(fake, { - signer: ADDR.owner, - execute: (async () => { - const winner = fake.addWallet(ADDR.owner, 'competing-create'); - winner.label = 'this-create'; // mutable view cannot erase the deploy evidence - throw new Error('opaque sender failure'); - }) as never, - }); - const err = await createWallet(rt, 'this-create', { - rules: [rule()], - }).catch((e) => e); - expect(err).toMatchObject({ - code: AGENTIC_ERROR_CODE.INDEX_RACE, - details: { - wallet: nextWallet, - index: 0, - committed: false, - competingLabel: 'competing-create', - }, - }); + expect(err.details).toMatchObject({ walletDeployed: false }); }); it('a later rename alone does not establish a competing creation', async () => { @@ -471,7 +380,7 @@ describe('rules.add / rules.delete', () => { ).toEqual(ids); const sent = exec.mock.calls[0][0] as { to: string; data: Hex }; expect(Array.isArray(sent.data)).toBe(false); - const outer = v5.decodeWalletCall(sent.data) as { + const outer = v6.decodeWalletCall(sent.data) as { kind: string; calls: { target: string; data: Hex }[]; }; @@ -512,7 +421,7 @@ describe('rules.add / rules.delete', () => { await revokeRules(rt, gen(), w.address, [ruleId(1), ruleId(2)]); const sent = exec.mock.calls[0][0] as { to: string; data: Hex }; expect(sent.to).toBe(w.address); - const decoded = v5.decodeWalletCall(sent.data) as { + const decoded = v6.decodeWalletCall(sent.data) as { kind: string; calls: { target: string; data: Hex }[]; }; @@ -566,7 +475,7 @@ describe('rules.update — one owner batch: assert → revoke → grant', () => expect(exec).toHaveBeenCalledTimes(1); const sent = exec.mock.calls[0][0] as { to: string; data: Hex }; expect(Array.isArray(sent.data)).toBe(false); // never a sequential multi-tx batch - const outer = v5.decodeWalletCall(sent.data) as { + const outer = v6.decodeWalletCall(sent.data) as { kind: string; mode: Hex; calls: { target: string; data: Hex }[]; @@ -632,7 +541,7 @@ describe('rules.update — one owner batch: assert → revoke → grant', () => rules: [{ rulesId: ruleId(1), rule: rule() }], }); const data = (exec.mock.calls[0][0] as { data: Hex }).data; - const outer = v5.decodeWalletCall(data) as { calls: { data: Hex }[] }; + const outer = v6.decodeWalletCall(data) as { calls: { data: Hex }[] }; const assertion = decodeFunctionData({ abi: UNIVERSAL_RULES_POLICY_ABI, data: outer.calls[0].data, diff --git a/packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts b/packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts index ab65c14f4..2ac5e2497 100644 --- a/packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/native-batch-response.spec.ts @@ -3,7 +3,7 @@ import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; import { PUSH_BATCH_EXECUTOR_ADDRESS } from '../../constants/chain'; import { convertExecutorToOrigin } from '../../universal/account/account'; import { adaptTrackedResponse } from '../response'; -import { v5, UEA_MULTICALL_PREFIX } from '../contracts/v5'; +import { v6, UEA_MULTICALL_PREFIX } from '../contracts/v6'; import { resetAgenticGenerations } from '../deployments'; import { ADDR, FakeChain, registerFakeGeneration, ruleId } from './fake-chain'; import { fakeResponse, mockRuntime } from './mock-runtime'; @@ -27,7 +27,7 @@ function fixture() { const outer = actions.map((c) => ({ target: wallet.address, value: BigInt(0), - data: v5.encodeExecuteAsAgent(ruleId(1), c), + data: v6.encodeExecuteAsAgent(ruleId(1), c), })); const runtime = mockRuntime(fake); return { fake, wallet, actions, outer, runtime }; @@ -47,7 +47,7 @@ it('recognizes a verified 7702 self-call and retains every native agent action', fakeResponse({ from: ADDR.agent, to: ADDR.agent, - data: v5.encodeExecute(outer), + data: v6.encodeExecute(outer), }) ); expect(result).toMatchObject({ @@ -112,7 +112,7 @@ it('does not label an arbitrary forwarding contract as a trusted sender batch', const response = fakeResponse({ from: ADDR.agent, to: ADDR.other, - data: v5.encodeExecute(outer), + data: v6.encodeExecute(outer), }); expect(await adaptTrackedResponse(runtime, response)).toBe(response); expect(response.agentic).toBeUndefined(); diff --git a/packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts b/packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts index b317a1a2a..967a14eb4 100644 --- a/packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/native-multi-action.spec.ts @@ -17,7 +17,7 @@ import { decodeEnvelope, decodeSession } from '../codec/session'; import { currentGeneration, resetAgenticGenerations, - V5_CAPABILITIES, + V6_CAPABILITIES, } from '../deployments'; import { AGENTIC_ERROR_CODE } from '../errors'; import { decodeActiveRule, readActiveRules } from '../reads/rules'; @@ -58,7 +58,7 @@ const ctx = { validator: ADDR.validator, rulesPolicy: ADDR.policy, gateway: ADDR.gateway, - capabilities: V5_CAPABILITIES, + capabilities: V6_CAPABILITIES, nowSeconds: NOW, forbiddenTargets: [ ADDR.factory, @@ -325,7 +325,7 @@ describe('rules.get / rules.list — reading several stored actions', () => { [a0.toLowerCase()]: { target: ADDR.target, selector: '0xd09de08a', - maxCalls: 0, + maxCalls: 0xffffffff, callsUsed: 5, }, [a1.toLowerCase()]: { diff --git a/packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts b/packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts index 9064b48f4..1a941af13 100644 --- a/packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/outbound-response.spec.ts @@ -8,7 +8,7 @@ import { transformToUniversalTxResponse } from '../../orchestrator/internals/res import { adaptTrackedResponse } from '../response'; import { composeOutbound, type DestinationCall } from '../execution/outbound'; import { agenticSend } from '../execution/send'; -import { v5 } from '../contracts/v5'; +import { v6 } from '../contracts/v6'; import { currentGeneration, resetAgenticGenerations } from '../deployments'; import type { AgenticExecutionContext } from '../context'; import { ADDR, FakeChain, registerFakeGeneration, ruleId } from './fake-chain'; @@ -187,8 +187,8 @@ describe.each(['owner', 'agent'] as const)( ); const input = door === 'owner' - ? v5.encodeExecute([out.gatewayCall]) - : v5.encodeExecuteAsAgent(ruleId(1), out.gatewayCall); + ? v6.encodeExecute([out.gatewayCall]) + : v6.encodeExecuteAsAgent(ruleId(1), out.gatewayCall); const signer = door === 'owner' ? ADDR.owner : ADDR.agent; const tx = { hash: PUSH_HASH, diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts index 0b24bd422..bf0a1dde8 100644 --- a/packages/core/src/lib/agentic/__tests__/presets.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -197,18 +197,21 @@ it('rejects unknown presets, unsupported list types and unsupported chain filter /no approved entry/ ); }); -it('refuses conflicting spenders rather than emitting unrestricted or ambiguous approvals', () => { +it('produces separate pinned alternatives for multiple spenders', () => { const data = catalog(); data[0].chains[0].approvals = [ { spender: target, app: 'One' }, { spender: ADDR.other, app: 'Two' }, ]; - expect(() => - buildPresetApi(data).get('DEXES', { - chainNamespace: chain, - assets: [{ token }], - }) - ).toThrow(/multiple spenders/); + const result = buildPresetApi(data).get('DEXES', { + chainNamespace: chain, + assets: [{ token }], + }); + expect( + result.rules[0].allowedCalls.filter( + (c) => c.selector === 'approve(address,uint256)' + ) + ).toHaveLength(2); }); it('enforces the 32-call ceiling including generated approvals', () => { const data = catalog(); @@ -232,20 +235,52 @@ it('requires full signatures and rejects duplicate function entries', () => { { target, selector: 'deposit()' }, { target, selector: 'deposit()' }, ]; - expect(() => buildPresetApi(data).get('DEXES')).toThrow(/duplicate target/); + expect(() => buildPresetApi(data).get('DEXES')).toThrow( + /unpinned alternative/ + ); }); -it('cannot bypass current deployment limitations through a preset approval', async () => { +it('compiles preset spender and amount pins with the deployed v6 policy', async () => { const { rules } = buildPresetApi(catalog()).get('DEXES', { chainNamespace: chain, agent, validUntil: until(), assets: [{ token, maxPerCall: BigInt(5) }], }); - await expect( - normalizePublicRule( - publicRules(rules)[0], - ADDR.other, - mockRuntime(new FakeChain()) - ) - ).rejects.toMatchObject({ code: 'CAPABILITY_UNAVAILABLE' }); + const normalized = await normalizePublicRule( + publicRules(rules)[0], + ADDR.other, + mockRuntime(new FakeChain()) + ); + expect( + ( + normalized as import('../agentic.types').UniversalRule + ).allowedCalls[1].pins?.map((p) => p.op) + ).toEqual([0, 1]); +}); + +it('keeps OR alternatives EVM-only: repeated Solana instructions are refused', () => { + const api = buildPresetApi([ + { + constant: 'DEXES', + name: 'fixture', + chains: [ + { + chainNamespace: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + allowedCalls: [ + { + target: '11111111111111111111111111111111', + selector: 'deposit(u64)', + pins: [{ account: 0, expected: AGENTIC.WALLET }], + }, + { + target: '11111111111111111111111111111111', + selector: 'deposit(u64)', + pins: [{ arg: 0, max: BigInt(1) }], + }, + ], + }, + ], + }, + ]); + expect(() => api.get('DEXES')).toThrow(/Solana does not support alternative/); }); diff --git a/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts b/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts index 10c4b5876..ddf1d5d94 100644 --- a/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts @@ -47,9 +47,11 @@ it('normalizes unified native calls into exact EQ/MAX words with zero PC default expect.objectContaining({ maxValuePerCall: BigInt(0), maxValueTotal: UINT256_MAX, - maxCalls: 0, - pins: [{ offset: 4, expected: `0x${wallet.slice(2).padStart(64, '0')}` }], - amount: { offset: 36, maxPerCall: BigInt(5), maxTotal: UINT256_MAX }, + maxCalls: 0xffffffff, + wirePins: [ + { offset: 4, op: 0, value: `0x${wallet.slice(2).padStart(64, '0')}` }, + { offset: 36, op: 1, value: `0x${'0'.repeat(63)}5` }, + ], }), ]); const rt = mockRuntime(fake()); @@ -61,7 +63,10 @@ it('normalizes unified native calls into exact EQ/MAX words with zero PC default publicRules([base()]), await Snapshot.at(rt.reader) ); - expect(p.nativeTerms?.[0].amount.maxPerCall).toBe(BigInt(5)); + expect(p.nativeTerms?.[0].pins[1]).toMatchObject({ + op: 1, + value: `0x${'0'.repeat(63)}5`, + }); }); it.each(['actions', 'target', 'amount', 'maxCalls', 'maxValueTotal', 'ref'])( 'rejects retired top-level %s before signing', @@ -71,15 +76,15 @@ it.each(['actions', 'target', 'amount', 'maxCalls', 'maxValueTotal', 'ref'])( ); } ); -it('rejects two native MAX pins on the deployed policy', async () => { +it('supports multiple native MAX pins', async () => { const r = base(); r.allowedCalls[0].pins = [ { arg: 0, max: BigInt(1) }, { arg: 1, max: BigInt(2) }, ]; - await expect(normalize(r)).rejects.toMatchObject({ - code: 'CAPABILITY_UNAVAILABLE', - }); + expect( + ((await normalize(r)) as NativeMultiRule).actions[0].wirePins + ).toHaveLength(2); }); it('compiles the existing EVM WALLET beneficiary and defaults asset total to zero', async () => { const r = base(); @@ -88,15 +93,17 @@ it('compiles the existing EVM WALLET beneficiary and defaults asset total to zer r.allowedCalls[0].pins = [{ arg: 0, expected: WALLET }]; const out = (await normalize(r)) as UniversalRule; expect(out.allowedCalls[0]).toMatchObject({ - beneficiaryOffset: 4, + pins: [expect.objectContaining({ offset: 4, op: 0 })], maxValue: BigInt(0), }); expect(out.assets[0].maxTotal).toBe(BigInt(0)); }); -it('rejects unavailable destination MAX pins before contract deployment', async () => { - await expect( - normalize({ ...base(), chainNamespace: 'eip155:11155111' }) - ).rejects.toMatchObject({ code: 'CAPABILITY_UNAVAILABLE' }); +it('supports general destination EQ/MAX pins', async () => { + const out = (await normalize({ + ...base(), + chainNamespace: 'eip155:11155111', + })) as UniversalRule; + expect(out.allowedCalls[0].pins?.map((p) => p.op)).toEqual([0, 1]); }); it('refuses layouts whose top-level argument indexes cannot be reconstructed', async () => { const r = base(); @@ -154,7 +161,7 @@ it('compiles numeric Solana arg/account pins without a caller IDL', async () => }, now ); - expect(terms.programs[0].maxAccounts).toBe(0); + expect(terms.programs[0].maxAccounts).toBe(255); expect(terms.pins).toEqual([ { ruleIndex: 0, accountIndex: 0, expected: cea }, ]); @@ -196,12 +203,34 @@ it('does not silently discard a token qualifier on an argument pin', async () => it('reads native wallet equality as WALLET and re-encodes its exact word', async () => { const { getRule } = await import('../reads/rules'); const { ruleId } = await import('./fake-chain'); - const chain=fake();const w=chain.addWallet(ADDR.owner,'wallet-pin'); - const word=`0x${w.address.slice(2).toLowerCase().padStart(64,'0')}` as const; - w.rules.push(chain.nativeRule(ADDR.agent,ruleId(1),{pins:[{offset:4,expected:word}]})); - const rt=mockRuntime(chain); - const rec=await getRule(await Snapshot.at(chain),currentGeneration(PUSH_NETWORK.TESTNET_DONUT),w.address,ruleId(1),rt.pushChainNamespace); - expect(rec.rule).toMatchObject({allowedCalls:[{pins:[{arg:0,expected:WALLET}]}]}); - const normalized=await normalizePublicRule(publicRules([rec.rule])[0],w.address,rt) as NativeMultiRule; - expect(normalized.actions[0].pins).toEqual([{offset:4,expected:word}]); + const chain = fake(); + const w = chain.addWallet(ADDR.owner, 'wallet-pin'); + const word = `0x${w.address + .slice(2) + .toLowerCase() + .padStart(64, '0')}` as const; + w.rules.push( + chain.nativeRule(ADDR.agent, ruleId(1), { + pins: [{ offset: 4, op: 0, value: word }], + }) + ); + const rt = mockRuntime(chain); + const rec = await getRule( + await Snapshot.at(chain), + currentGeneration(PUSH_NETWORK.TESTNET_DONUT), + w.address, + ruleId(1), + rt.pushChainNamespace + ); + expect(rec.rule).toMatchObject({ + allowedCalls: [{ pins: [{ arg: 0, expected: WALLET }] }], + }); + const normalized = (await normalizePublicRule( + publicRules([rec.rule])[0], + w.address, + rt + )) as NativeMultiRule; + expect(normalized.actions[0].wirePins).toEqual([ + { offset: 4, op: 0, value: word }, + ]); }); diff --git a/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts b/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts index 72cf7515b..0babc61af 100644 --- a/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/reads-context.spec.ts @@ -18,16 +18,16 @@ import { ruleId, } from './fake-chain'; import { mockRuntime } from './mock-runtime'; -import { v5 } from '../contracts/v5'; +import { v6 } from '../contracts/v6'; import { readOriginToken } from '../contracts/prc20-metadata'; describe('generation registry', () => { afterEach(() => resetAgenticGenerations()); - it('ships Donut v5 only; other networks are unsupported', () => { + it('ships Donut v6 only; other networks are unsupported', () => { resetAgenticGenerations(); expect(currentGeneration(PUSH_NETWORK.TESTNET_DONUT)).toMatchObject({ - id: 'v5', + id: 'v6', advertised: true, }); for (const n of Object.values(PUSH_NETWORK).filter( @@ -61,7 +61,7 @@ describe('Donut log-range limit', () => { const getLogs = jest.spyOn(fake, 'getLogs'); await scanLogs( fake, - { address: ADDR.factory, event: v5.events.walletDeployed }, + { address: ADDR.factory, event: v6.events.walletDeployed }, BigInt(10), BigInt(2010) ); @@ -298,7 +298,7 @@ describe('rules.list / rules.get', () => { fake.nativeRule(ADDR.agent, ruleId(1), { callsUsed: 3, valueSpent: BigInt(5), - maxCalls: 0, + maxCalls: 0xffffffff, }), ]); const rules = await listRules( @@ -396,7 +396,7 @@ describe('rules.list / rules.get', () => { fake.extraLogs.push( log( w.address, - v5.events.rulesRevoked, + v6.events.rulesRevoked, { rulesId: ruleId(9) }, {}, BigInt(50) diff --git a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts index e4550ae4a..3dac9d67c 100644 --- a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts @@ -10,8 +10,8 @@ import { PUSH_NETWORK } from '../../constants/enums'; import { UNIVERSAL_GATEWAY_PC } from '../../constants/abi'; import { PushChainExecutionError } from '../../orchestrator/internals/errors'; import { Utils } from '../../utils'; -import { AGW_ABI } from '../contracts/abi/v5'; -import { v5 } from '../contracts/v5'; +import { AGW_ABI } from '../contracts/abi/v6'; +import { v6 } from '../contracts/v6'; import { resetAgenticGenerations } from '../deployments'; import { AgenticRevertError } from '../errors'; import { adaptAgenticResponse, adaptTrackedResponse } from '../response'; @@ -58,7 +58,7 @@ describe('response identity', () => { registerFakeGeneration(); const fake = new FakeChain(); const w = fake.addWallet(ADDR.owner, 'w'); - const data = v5.encodeExecuteAsAgent(ruleId(4), { + const data = v6.encodeExecuteAsAgent(ruleId(4), { target: ADDR.target, value: BigInt(0), data: '0xd09de08a', @@ -120,7 +120,7 @@ describe('response identity', () => { }, ], }); - const data = v5.encodeExecuteAsAgent(ruleId(4), { + const data = v6.encodeExecuteAsAgent(ruleId(4), { target: ADDR.gateway, value: BigInt(5), data: gatewayCall, @@ -155,7 +155,7 @@ describe('response identity', () => { registerFakeGeneration(); const spoof = fakeResponse({ to: ADDR.target, - data: v5.encodeExecute([ + data: v6.encodeExecute([ { target: ADDR.other, value: BigInt(0), data: '0x' }, ]), }); diff --git a/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts b/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts index 8127cfc8c..3826fe622 100644 --- a/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/rules-policy.spec.ts @@ -6,7 +6,7 @@ import { encodeUniversalTerms } from '../codec/universal-terms'; import { buildSession, encodeEnvelope, encodeSession } from '../codec/session'; import { prepareRules } from '../codec/rules'; import { validateUniversalRuleShape } from '../codec/universal'; -import { V5_CAPABILITIES } from '../deployments'; +import { V6_CAPABILITIES } from '../deployments'; import { AGENTIC_ERROR_CODE } from '../errors'; import { internalAgenticUtils } from '../utils'; import type { @@ -22,7 +22,7 @@ const ctx = { pushChainNamespace: PUSH_NS, validator: ADDR.validator, rulesPolicy: ADDR.policy, - capabilities: V5_CAPABILITIES, + capabilities: V6_CAPABILITIES, nowSeconds: NOW, forbiddenTargets: [ ADDR.factory, @@ -217,7 +217,12 @@ describe('universal rules — validated, then capability-gated (A01, A05)', () = validateUniversalRuleShape( universal({ allowedCalls: [ - { target: ADDR.target, selector: '0x617ba037', beneficiary: 2 }, + { + target: ADDR.target, + selector: '0x617ba037', + pins: [], + beneficiary: 2, + }, ], }), NOW @@ -270,10 +275,13 @@ describe('internal generation codecs (pure)', () => { maxCalls: 4, validUntil: NOW + 100, }); - expect((back as NativeRule).pins).toEqual([ + expect( + (back as import('../agentic.types').NativeSingleRule).wirePins + ).toEqual([ { offset: 4, - expected: `0x${'0'.repeat(24)}${ADDR.other.slice(2).toLowerCase()}`, + op: 0, + value: `0x${'0'.repeat(24)}${ADDR.other.slice(2).toLowerCase()}`, }, ]); }); @@ -323,6 +331,7 @@ describe('internal generation codecs (pure)', () => { { target: ADDR.target, selector: '0x617ba037', + pins: [], beneficiaryOffset: 68, hasBeneficiary: true, maxValue: BigInt(0), diff --git a/packages/core/src/lib/agentic/__tests__/send.spec.ts b/packages/core/src/lib/agentic/__tests__/send.spec.ts index a9b567cfc..c5ce94e71 100644 --- a/packages/core/src/lib/agentic/__tests__/send.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/send.spec.ts @@ -13,7 +13,7 @@ import { PushChainExecutionError } from '../../orchestrator/internals/errors'; import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; import type { AgenticExecutionContext } from '../context'; import { currentGeneration, resetAgenticGenerations } from '../deployments'; -import { v5 } from '../contracts/v5'; +import { v6 } from '../contracts/v6'; import { actionId } from '../codec/ids'; import { AGENTIC_ERROR_CODE, AgenticRevertError } from '../errors'; import { agenticSend } from '../execution/send'; @@ -111,7 +111,7 @@ describe('guards fail before the signer is ever invoked', () => { data: Hex; }[]; expect(batch.map((c) => c.to)).toEqual([ctx.wallet, ctx.wallet]); - expect(batch.map((c) => v5.decodeWalletCall(c.data))).toEqual( + expect(batch.map((c) => v6.decodeWalletCall(c.data))).toEqual( calls.map((c) => ({ kind: 'executeAsAgent', rulesId: ruleId(1), @@ -186,7 +186,7 @@ describe('explicit Push destinations', () => { data, }); expect( - v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) + v6.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) ).toMatchObject({ kind: 'executeAsAgent', rulesId: ruleId(1) }); expect(tx.route).not.toBe('UOA_TO_CEA'); expect(tx.to).toBe(ADDR.target); @@ -199,7 +199,7 @@ describe('explicit Push destinations', () => { data, }); expect( - v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) + v6.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) ).toMatchObject({ kind: 'execute', calls: [{ target: ADDR.target }] }); }); @@ -228,7 +228,7 @@ describe('native wrapping and logical identity', () => { const sent = rt.executeMock.mock.calls[0][0]; expect(sent.to).toBe(w.address); expect(sent.value).toBe(BigInt(0)); - expect(v5.decodeWalletCall(sent.data)).toEqual({ + expect(v6.decodeWalletCall(sent.data)).toEqual({ kind: 'executeAsAgent', rulesId: ruleId(1), mode: `0x${'00'.repeat(32)}`, @@ -267,7 +267,7 @@ describe('native wrapping and logical identity', () => { { to: ADDR.other, value: BigInt(1), data: '0x' }, ], }); - const decoded = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data); + const decoded = v6.decodeWalletCall(rt.executeMock.mock.calls[0][0].data); expect(decoded).toMatchObject({ kind: 'execute', mode: `0x01${'00'.repeat(31)}`, @@ -398,7 +398,7 @@ describe('EVM outbound composition from the wallet', () => { s.w.address, CHAIN.ETHEREUM_SEPOLIA ); - const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + const outer = v6.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { kind: string; rulesId: Hex; calls: { target: Address; value: bigint; data: Hex }[]; @@ -438,19 +438,18 @@ describe('EVM outbound composition from the wallet', () => { ); }); - it('never injects an approval into the agent path; missing allowance fails before signing', async () => { + it('agent sends without a standing allowance; the wallet handles approval', async () => { const s = universalSetup(); s.fake.balances.set(`allowance:${token.toLowerCase()}`, BigInt(10)); const rt = mockRuntime(s.fake, { signer: ADDR.agent, resolvePrc20: () => token, }); - await expect( - agenticSend(rt, s.ctx, send(BigInt(100))) - ).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, - }); - expect(rt.executeMock).not.toHaveBeenCalled(); + await agenticSend(rt, s.ctx, send(BigInt(100))); + expect(rt.executeMock).toHaveBeenCalledTimes(1); + expect(s.fake.calls.some((c) => c.functionName === 'allowance')).toBe( + false + ); }); it('wallet funding is checked locally; rule PC limits are enforced by the contract', async () => { @@ -532,7 +531,7 @@ describe('EVM outbound composition from the wallet', () => { resolvePrc20: () => token, }); await agenticSend(rt, s.ctx, send(BigInt(100))); - const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + const outer = v6.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { kind: string; calls: { target: Address; data: Hex }[]; }; @@ -540,7 +539,7 @@ describe('EVM outbound composition from the wallet', () => { expect(outer.calls.map((c) => c.target)).toEqual([ADDR.gateway]); }); - it('owner door with too small an allowance fails before signing (no implicit approval)', async () => { + it('owner sends without a standing allowance', async () => { const s = universalSetup(); s.ctx.door = 'owner'; s.ctx.signerPushAccount = ADDR.owner; @@ -549,12 +548,11 @@ describe('EVM outbound composition from the wallet', () => { signer: ADDR.owner, resolvePrc20: () => token, }); - await expect( - agenticSend(rt, s.ctx, send(BigInt(100))) - ).rejects.toMatchObject({ - code: AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, - }); - expect(rt.executeMock).not.toHaveBeenCalled(); + await agenticSend(rt, s.ctx, send(BigInt(100))); + expect(rt.executeMock).toHaveBeenCalledTimes(1); + expect(s.fake.calls.some((c) => c.functionName === 'allowance')).toBe( + false + ); }); it('an existing unlimited allowance is consumed as-is', async () => { @@ -590,7 +588,7 @@ describe('EVM outbound composition from the wallet', () => { to: { address: recipient, chain: CHAIN.ETHEREUM_SEPOLIA }, funds: { amount: BigInt(100), token: usdc }, }); - const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + const outer = v6.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { calls: { data: Hex }[]; }; const { args } = decodeFunctionData({ @@ -638,7 +636,7 @@ describe('EVM outbound composition from the wallet', () => { to: { address: ADDR.other, chain: CHAIN.ETHEREUM_SEPOLIA }, value: BigInt(7), }); - const outer = v5.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { + const outer = v6.decodeWalletCall(rt.executeMock.mock.calls[0][0].data) as { calls: { data: Hex }[]; }; const req = decodeFunctionData({ diff --git a/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts index 0d7317fce..cf8c79122 100644 --- a/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts @@ -351,7 +351,7 @@ describe('S12–S18 payload checks', () => { ) ) ).toThrow(/SvmAccountPinMismatch/); - t.programs = [{ ...t.programs[0], maxAccounts: 0 }]; + t.programs = [{ ...t.programs[0], maxAccounts: 255 }]; expect(() => validateAgwSvmPayload( t, diff --git a/packages/core/src/lib/agentic/agentic.types.ts b/packages/core/src/lib/agentic/agentic.types.ts index 6dcc6e9a8..34acefad7 100644 --- a/packages/core/src/lib/agentic/agentic.types.ts +++ b/packages/core/src/lib/agentic/agentic.types.ts @@ -1,4 +1,5 @@ -/** Public section-6 rules and internal adapters for the deployed v5 wire format. +import type { ArgPinWire } from './codec/pins'; +/** Public section-6 rules and internal adapters for the deployed v6 wire format. * Source snapshot: October 12, 2026. Internal normalized types are not package exports. */ import type { Idl } from '@coral-xyz/anchor'; @@ -17,6 +18,7 @@ export type Selector = AgenticHex | `${string}(${string})`; /** One Push function a native rule allows, with its own limits and counters. */ export interface NativeAction { + wirePins?: ArgPinWire[]; target: AgenticAddress; selector: Selector | 'value-only'; /** Default 0: no native PC value allowance. */ @@ -102,6 +104,7 @@ export interface AssetCap { } export interface AllowedCall { + pins?: ArgPinWire[]; target: AgenticAddress; selector: Selector; /** Argument index whose address must be the wallet's destination account. */ diff --git a/packages/core/src/lib/agentic/capabilities.ts b/packages/core/src/lib/agentic/capabilities.ts index 0f6cecf06..5cef00913 100644 --- a/packages/core/src/lib/agentic/capabilities.ts +++ b/packages/core/src/lib/agentic/capabilities.ts @@ -21,7 +21,7 @@ export enum AgenticCapability { ASSERT_SPENT_NATIVE = 'assertSpentNative', /** checkpointCount + Checkpointed events. */ CHECKPOINTS = 'checkpoints', - /** Agent-door EVM outbound composition against v5 per-token caps. */ + /** Agent-door EVM outbound composition against v6 per-token caps. */ UNIVERSAL_EVM_OUTBOUND = 'universalEvmOutbound', /** Target UniversalRule (assets[] / maxGasPerCall) encode/decode. A05/A07. */ UNIVERSAL_EVM_RULES = 'universalEvmRules', diff --git a/packages/core/src/lib/agentic/codec/defaults.ts b/packages/core/src/lib/agentic/codec/defaults.ts index a5fc885b0..4f1ba6961 100644 --- a/packages/core/src/lib/agentic/codec/defaults.ts +++ b/packages/core/src/lib/agentic/codec/defaults.ts @@ -4,14 +4,14 @@ * a fallback for these fields; update this table for future changes. * * Explicit zero always stays zero. In the native policy a zero value/amount - * cap forbids positive value/amount, while maxCalls = 0 means unlimited. + * cap forbids positive value/amount, while maxCalls = uint32 maximum means unlimited. */ export const UINT256_MAX = BigInt(2) ** BigInt(256) - BigInt(1); export interface DefaultRow { value: bigint; meaning: string; - source: 'product' | 'v5'; + source: 'product' | 'v6'; } export const AGENTIC_DEFAULTS = { @@ -32,7 +32,7 @@ export const AGENTIC_DEFAULTS = { source: 'product', }, maxCalls: { - value: BigInt(0), + value: BigInt(0xffffffff), meaning: 'unlimited calls until expiry', source: 'product', }, diff --git a/packages/core/src/lib/agentic/codec/ids.ts b/packages/core/src/lib/agentic/codec/ids.ts index 56c4f473d..6a4b0e996 100644 --- a/packages/core/src/lib/agentic/codec/ids.ts +++ b/packages/core/src/lib/agentic/codec/ids.ts @@ -12,7 +12,7 @@ import { import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; /** - * Pure identity helpers mirroring the pinned AGW generation (v5): + * Pure identity helpers mirroring the pinned AGW generation (v6): * smartsessions IdLib.toActionId / toConfigId / toPermissionId and * AGWFactory._predict (OZ Clones immutable-args CREATE2). * Every input that changes the result is explicit — validator, factory and diff --git a/packages/core/src/lib/agentic/codec/native.ts b/packages/core/src/lib/agentic/codec/native.ts index 570131617..ae6012f3f 100644 --- a/packages/core/src/lib/agentic/codec/native.ts +++ b/packages/core/src/lib/agentic/codec/native.ts @@ -1,3 +1,4 @@ +import { ARG_PIN_COMPONENTS, validatePins, type ArgPinWire } from './pins'; import { decodeAbiParameters, encodeAbiParameters, @@ -23,13 +24,13 @@ import { type ParsedSelector, } from './selectors'; -/** URP native limits at v5 (Types.sol MAX_PINS, AGW MAX_NATIVE_ACTIONS). */ +/** URP native limits at v6 (Types.sol MAX_PINS, AGW MAX_NATIVE_ACTIONS). */ export const MAX_PINS = 8; export const MAX_NATIVE_ACTIONS = 8; const UINT32_MAX = 2 ** 32 - 1; const UINT48_MAX = 2 ** 48 - 1; -/** NativeTerms (Types.sol:354-363 at v5). */ +/** NativeTerms in the pinned v6 Types.sol. */ export interface NativeTermsWire { validUntil: number; target: Address; @@ -43,7 +44,7 @@ export interface NativeTermsWire { maxTotal: bigint; }; maxCalls: number; - pins: readonly { offset: number; expected: Hex }[]; + pins: readonly ArgPinWire[]; } export const NATIVE_TERMS_PARAM: AbiParameter = { @@ -68,10 +69,7 @@ export const NATIVE_TERMS_PARAM: AbiParameter = { { name: 'pins', type: 'tuple[]', - components: [ - { name: 'offset', type: 'uint16' }, - { name: 'expected', type: 'bytes32' }, - ], + components: ARG_PIN_COMPONENTS, }, ], }; @@ -107,6 +105,7 @@ const SINGLE_ACTION_FIELDS = [ 'maxValueTotal', 'maxCalls', 'pins', + 'wirePins', 'amount', ] as const; @@ -162,6 +161,7 @@ export function nativeActionsOf( maxValueTotal: single.maxValueTotal, maxCalls: single.maxCalls, pins: single.pins, + wirePins: single.wirePins, amount: single.amount, validUntil: single.validUntil, }, @@ -250,7 +250,10 @@ export function nativeActionToTerms( } const parsed = parseSelector(rule.selector); const pinsIn = rule.pins ?? []; - if (parsed.valueOnly && (pinsIn.length > 0 || rule.amount)) { + if ( + parsed.valueOnly && + (pinsIn.length > 0 || rule.wirePins?.length || rule.amount) + ) { throw invalid('a value-only rule cannot carry pins or an amount rule'); } if (pinsIn.length > MAX_PINS) { @@ -338,10 +341,22 @@ export function nativeActionToTerms( rule.maxCalls === undefined ? Number(AGENTIC_DEFAULTS.native.maxCalls.value) : rule.maxCalls; - if (!Number.isInteger(maxCalls) || maxCalls < 0 || maxCalls > UINT32_MAX) { + if (!Number.isInteger(maxCalls) || maxCalls <= 0 || maxCalls > UINT32_MAX) { throw invalid('maxCalls must be a uint32 integer'); } + try { + validatePins( + rule.wirePins ?? + pins.map((p) => ({ + offset: p.offset, + op: 0 as const, + value: p.expected, + })) + ); + } catch (error) { + throw invalid((error as Error).message); + } return { parsed, terms: { @@ -352,7 +367,13 @@ export function nativeActionToTerms( maxValueTotal, amount, maxCalls, - pins, + pins: + rule.wirePins ?? + pins.map((p) => ({ + offset: p.offset, + op: 0 as const, + value: p.expected, + })), }, }; } @@ -388,12 +409,7 @@ export function nativeTermsToRule( maxValueTotal: terms.maxValueTotal, maxCalls: Number(terms.maxCalls), }; - if (terms.pins.length > 0) { - rule.pins = terms.pins.map((p) => ({ - offset: Number(p.offset), - expected: p.expected.toLowerCase() as AgenticHex, - })); - } + if (terms.pins.length > 0) rule.wirePins = terms.pins.map((p) => ({ ...p })); if (terms.amount.enabled) { rule.amount = { offset: Number(terms.amount.offset), diff --git a/packages/core/src/lib/agentic/codec/pins.ts b/packages/core/src/lib/agentic/codec/pins.ts new file mode 100644 index 000000000..4c81a5222 --- /dev/null +++ b/packages/core/src/lib/agentic/codec/pins.ts @@ -0,0 +1,32 @@ +import type { Hex } from 'viem'; +export interface ArgPinWire { + offset: number; + op: 0 | 1; + value: Hex; +} +export const ARG_PIN_COMPONENTS = [ + { name: 'offset', type: 'uint16' }, + { name: 'op', type: 'uint8' }, + { name: 'value', type: 'bytes32' }, +] as const; +export const UNLIMITED_CALLS = 0xffffffff; +export const ANY_ACCOUNT_COUNT = 255; + +/** Validate every encoded constraint before signing. */ +export function validatePins(pins: readonly ArgPinWire[]): void { + if (!Array.isArray(pins) || pins.length > 8) + throw new Error('pins must contain at most eight constraints'); + const seen = new Set(); + for (const p of pins) { + if ( + !Number.isInteger(p.offset) || + p.offset < 4 || + p.offset + 32 > 65535 || + ![0, 1].includes(p.op) || + !/^0x[0-9a-fA-F]{64}$/.test(p.value) + ) + throw new Error('invalid argument pin'); + if (seen.has(p.offset)) throw new Error('duplicate pin offset'); + seen.add(p.offset); + } +} diff --git a/packages/core/src/lib/agentic/codec/rules.ts b/packages/core/src/lib/agentic/codec/rules.ts index 3b9988b05..e180f2695 100644 --- a/packages/core/src/lib/agentic/codec/rules.ts +++ b/packages/core/src/lib/agentic/codec/rules.ts @@ -22,7 +22,7 @@ import { import { buildSession, encodeEnvelope, type SessionWire } from './session'; import { encodeUniversalRule, type ResolvedUniversalRule } from './universal'; import { encodeSvmTerms, type SvmTermsWire } from './svm-terms'; -import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v6'; import type { UniversalTermsWire } from './universal-terms'; export function isSolanaRule(rule: InternalRule): rule is SolanaRule { @@ -81,7 +81,7 @@ export interface PreparedRule { svmTerms?: SvmTermsWire; } -/** Validate before signing. V5 permits several grants for the same agent/chain. */ +/** Validate before signing. V6 permits several grants for the same agent/chain. */ export function prepareRules( rules: readonly InternalRule[], ctx: RuleEncodeContext & { diff --git a/packages/core/src/lib/agentic/codec/session.ts b/packages/core/src/lib/agentic/codec/session.ts index fc98c1f6e..1a7c839cc 100644 --- a/packages/core/src/lib/agentic/codec/session.ts +++ b/packages/core/src/lib/agentic/codec/session.ts @@ -6,13 +6,13 @@ import { type Address, type Hex, } from 'viem'; -import { AGW_ABI } from '../contracts/abi/v5'; +import { AGW_ABI } from '../contracts/abi/v6'; import { agentConfig } from './ids'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; /** * The smartsessions `Session` a grant carries (DataTypes.sol:56-86, pinned - * v5). The wallet overwrites `salt` with its grant nonce, so the value + * v6). The wallet overwrites `salt` with its grant nonce, so the value * sent is irrelevant; it is always zero here. */ export interface SessionWire { @@ -44,7 +44,7 @@ const ZERO_SALT = `0x${'00'.repeat(32)}` as Hex; export const ENVELOPE_VERSION = 1; -/** V5 URP envelope: abi.encode(uint16(1), string chainNamespace, bytes body). */ +/** V6 URP envelope: abi.encode(uint16(1), string chainNamespace, bytes body). */ export function encodeEnvelope(chainNamespace: string, body: Hex): Hex { return encodeAbiParameters( [{ type: 'uint16' }, { type: 'string' }, { type: 'bytes' }], diff --git a/packages/core/src/lib/agentic/codec/svm-terms.ts b/packages/core/src/lib/agentic/codec/svm-terms.ts index dfae8ad73..3b0e10abd 100644 --- a/packages/core/src/lib/agentic/codec/svm-terms.ts +++ b/packages/core/src/lib/agentic/codec/svm-terms.ts @@ -1,4 +1,4 @@ -/** Internal v5 SVM wire representation; not a proposed public Rule API. */ +/** Internal v6 SVM wire representation; not a proposed public Rule API. */ import { decodeAbiParameters, encodeAbiParameters, @@ -192,7 +192,11 @@ export function validateSvmTerms(t: SvmTermsWire, nowSeconds: number): void { if (typeof r.dataless !== 'boolean') throw svmInvalid('dataless must be boolean'); integer(r.discriminatorLen, 8, 'discriminatorLen'); - integer(r.maxAccounts, SVM_LIMITS.accounts, 'maxAccounts'); + if (r.maxAccounts !== 255) { + integer(r.maxAccounts, SVM_LIMITS.accounts, 'maxAccounts'); + if (r.maxAccounts === 0) + throw svmInvalid('maxAccounts must be 1..64 or 255'); + } if (r.dataless !== (r.discriminatorLen === 0)) throw svmInvalid('dataless and discriminator length disagree'); if (isForbiddenSvmProgram(r.program, t)) @@ -227,7 +231,7 @@ export function validateSvmTerms(t: SvmTermsWire, nowSeconds: number): void { integer(p.accountIndex, SVM_LIMITS.accounts - 1, 'accountIndex'); exactHex(p.expected, 32, 'pin expected'); const count = t.programs[p.ruleIndex].maxAccounts; - if (count && p.accountIndex >= count) + if (count !== 255 && p.accountIndex >= count) throw svmInvalid('pin outside fixed account count'); const key = `${p.ruleIndex}:${p.accountIndex}`; if (positions.has(key)) throw svmInvalid('duplicate account pin'); diff --git a/packages/core/src/lib/agentic/codec/universal-terms.ts b/packages/core/src/lib/agentic/codec/universal-terms.ts index 4ac26304b..21af4c213 100644 --- a/packages/core/src/lib/agentic/codec/universal-terms.ts +++ b/packages/core/src/lib/agentic/codec/universal-terms.ts @@ -1,3 +1,4 @@ +import { ARG_PIN_COMPONENTS, type ArgPinWire } from './pins'; import { decodeAbiParameters, encodeAbiParameters, @@ -11,6 +12,7 @@ export interface AssetCapWire { maxTotal: bigint; } export interface AllowedCallWire { + pins: readonly ArgPinWire[]; target: Address; selector: Hex; beneficiaryOffset: number; @@ -48,6 +50,7 @@ export const UNIVERSAL_TERMS_PARAM = { { name: 'beneficiaryOffset', type: 'uint16' }, { name: 'hasBeneficiary', type: 'bool' }, { name: 'maxValue', type: 'uint256' }, + { name: 'pins', type: 'tuple[]', components: ARG_PIN_COMPONENTS }, ], }, ], @@ -56,5 +59,8 @@ export function encodeUniversalTerms(terms: UniversalTermsWire): Hex { return encodeAbiParameters([UNIVERSAL_TERMS_PARAM], [terms]); } export function decodeUniversalTerms(body: Hex): UniversalTermsWire { - return decodeAbiParameters([UNIVERSAL_TERMS_PARAM], body)[0]; + return decodeAbiParameters( + [UNIVERSAL_TERMS_PARAM], + body + )[0] as UniversalTermsWire; } diff --git a/packages/core/src/lib/agentic/codec/universal.ts b/packages/core/src/lib/agentic/codec/universal.ts index d27b48ffd..fa6b6d6f9 100644 --- a/packages/core/src/lib/agentic/codec/universal.ts +++ b/packages/core/src/lib/agentic/codec/universal.ts @@ -1,3 +1,4 @@ +import { validatePins } from './pins'; import { getAddress, isAddress, type Address } from 'viem'; import { AgenticCapability, requireCapability } from '../capabilities'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; @@ -7,7 +8,7 @@ import { argumentOffset, rawOffset } from './abi-layout'; import { parseSelector } from './selectors'; import { AGENTIC_DEFAULTS, UINT256_MAX, withDefault } from './defaults'; import { buildSession, encodeEnvelope } from './session'; -import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v6'; import { encodeUniversalTerms, type AssetCapWire, @@ -53,7 +54,8 @@ export function validateUniversalRuleShape( rule.allowedCalls.length > MAX_ALLOWED_CALLS ) throw invalid(`allowedCalls must contain 1..${MAX_ALLOWED_CALLS} entries`); - const seen = new Set(); + const seen = new Map(); + let pinCount = 0; rule.allowedCalls.forEach((call, i) => { if (!isAddress(call.target, { strict: false })) throw invalid(`allowedCalls[${i}].target is not an EVM address`); @@ -79,10 +81,24 @@ export function validateUniversalRuleShape( call.maxValue ?? AGENTIC_DEFAULTS.universal.allowedCallMaxValue.value, `allowedCalls[${i}].maxValue` ); + try { + validatePins(call.pins ?? []); + } catch (error) { + throw invalid((error as Error).message); + } + pinCount += call.pins?.length ?? 0; + if (pinCount > 32) + throw invalid('at most 32 pins across all allowed calls'); const key = `${call.target.toLowerCase()}:${parsed.selector}`; - if (seen.has(key)) - throw invalid(`allowedCalls[${i}] duplicates an earlier target/selector`); - seen.add(key); + const restricted = + !!call.pins?.length || + call.beneficiary !== undefined || + call.beneficiaryOffset !== undefined; + if (seen.has(key) && (!restricted || !seen.get(key))) + throw invalid( + 'an unpinned alternative would bypass restrictions for this target/selector' + ); + seen.set(key, restricted); }); const tokens = new Set(); rule.assets.forEach((a, i) => { @@ -137,6 +153,7 @@ export function encodeUniversalRule( assets: resolved.assets, maxGasPerCall: rule.maxGasPerCall, allowedCalls: rule.allowedCalls.map((call) => ({ + pins: call.pins ?? [], target: getAddress(call.target), selector: parseSelector(call.selector).selector, hasBeneficiary: @@ -200,6 +217,7 @@ export function universalTermsToRule( maxTotal: a.maxTotal, })), allowedCalls: terms.allowedCalls.map((c) => ({ + pins: [...c.pins], target: c.target, selector: c.selector, maxValue: c.maxValue, diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/agent-validator.ts b/packages/core/src/lib/agentic/contracts/abi/v6/agent-validator.ts similarity index 97% rename from packages/core/src/lib/agentic/contracts/abi/v5/agent-validator.ts rename to packages/core/src/lib/agentic/contracts/abi/v6/agent-validator.ts index 069206f63..52e5ded3b 100644 --- a/packages/core/src/lib/agentic/contracts/abi/v5/agent-validator.ts +++ b/packages/core/src/lib/agentic/contracts/abi/v6/agent-validator.ts @@ -1,6 +1,6 @@ // GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. // Source: packages/core/scripts/agw-local/abis/validator.json -// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb +// push-agentic-wallets@ffd567d3e49b2d27c6f50ad36093274dbac3090f // sha256(AgentValidator.json) = a4570ddc373241580de8742cbb3d9f2c837094c65bfd1cdd52243ca0c38196fd export const AGENT_VALIDATOR_ABI = [ { diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/agw-factory.ts b/packages/core/src/lib/agentic/contracts/abi/v6/agw-factory.ts similarity index 66% rename from packages/core/src/lib/agentic/contracts/abi/v5/agw-factory.ts rename to packages/core/src/lib/agentic/contracts/abi/v6/agw-factory.ts index 45519e583..215cf6b28 100644 --- a/packages/core/src/lib/agentic/contracts/abi/v5/agw-factory.ts +++ b/packages/core/src/lib/agentic/contracts/abi/v6/agw-factory.ts @@ -1,7 +1,7 @@ // GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. // Source: packages/core/scripts/agw-local/abis/factory.json -// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb -// sha256(AGWFactory.json) = b65f66a1556d2a9ddf9aabe9f595c685c2aada436772892f5a40fb1eec846397 +// push-agentic-wallets@ffd567d3e49b2d27c6f50ad36093274dbac3090f +// sha256(AGWFactory.json) = 08588f8870335492c5abeee32eec6a9bb050f81e234fdd58bbf389c6e9074a12 export const AGW_FACTORY_ABI = [ { "type": "function", @@ -95,6 +95,364 @@ export const AGW_FACTORY_ABI = [ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "createWallet", + "inputs": [ + { + "name": "label", + "type": "string", + "internalType": "string" + }, + { + "name": "rules", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "rulesIds", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createWalletWithSig", + "inputs": [ + { + "name": "intent", + "type": "tuple", + "internalType": "struct OwnerIntent", + "components": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "index", + "type": "uint96", + "internalType": "uint96" + }, + { + "name": "sessionHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "mode", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "execCalldataHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "nonceKey", + "type": "uint192", + "internalType": "uint192" + }, + { + "name": "nonceSeq", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "grantNonce", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "signerChainId", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "label", + "type": "string", + "internalType": "string" + }, + { + "name": "rules", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "wallet", + "type": "address", + "internalType": "address" + }, + { + "name": "rulesIds", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "defaultAdmin", @@ -1011,6 +1369,28 @@ export const AGW_FACTORY_ABI = [ } ] }, + { + "type": "error", + "name": "IntentGrantNonceNotZero", + "inputs": [ + { + "name": "provided", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "IntentRulesMismatch", + "inputs": [ + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, { "type": "error", "name": "IntentWalletMismatch", @@ -1080,6 +1460,17 @@ export const AGW_FACTORY_ABI = [ } ] }, + { + "type": "error", + "name": "TooManyInitialRules", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] + }, { "type": "error", "name": "UUPSUnauthorizedCallContext", diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/agw.ts b/packages/core/src/lib/agentic/contracts/abi/v6/agw.ts similarity index 89% rename from packages/core/src/lib/agentic/contracts/abi/v5/agw.ts rename to packages/core/src/lib/agentic/contracts/abi/v6/agw.ts index d3bed3290..0ec09cc8b 100644 --- a/packages/core/src/lib/agentic/contracts/abi/v5/agw.ts +++ b/packages/core/src/lib/agentic/contracts/abi/v6/agw.ts @@ -1,7 +1,7 @@ // GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. // Source: packages/core/scripts/agw-local/abis/wallet.json -// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb -// sha256(AGW.json) = 6a145964d6c43eaa6223dc8f3d59a540ac0c2f447f25c3a99007eea25de9c695 +// push-agentic-wallets@ffd567d3e49b2d27c6f50ad36093274dbac3090f +// sha256(AGW.json) = 76705e5a25b10d3a84f867f983c3e4f5adfc881fb0d65710fb8684df2ea7abd3 export const AGW_ABI = [ { "type": "receive", @@ -645,9 +645,134 @@ export const AGW_ABI = [ "name": "label_", "type": "string", "internalType": "string" + }, + { + "name": "rules", + "type": "tuple[]", + "internalType": "struct Session[]", + "components": [ + { + "name": "sessionValidator", + "type": "address", + "internalType": "contract ISessionValidator" + }, + { + "name": "sessionValidatorInitData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "userOpPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "erc7739Policies", + "type": "tuple", + "internalType": "struct ERC7739Data", + "components": [ + { + "name": "allowedERC7739Content", + "type": "tuple[]", + "internalType": "struct ERC7739Context[]", + "components": [ + { + "name": "appDomainSeparator", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "contentNames", + "type": "string[]", + "internalType": "string[]" + } + ] + }, + { + "name": "erc1271Policies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct ActionData[]", + "components": [ + { + "name": "actionTargetSelector", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "actionTarget", + "type": "address", + "internalType": "address" + }, + { + "name": "actionPolicies", + "type": "tuple[]", + "internalType": "struct PolicyData[]", + "components": [ + { + "name": "policy", + "type": "address", + "internalType": "address" + }, + { + "name": "initData", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "permitERC4337Paymaster", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "outputs": [ + { + "name": "rulesIds", + "type": "bytes32[]", + "internalType": "bytes32[]" } ], - "outputs": [], "stateMutability": "nonpayable" }, { diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/index.ts b/packages/core/src/lib/agentic/contracts/abi/v6/index.ts similarity index 69% rename from packages/core/src/lib/agentic/contracts/abi/v5/index.ts rename to packages/core/src/lib/agentic/contracts/abi/v6/index.ts index fa7ddd6ae..b0883d137 100644 --- a/packages/core/src/lib/agentic/contracts/abi/v5/index.ts +++ b/packages/core/src/lib/agentic/contracts/abi/v6/index.ts @@ -1,6 +1,6 @@ // GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. -// Review generation v5: Donut v5 deployment; source 2e61e13. -export const V5_SOURCE_COMMIT = '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb' as const; +// Review generation v6: Donut v6 deployment; source ffd567d. +export const V6_SOURCE_COMMIT = 'ffd567d3e49b2d27c6f50ad36093274dbac3090f' as const; export { AGW_ABI } from './agw'; export { AGW_FACTORY_ABI } from './agw-factory'; export { UNIVERSAL_RULES_POLICY_ABI } from './universal-rules-policy'; diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/smart-session.ts b/packages/core/src/lib/agentic/contracts/abi/v6/smart-session.ts similarity index 99% rename from packages/core/src/lib/agentic/contracts/abi/v5/smart-session.ts rename to packages/core/src/lib/agentic/contracts/abi/v6/smart-session.ts index 20e207b12..3fe24ed26 100644 --- a/packages/core/src/lib/agentic/contracts/abi/v5/smart-session.ts +++ b/packages/core/src/lib/agentic/contracts/abi/v6/smart-session.ts @@ -1,6 +1,6 @@ // GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. // Source: packages/core/scripts/agw-local/abis/engine.json -// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb +// push-agentic-wallets@ffd567d3e49b2d27c6f50ad36093274dbac3090f // sha256(SmartSession.json) = 6e5d9cea4dbed01f06af913c1e093743b86b7432195f4a4bf90fb94b282c51b2 export const SMART_SESSION_ABI = [ { diff --git a/packages/core/src/lib/agentic/contracts/abi/v5/universal-rules-policy.ts b/packages/core/src/lib/agentic/contracts/abi/v6/universal-rules-policy.ts similarity index 97% rename from packages/core/src/lib/agentic/contracts/abi/v5/universal-rules-policy.ts rename to packages/core/src/lib/agentic/contracts/abi/v6/universal-rules-policy.ts index 5b0e2f34b..089855b0c 100644 --- a/packages/core/src/lib/agentic/contracts/abi/v5/universal-rules-policy.ts +++ b/packages/core/src/lib/agentic/contracts/abi/v6/universal-rules-policy.ts @@ -1,7 +1,7 @@ // GENERATED by packages/core/scripts/agw-local/gen-abi.mjs — do not edit. // Source: packages/core/scripts/agw-local/abis/policy.json -// push-agentic-wallets@2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb -// sha256(UniversalRulesPolicy.json) = c7e3cd5c7f8e47be217c77ebebde4c090903cea7db63024838e434eb6f1fd77b +// push-agentic-wallets@ffd567d3e49b2d27c6f50ad36093274dbac3090f +// sha256(UniversalRulesPolicy.json) = a07661d19b19656afdea560275d45669a908cd96646817884295e0017560f299 export const UNIVERSAL_RULES_POLICY_ABI = [ { "type": "function", @@ -280,6 +280,28 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ "name": "maxValue", "type": "uint256", "internalType": "uint256" + }, + { + "name": "pins", + "type": "tuple[]", + "internalType": "struct ArgPin[]", + "components": [ + { + "name": "offset", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "op", + "type": "uint8", + "internalType": "enum PinOp" + }, + { + "name": "value", + "type": "bytes32", + "internalType": "bytes32" + } + ] } ] } @@ -443,7 +465,12 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ "internalType": "uint16" }, { - "name": "expected", + "name": "op", + "type": "uint8", + "internalType": "enum PinOp" + }, + { + "name": "value", "type": "bytes32", "internalType": "bytes32" } @@ -980,15 +1007,15 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "AmbiguousRule", + "name": "AmountExceedsCap", "inputs": [ { - "name": "i", + "name": "amount", "type": "uint256", "internalType": "uint256" }, { - "name": "j", + "name": "cap", "type": "uint256", "internalType": "uint256" } @@ -996,26 +1023,20 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "AmountExceedsCap", + "name": "ArgPinAboveMax", "inputs": [ { - "name": "amount", + "name": "actual", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", + "name": "index", "type": "uint256", "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "AmountExceedsU64", - "inputs": [ + }, { - "name": "amount", + "name": "max", "type": "uint256", "internalType": "uint256" } @@ -1096,22 +1117,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "BeneficiaryMismatch", - "inputs": [ - { - "name": "expected", - "type": "address", - "internalType": "address" - }, - { - "name": "actual", - "type": "address", - "internalType": "address" - } - ] - }, { "type": "error", "name": "CallLimitReached", @@ -1203,27 +1208,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "CeaAccountAtUnpinnedIndex", - "inputs": [ - { - "name": "accountIndex", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "account", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "CeaAccountsMissExpectedCEA", - "inputs": [] - }, { "type": "error", "name": "ChainMismatch", @@ -1240,22 +1224,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "DiscriminatorLenOutOfRange", - "inputs": [ - { - "name": "rule", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "len", - "type": "uint8", - "internalType": "uint8" - } - ] - }, { "type": "error", "name": "DuplicateAsset", @@ -1267,22 +1235,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "DuplicateSvmPin", - "inputs": [ - { - "name": "first", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "second", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "EmptyChain", @@ -1299,54 +1251,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "ForbiddenProgramInAllowList", - "inputs": [ - { - "name": "rule", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "program", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "ForbiddenTargetProgram", - "inputs": [ - { - "name": "program", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "InnerValueExceedsAllowance", - "inputs": [ - { - "name": "index", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "value", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "maxValue", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "InvalidAsset", @@ -1358,17 +1262,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "InvalidCeaAccount", - "inputs": [ - { - "name": "index", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "InvalidConfigField", @@ -1417,11 +1310,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "InvalidSvmConfigField", - "inputs": [] - }, { "type": "error", "name": "InvalidTarget", @@ -1449,17 +1337,6 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "MalformedSvmPayload", - "inputs": [ - { - "name": "code", - "type": "uint8", - "internalType": "uint8" - } - ] - }, { "type": "error", "name": "NativeAmountExceedsCap", @@ -1555,76 +1432,11 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ } ] }, - { - "type": "error", - "name": "ProgramListOutOfRange", - "inputs": [ - { - "name": "length", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "ProgramNotAllowed", - "inputs": [ - { - "name": "program", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "discriminator", - "type": "bytes8", - "internalType": "bytes8" - } - ] - }, { "type": "error", "name": "RecipientMustBeEmpty", "inputs": [] }, - { - "type": "error", - "name": "RecipientNotPubkey", - "inputs": [ - { - "name": "length", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "RecipientTargetMismatch", - "inputs": [ - { - "name": "recipient", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "target", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "RuleWithoutPin", - "inputs": [ - { - "name": "rule", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "RulesExpired", @@ -1686,73 +1498,85 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmAccountCountBelowPin", + "name": "TargetMismatch", "inputs": [ { - "name": "pin", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "count", - "type": "uint256", - "internalType": "uint256" + "name": "actual", + "type": "address", + "internalType": "address" }, { - "name": "needed", - "type": "uint8", - "internalType": "uint8" + "name": "expected", + "type": "address", + "internalType": "address" } ] }, { "type": "error", - "name": "SvmAccountCountMismatch", + "name": "TooManyAllowedCallPins", "inputs": [ { - "name": "count", + "name": "total", "type": "uint256", "internalType": "uint256" - }, - { - "name": "expected", - "type": "uint8", - "internalType": "uint8" } ] }, { "type": "error", - "name": "SvmAccountPinMismatch", + "name": "TooManyPins", "inputs": [ { - "name": "pin", + "name": "count", "type": "uint256", "internalType": "uint256" - }, + } + ] + }, + { + "type": "error", + "name": "TotalNativeAmountExceeded", + "inputs": [ { - "name": "accountIndex", - "type": "uint8", - "internalType": "uint8" + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" }, { - "name": "expected", - "type": "bytes32", - "internalType": "bytes32" + "name": "cap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "TotalSpendCapExceeded", + "inputs": [ + { + "name": "wouldBeTotal", + "type": "uint256", + "internalType": "uint256" }, { - "name": "actual", - "type": "bytes32", - "internalType": "bytes32" + "name": "cap", + "type": "uint256", + "internalType": "uint256" } ] }, { "type": "error", - "name": "SvmAccountsOutOfRange", + "name": "TotalValueExceeded", "inputs": [ { - "name": "count", + "name": "newSpent", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "cap", "type": "uint256", "internalType": "uint256" } @@ -1760,20 +1584,42 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmDataCeilingExceeded", + "name": "UncappedGasSwapRejected", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedEnvelopeVersion", "inputs": [ { - "name": "pin", + "name": "version", "type": "uint256", "internalType": "uint256" - }, + } + ] + }, + { + "type": "error", + "name": "UnsupportedNamespace", + "inputs": [ { - "name": "actual", + "name": "chainHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ValueExceedsCap", + "inputs": [ + { + "name": "value", "type": "uint256", "internalType": "uint256" }, { - "name": "ceiling", + "name": "cap", "type": "uint256", "internalType": "uint256" } @@ -1781,20 +1627,68 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmDataFloorNotMet", + "name": "ValueOnlyCalldataNotEmpty", "inputs": [ { - "name": "pin", + "name": "length", "type": "uint256", "internalType": "uint256" - }, + } + ] + }, + { + "type": "error", + "name": "ValueOnlyWithAmountRule", + "inputs": [] + }, + { + "type": "error", + "name": "ValueOnlyWithPins", + "inputs": [] + }, + { + "type": "error", + "name": "WrongModeForCall", + "inputs": [ + { + "name": "actual", + "type": "uint8", + "internalType": "enum RulesType" + } + ] + }, + { + "type": "error", + "name": "WrongVmForCall", + "inputs": [ { "name": "actual", + "type": "uint8", + "internalType": "enum VmFamily" + } + ] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + }, + { + "type": "error", + "name": "ZeroMaxCalls", + "inputs": [] + }, + { + "type": "error", + "name": "AmbiguousRule", + "inputs": [ + { + "name": "i", "type": "uint256", "internalType": "uint256" }, { - "name": "floor", + "name": "j", "type": "uint256", "internalType": "uint256" } @@ -1802,10 +1696,10 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmDataPinInvalid", + "name": "AmountExceedsU64", "inputs": [ { - "name": "pin", + "name": "amount", "type": "uint256", "internalType": "uint256" } @@ -1813,20 +1707,15 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmDataPinMismatch", + "name": "CeaAccountAtUnpinnedIndex", "inputs": [ { - "name": "pin", + "name": "accountIndex", "type": "uint256", "internalType": "uint256" }, { - "name": "expected", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "actual", + "name": "account", "type": "bytes32", "internalType": "bytes32" } @@ -1834,46 +1723,74 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmDataRatioNotMet", + "name": "CeaAccountsMissExpectedCEA", + "inputs": [] + }, + { + "type": "error", + "name": "DiscriminatorLenOutOfRange", "inputs": [ { - "name": "pin", + "name": "rule", "type": "uint256", "internalType": "uint256" }, { - "name": "a", + "name": "len", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "DuplicateSvmPin", + "inputs": [ + { + "name": "first", "type": "uint256", "internalType": "uint256" }, { - "name": "b", + "name": "second", "type": "uint256", "internalType": "uint256" - }, - { - "name": "num", - "type": "uint64", - "internalType": "uint64" - }, - { - "name": "den", - "type": "uint64", - "internalType": "uint64" } ] }, { "type": "error", - "name": "SvmDataTooShortForPin", + "name": "ForbiddenProgramInAllowList", "inputs": [ { - "name": "pin", + "name": "rule", "type": "uint256", "internalType": "uint256" }, { - "name": "ixLen", + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ForbiddenTargetProgram", + "inputs": [ + { + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidCeaAccount", + "inputs": [ + { + "name": "index", "type": "uint256", "internalType": "uint256" } @@ -1881,10 +1798,15 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmInstructionNotExecute", + "name": "InvalidSvmConfigField", + "inputs": [] + }, + { + "type": "error", + "name": "MalformedSvmPayload", "inputs": [ { - "name": "instructionId", + "name": "code", "type": "uint8", "internalType": "uint8" } @@ -1892,7 +1814,7 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmIxDataTooLong", + "name": "ProgramListOutOfRange", "inputs": [ { "name": "length", @@ -1903,28 +1825,61 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmMaxAccountsOutOfRange", + "name": "ProgramNotAllowed", "inputs": [ { - "name": "rule", + "name": "program", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "discriminator", + "type": "bytes8", + "internalType": "bytes8" + } + ] + }, + { + "type": "error", + "name": "RecipientNotPubkey", + "inputs": [ + { + "name": "length", "type": "uint256", "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "RecipientTargetMismatch", + "inputs": [ + { + "name": "recipient", + "type": "bytes32", + "internalType": "bytes32" }, { - "name": "maxAccounts", - "type": "uint8", - "internalType": "uint8" + "name": "target", + "type": "bytes32", + "internalType": "bytes32" } ] }, { "type": "error", - "name": "SvmPayloadEmpty", - "inputs": [] + "name": "RuleWithoutPin", + "inputs": [ + { + "name": "rule", + "type": "uint256", + "internalType": "uint256" + } + ] }, { "type": "error", - "name": "SvmPinIndexOutOfRange", + "name": "SvmAccountCountBelowPin", "inputs": [ { "name": "pin", @@ -1932,7 +1887,12 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ "internalType": "uint256" }, { - "name": "accountIndex", + "name": "count", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", "type": "uint8", "internalType": "uint8" } @@ -1940,15 +1900,15 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "SvmPinRuleOutOfRange", + "name": "SvmAccountCountMismatch", "inputs": [ { - "name": "pin", + "name": "count", "type": "uint256", "internalType": "uint256" }, { - "name": "rule", + "name": "expected", "type": "uint8", "internalType": "uint8" } @@ -1956,23 +1916,33 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "TargetMismatch", + "name": "SvmAccountPinMismatch", "inputs": [ { - "name": "actual", - "type": "address", - "internalType": "address" + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" }, { "name": "expected", - "type": "address", - "internalType": "address" + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" } ] }, { "type": "error", - "name": "TooManyCeaAccounts", + "name": "SvmAccountsOutOfRange", "inputs": [ { "name": "count", @@ -1983,10 +1953,20 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "TooManyPins", + "name": "SvmDataCeilingExceeded", "inputs": [ { - "name": "count", + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "ceiling", "type": "uint256", "internalType": "uint256" } @@ -1994,10 +1974,20 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "TooManySvmDataPins", + "name": "SvmDataFloorNotMet", "inputs": [ { - "name": "count", + "name": "pin", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actual", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "floor", "type": "uint256", "internalType": "uint256" } @@ -2005,10 +1995,10 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "TooManySvmPins", + "name": "SvmDataPinInvalid", "inputs": [ { - "name": "count", + "name": "pin", "type": "uint256", "internalType": "uint256" } @@ -2016,47 +2006,67 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "TotalNativeAmountExceeded", + "name": "SvmDataPinMismatch", "inputs": [ { - "name": "newSpent", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", - "type": "uint256", - "internalType": "uint256" + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actual", + "type": "bytes32", + "internalType": "bytes32" } ] }, { "type": "error", - "name": "TotalSpendCapExceeded", + "name": "SvmDataRatioNotMet", "inputs": [ { - "name": "wouldBeTotal", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", + "name": "a", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "b", "type": "uint256", "internalType": "uint256" + }, + { + "name": "num", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "den", + "type": "uint64", + "internalType": "uint64" } ] }, { "type": "error", - "name": "TotalValueExceeded", + "name": "SvmDataTooShortForPin", "inputs": [ { - "name": "newSpent", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", + "name": "ixLen", "type": "uint256", "internalType": "uint256" } @@ -2064,15 +2074,21 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "UncappedGasSwapRejected", - "inputs": [] + "name": "SvmInstructionNotExecute", + "inputs": [ + { + "name": "instructionId", + "type": "uint8", + "internalType": "uint8" + } + ] }, { "type": "error", - "name": "UnsupportedEnvelopeVersion", + "name": "SvmIxDataTooLong", "inputs": [ { - "name": "version", + "name": "length", "type": "uint256", "internalType": "uint256" } @@ -2080,77 +2096,88 @@ export const UNIVERSAL_RULES_POLICY_ABI = [ }, { "type": "error", - "name": "UnsupportedNamespace", + "name": "SvmMaxAccountsOutOfRange", "inputs": [ { - "name": "chainHash", - "type": "bytes32", - "internalType": "bytes32" + "name": "rule", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxAccounts", + "type": "uint8", + "internalType": "uint8" } ] }, { "type": "error", - "name": "ValueExceedsCap", + "name": "SvmPayloadEmpty", + "inputs": [] + }, + { + "type": "error", + "name": "SvmPinIndexOutOfRange", "inputs": [ { - "name": "value", + "name": "pin", "type": "uint256", "internalType": "uint256" }, { - "name": "cap", - "type": "uint256", - "internalType": "uint256" + "name": "accountIndex", + "type": "uint8", + "internalType": "uint8" } ] }, { "type": "error", - "name": "ValueOnlyCalldataNotEmpty", + "name": "SvmPinRuleOutOfRange", "inputs": [ { - "name": "length", + "name": "pin", "type": "uint256", "internalType": "uint256" + }, + { + "name": "rule", + "type": "uint8", + "internalType": "uint8" } ] }, { "type": "error", - "name": "ValueOnlyWithAmountRule", - "inputs": [] - }, - { - "type": "error", - "name": "ValueOnlyWithPins", - "inputs": [] - }, - { - "type": "error", - "name": "WrongModeForCall", + "name": "TooManyCeaAccounts", "inputs": [ { - "name": "actual", - "type": "uint8", - "internalType": "enum RulesType" + "name": "count", + "type": "uint256", + "internalType": "uint256" } ] }, { "type": "error", - "name": "WrongVmForCall", + "name": "TooManySvmDataPins", "inputs": [ { - "name": "actual", - "type": "uint8", - "internalType": "enum VmFamily" + "name": "count", + "type": "uint256", + "internalType": "uint256" } ] }, { "type": "error", - "name": "ZeroAddress", - "inputs": [] + "name": "TooManySvmPins", + "inputs": [ + { + "name": "count", + "type": "uint256", + "internalType": "uint256" + } + ] } ] as const; diff --git a/packages/core/src/lib/agentic/contracts/donut-v5.ts b/packages/core/src/lib/agentic/contracts/donut-v5.ts deleted file mode 100644 index c75425ea6..000000000 --- a/packages/core/src/lib/agentic/contracts/donut-v5.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Address } from 'viem'; - -/** Source 2e61e13; code/proxy wiring checked at Donut block 23991912. */ -export const V5_SOURCE_COMMIT = '2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb'; -export const DONUT_V5_START_BLOCK = BigInt(23989983); -export const DONUT_V5_ADDRESSES = { - factory: '0x8137F96A50EBF41d904e3678c84c391a0D1BCcc5', - walletImplementation: '0x4D459Da499C14548aa16c46c57fD92880A88EBb4', - sessionEngine: '0x165A5E6782f39D30B38c7D97e1303e4CB2aD102a', - rulesPolicy: '0x603E7f0aF6e1aAFf46DDfb28b1e99364f8BC59af', - sessionValidator: '0x068EE2388475A98EE1f5a434C58bFF3444fffFe6', - gateway: '0x00000000000000000000000000000000000000C1', -} as const satisfies Record; diff --git a/packages/core/src/lib/agentic/contracts/donut-v6.ts b/packages/core/src/lib/agentic/contracts/donut-v6.ts new file mode 100644 index 000000000..b18992184 --- /dev/null +++ b/packages/core/src/lib/agentic/contracts/donut-v6.ts @@ -0,0 +1,13 @@ +import type { Address } from 'viem'; + +/** Source ffd567d; code sizes and wiring checked at Donut block 24244801. */ +export const V6_SOURCE_COMMIT = 'ffd567d3e49b2d27c6f50ad36093274dbac3090f'; +export const DONUT_V6_START_BLOCK = BigInt(24229957); +export const DONUT_V6_ADDRESSES = { + factory: '0xFBBEB6a365178fAaA81A9789a5f64b300c37e5bc', + walletImplementation: '0x536D3EA48219ff743ac663C6c224920e9eB476A5', + sessionEngine: '0xf63DC0628fe1B25049FE3E3a3585Ef051CB98c1d', + rulesPolicy: '0xBA32e7A1500cb34311a31127bcD40CA52C8CFd36', + sessionValidator: '0xBf08d8335c1cE41a577DB70666816027814737dD', + gateway: '0x00000000000000000000000000000000000000C1', +} as const satisfies Record; diff --git a/packages/core/src/lib/agentic/contracts/v5.ts b/packages/core/src/lib/agentic/contracts/v6.ts similarity index 88% rename from packages/core/src/lib/agentic/contracts/v5.ts rename to packages/core/src/lib/agentic/contracts/v6.ts index 444dc5def..6d9a3b973 100644 --- a/packages/core/src/lib/agentic/contracts/v5.ts +++ b/packages/core/src/lib/agentic/contracts/v6.ts @@ -18,7 +18,7 @@ import { AGW_FACTORY_ABI, SMART_SESSION_ABI, UNIVERSAL_RULES_POLICY_ABI, -} from './abi/v5'; +} from './abi/v6'; import type { SessionWire } from '../codec/session'; import type { CheckpointKind } from '../agentic.types'; import type { NativeTermsWire } from '../codec/native'; @@ -26,7 +26,7 @@ import type { UniversalTermsWire } from '../codec/universal-terms'; import type { SvmTermsWire } from '../codec/svm-terms'; /** - * Contract adapter for the reviewed v5 ABI generation. All version- + * Contract adapter for the reviewed v6 ABI generation. All version- * sensitive encoding, decoding and view names live here; callers never touch * a raw ABI. A future generation gets its own adapter implementing the same * interface. @@ -104,8 +104,8 @@ const EXECUTION_TUPLE = [ }, ] as const; -export const v5 = { - id: 'v5', +export const v6 = { + id: 'v6', abis: { wallet: AGW_ABI, factory: AGW_FACTORY_ABI, @@ -178,6 +178,39 @@ export const v5 = { ], }); }, + encodeCreateWalletAt( + owner: Address, + index: bigint, + wallet: Address, + label: string, + rules: readonly SessionWire[] + ): Hex { + const zero32 = `0x${'00'.repeat(32)}` as Hex; + const zeroAddress = '0x0000000000000000000000000000000000000000' as Address; + return encodeFunctionData({ + abi: AGW_FACTORY_ABI, + functionName: 'createWalletWithSig', + args: [ + { + owner, + wallet, + executor: zeroAddress, + index, + sessionHash: zero32, + mode: zero32, + execCalldataHash: zero32, + nonceKey: BigInt(0), + nonceSeq: BigInt(0), + grantNonce: BigInt(0), + deadline: 0, + signerChainId: BigInt(0), + }, + '0x', + label, + rules as never, + ], + }); + }, encodeGrantRules(session: SessionWire): Hex { return encodeFunctionData({ abi: AGW_ABI, @@ -219,7 +252,7 @@ export const v5 = { return encodeFunctionData({ abi: AGW_ABI, functionName: 'execute', - args: [MODE_SINGLE, v5.packSingle(calls[0])], + args: [MODE_SINGLE, v6.packSingle(calls[0])], }); } const batch = encodeAbiParameters(EXECUTION_TUPLE, [ @@ -239,7 +272,7 @@ export const v5 = { return encodeFunctionData({ abi: AGW_ABI, functionName: 'executeAsAgent', - args: [rulesId, MODE_SINGLE, v5.packSingle(call)], + args: [rulesId, MODE_SINGLE, v6.packSingle(call)], }); }, encodeAssertSpentNative( @@ -312,7 +345,7 @@ export const v5 = { return k; }, parseWalletDeployed(logs: readonly Log[], factory: Address) { - return decodeMatching(logs, factory, v5.events.walletDeployed).map( + return decodeMatching(logs, factory, v6.events.walletDeployed).map( (args) => ({ owner: getAddress(args['owner'] as Address), index: BigInt(args['index'] as bigint), @@ -322,14 +355,14 @@ export const v5 = { ); }, parseRulesGranted(logs: readonly Log[], wallet: Address) { - return decodeMatching(logs, wallet, v5.events.rulesGranted).map((args) => ({ + return decodeMatching(logs, wallet, v6.events.rulesGranted).map((args) => ({ rulesId: args['rulesId'] as Hex, mode: Number(args['mode']), chainNamespace: args['chainNamespace'] as string, })); }, parseRulesRevoked(logs: readonly Log[], wallet: Address) { - return decodeMatching(logs, wallet, v5.events.rulesRevoked).map((args) => ({ + return decodeMatching(logs, wallet, v6.events.rulesRevoked).map((args) => ({ rulesId: args['rulesId'] as Hex, })); }, @@ -339,12 +372,12 @@ export const v5 = { (l) => sameAddress(l.address, wallet) && l.topics[0]?.toLowerCase() === - toEventSelector(v5.events.checkpointed).toLowerCase() + toEventSelector(v6.events.checkpointed).toLowerCase() ) .flatMap((log) => { try { const ev = decodeEventLog({ - abi: [v5.events.checkpointed], + abi: [v6.events.checkpointed], ...log, strict: true, }); @@ -355,7 +388,7 @@ export const v5 = { }) .map(({ log, args }) => ({ seq: Number(args['seq']), - kind: v5.decodeCheckpointKind(Number(args['kind'])), + kind: v6.decodeCheckpointKind(Number(args['kind'])), ref: args['ref'] as Hex, blockNumber: BigInt(log.blockNumber ?? BigInt(0)), txHash: log.transactionHash as Hex, @@ -419,4 +452,4 @@ function decodeMatching( return out; } -export type AgwContracts = typeof v5; +export type AgwContracts = typeof v6; diff --git a/packages/core/src/lib/agentic/deployments.ts b/packages/core/src/lib/agentic/deployments.ts index e9a9f84eb..99e61eb16 100644 --- a/packages/core/src/lib/agentic/deployments.ts +++ b/packages/core/src/lib/agentic/deployments.ts @@ -1,14 +1,14 @@ import { getAddress, type Address } from 'viem'; import { PUSH_NETWORK } from '../constants/enums'; import { AgenticCapability } from './capabilities'; -import { v5, type AgwContracts } from './contracts/v5'; +import { v6, type AgwContracts } from './contracts/v6'; import type { ChainReader } from './contracts/reader'; import { AGENTIC_ERROR_CODE, AgenticError } from './errors'; import { - DONUT_V5_ADDRESSES, - DONUT_V5_START_BLOCK, - V5_SOURCE_COMMIT, -} from './contracts/donut-v5'; + DONUT_V6_ADDRESSES, + DONUT_V6_START_BLOCK, + V6_SOURCE_COMMIT, +} from './contracts/donut-v6'; /** * A verified AGW contract generation on one Push network. The registry is the @@ -17,7 +17,7 @@ import { * are NOT registered because they do not implement this ABI. */ export interface AgenticGeneration { - /** Stable identifier, e.g. 'v5'. */ + /** Stable identifier, e.g. 'v6'. */ id: string; /** Contract source commit the ABI was generated from. */ sourceCommit: string; @@ -40,8 +40,8 @@ export interface AgenticGeneration { contracts: AgwContracts; } -/** Capabilities the v5 ABI actually implements. Target-only features are absent. */ -export const V5_CAPABILITIES: ReadonlySet = new Set([ +/** Capabilities the v6 ABI actually implements. Target-only features are absent. */ +export const V6_CAPABILITIES: ReadonlySet = new Set([ AgenticCapability.WALLET_READS, AgenticCapability.ACTIVE_RULE_READS, AgenticCapability.OWNER_EXECUTE, @@ -57,19 +57,19 @@ export const V5_CAPABILITIES: ReadonlySet = new Set([ ]); /** - * V5 only. Unsupported networks fail before signing; no legacy adapter fallback. + * V6 only. Unsupported networks fail before signing; no legacy adapter fallback. */ const VERIFIED_DEPLOYMENTS: readonly AgenticGeneration[] = [ { - id: 'v5', - sourceCommit: V5_SOURCE_COMMIT, + id: 'v6', + sourceCommit: V6_SOURCE_COMMIT, network: PUSH_NETWORK.TESTNET_DONUT, advertised: true, - addresses: DONUT_V5_ADDRESSES, - startBlock: DONUT_V5_START_BLOCK, + addresses: DONUT_V6_ADDRESSES, + startBlock: DONUT_V6_START_BLOCK, accountId: 'push.agw.1.0.0', - capabilities: V5_CAPABILITIES, - contracts: v5, + capabilities: V6_CAPABILITIES, + contracts: v6, }, ]; @@ -85,7 +85,7 @@ export function registerAgenticGeneration( capabilities?: ReadonlySet; } ): AgenticGeneration { - if (input.id !== v5.id || input.sourceCommit !== V5_SOURCE_COMMIT) { + if (input.id !== v6.id || input.sourceCommit !== V6_SOURCE_COMMIT) { throw new AgenticError( AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `no contract adapter for generation "${input.id}"` @@ -101,8 +101,8 @@ export function registerAgenticGeneration( sessionValidator: getAddress(input.addresses.sessionValidator), gateway: getAddress(input.addresses.gateway), }, - capabilities: input.capabilities ?? V5_CAPABILITIES, - contracts: v5, + capabilities: input.capabilities ?? V6_CAPABILITIES, + contracts: v6, }; const existing = registry.findIndex( (g) => @@ -114,7 +114,7 @@ export function registerAgenticGeneration( return gen; } -/** @internal Test/harness cleanup. Restores the checked Donut v5 registry. */ +/** @internal Test/harness cleanup. Restores the checked Donut v6 registry. */ export function resetAgenticGenerations(): void { registry.splice(0, registry.length, ...VERIFIED_DEPLOYMENTS); verifiedWiring.clear(); @@ -133,7 +133,7 @@ export async function verifyPolicyVersion( if (version !== '3.1.0') throw new AgenticError( AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, - `URP version ${String(version)} is not supported by the v5 adapter` + `URP version ${String(version)} is not supported by the v6 adapter` ); } @@ -152,7 +152,7 @@ export function currentGeneration(network: PUSH_NETWORK): AgenticGeneration { AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, `no verified AGW deployment is registered for ${network}`, { - hint: 'AGW v5 is configured on Donut only; this Push network has no registered v5 deployment.', + hint: 'AGW v6 is configured on Donut only; this Push network has no registered v6 deployment.', details: { network, assumption: 'A07' }, } ); @@ -233,7 +233,7 @@ export async function resolveWalletGeneration( factory = getAddress( (await reader.readContract({ address: wallet, - abi: v5.abis.wallet, + abi: v6.abis.wallet, functionName: 'factory', })) as Address ); diff --git a/packages/core/src/lib/agentic/errors.ts b/packages/core/src/lib/agentic/errors.ts index b7583887e..bbe55e4bd 100644 --- a/packages/core/src/lib/agentic/errors.ts +++ b/packages/core/src/lib/agentic/errors.ts @@ -34,12 +34,10 @@ export const AGENTIC_ERROR_CODE = { RULE_READ_FAILED: 'RULE_READ_FAILED', INCONSISTENT_READ: 'INCONSISTENT_READ', INDEX_RACE: 'INDEX_RACE', - CREATE_PARTIAL: 'CREATE_PARTIAL', RECEIPT_MISMATCH: 'RECEIPT_MISMATCH', RECEIPT_UNAVAILABLE: 'RECEIPT_UNAVAILABLE', AGENT_GAS_INSUFFICIENT: 'AGENT_GAS_INSUFFICIENT', WALLET_BALANCE_INSUFFICIENT: 'WALLET_BALANCE_INSUFFICIENT', - GATEWAY_ALLOWANCE_INSUFFICIENT: 'GATEWAY_ALLOWANCE_INSUFFICIENT', } as const; export type AgenticErrorCode = @@ -48,7 +46,7 @@ export type AgenticErrorCode = /** * Pre-signature AGW failure: invalid input, unsupported capability, failed * eligibility or a read that cannot be trusted. Nothing was signed or sent - * unless `details` explicitly records committed hashes (CREATE_PARTIAL). + * except receipt errors, which retain the submitted transaction hash. */ export class AgenticError extends Error { readonly code: AgenticErrorCode; @@ -82,8 +80,6 @@ export class AgenticError extends Error { */ export class AgenticRevertError extends PushChainExecutionError { readonly agenticCode?: string; - readonly transactionHashes?: `0x${string}`[]; - readonly pendingTransactionHash?: `0x${string}`; constructor( message: string, @@ -91,8 +87,6 @@ export class AgenticRevertError extends PushChainExecutionError { decodedError?: DecodedErrorPayload; gatewayTxHash?: string; agenticCode?: string; - transactionHashes?: `0x${string}`[]; - pendingTransactionHash?: `0x${string}`; cause?: unknown; } = {} ) { @@ -103,10 +97,6 @@ export class AgenticRevertError extends PushChainExecutionError { }); this.name = 'AgenticRevertError'; this.agenticCode = opts.agenticCode; - this.transactionHashes = opts.transactionHashes - ? [...opts.transactionHashes] - : undefined; - this.pendingTransactionHash = opts.pendingTransactionHash; } } diff --git a/packages/core/src/lib/agentic/execution/outbound.ts b/packages/core/src/lib/agentic/execution/outbound.ts index 999690ef5..5e77b6f66 100644 --- a/packages/core/src/lib/agentic/execution/outbound.ts +++ b/packages/core/src/lib/agentic/execution/outbound.ts @@ -18,8 +18,8 @@ import { isPC20Reference } from '../../orchestrator/orchestrator.types'; import { resolveR2DestinationFundsToken } from '../../orchestrator/internals/route-handlers'; import { AgenticCapability, requireCapability } from '../capabilities'; import type { AgenticGeneration } from '../deployments'; -import type { Call } from '../contracts/v5'; -import { UEA_MULTICALL_PREFIX } from '../contracts/v5'; +import type { Call } from '../contracts/v6'; +import { UEA_MULTICALL_PREFIX } from '../contracts/v6'; import { AGENTIC_ERROR_CODE, AgenticError, @@ -30,7 +30,7 @@ import type { AgenticRuntime } from '../runtime'; /** Includes first-use CEA deployment; explicit caller gas always takes precedence. */ const FIRST_USE_EVM_GAS_LIMIT = BigInt(2_000_000); -/** URP MAX_ACTIONS_PER_REQUEST at v5. */ +/** URP MAX_ACTIONS_PER_REQUEST at v6. */ export const MAX_DESTINATION_CALLS = 10; const MULTICALL_TUPLE = [ @@ -123,8 +123,8 @@ export function destinationCalls( * - payload = UEA_MULTICALL ‖ abi.encode(Multicall[1..10]) executed by the AGW's CEA * - value = protocolFee + maxPCForGas, paid from the AGW's PC * The destination account is resolved from the AGW, never from the signer. - * The SDK never writes a gateway allowance on either door: the gateway pulls - * from an allowance the owner set separately with an ordinary send. + * The SDK does not add approval calls: the v6 wallet approves and clears + * the gateway allowance within its own outbound execution. */ export async function composeOutbound( runtime: AgenticRuntime, diff --git a/packages/core/src/lib/agentic/execution/send.ts b/packages/core/src/lib/agentic/execution/send.ts index 6f18a4ca1..ca2b978f8 100644 --- a/packages/core/src/lib/agentic/execution/send.ts +++ b/packages/core/src/lib/agentic/execution/send.ts @@ -17,8 +17,8 @@ import { } from '../../orchestrator/internals/helpers'; import { AgenticCapability, requireCapability } from '../capabilities'; import type { AgenticExecutionContext } from '../context'; -import type { Call, UniversalConfigRead } from '../contracts/v5'; -import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import type { Call, UniversalConfigRead } from '../contracts/v6'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v6'; import { actionId, configId } from '../codec/ids'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; import { @@ -252,7 +252,7 @@ export async function agenticSend( }); outboundChain = out.chain; meta = { route: 'UOA_TO_CEA', destinationAccount: out.destinationAccount }; - const [walletPc, tokenBalance, allowance] = await Promise.all([ + const [walletPc, tokenBalance] = await Promise.all([ runtime.reader.getBalance({ address: wallet, blockNumber: snap.blockNumber, @@ -265,12 +265,6 @@ export async function agenticSend( [wallet] ) : Promise.resolve(BigInt(0)), - snap.read( - out.token, - ERC20_EVM as unknown as readonly unknown[], - 'allowance', - [wallet, gen.addresses.gateway] - ), ]); if (walletPc < out.value) { throw new AgenticError( @@ -285,17 +279,6 @@ export async function agenticSend( `wallet ${wallet} holds ${tokenBalance} of ${out.token}; this outbound burns ${out.amount}` ); } - // Both doors only consume an allowance the owner set separately; the SDK - // never writes one (a write from a stale read could restore revoked authority). - if (out.amount > allowance) { - throw new AgenticError( - AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, - `the gateway may pull ${allowance} of ${out.token} from the wallet; this outbound needs ${out.amount}`, - { - hint: 'Set a bounded allowance first with an owner-door send: approve(gateway, cap) on the token (approve(gateway, 0) removes it).', - } - ); - } if (actx.door === 'agent') { if (expectedCEA && expectedCEA !== out.destinationAccount) { throw new AgenticError( diff --git a/packages/core/src/lib/agentic/execution/svm-outbound.ts b/packages/core/src/lib/agentic/execution/svm-outbound.ts index 390f3320f..822e8f1a9 100644 --- a/packages/core/src/lib/agentic/execution/svm-outbound.ts +++ b/packages/core/src/lib/agentic/execution/svm-outbound.ts @@ -2,7 +2,7 @@ import { encodeFunctionData, getAddress, type Address, type Hex } from 'viem'; import { UNIVERSAL_GATEWAY_PC } from '../../constants/abi'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; -import type { Call, SvmConfigRead } from '../contracts/v5'; +import type { Call, SvmConfigRead } from '../contracts/v6'; import { UINT256_MAX } from '../codec/defaults'; import { UINT64_MAX } from '../codec/svm-terms'; import { validateAgwSvmPayload } from './svm-payload'; diff --git a/packages/core/src/lib/agentic/execution/svm-payload.ts b/packages/core/src/lib/agentic/execution/svm-payload.ts index da76d7ee4..c7f5208b8 100644 --- a/packages/core/src/lib/agentic/execution/svm-payload.ts +++ b/packages/core/src/lib/agentic/execution/svm-payload.ts @@ -101,7 +101,8 @@ export function validateAgwSvmPayload( ); if (rule < 0) fail('ProgramNotAllowed'); const fixed = terms.programs[rule].maxAccounts; - if (fixed && fixed !== v.accounts.length) fail('SvmAccountCountMismatch'); + if (fixed !== 255 && fixed !== v.accounts.length) + fail('SvmAccountCountMismatch'); const pins = terms.pins.filter((p) => p.ruleIndex === rule); for (const p of pins) { if (p.accountIndex >= v.accounts.length) fail('SvmAccountCountBelowPin'); diff --git a/packages/core/src/lib/agentic/execution/svm-public.ts b/packages/core/src/lib/agentic/execution/svm-public.ts index 00c9b3479..d8c3e5755 100644 --- a/packages/core/src/lib/agentic/execution/svm-public.ts +++ b/packages/core/src/lib/agentic/execution/svm-public.ts @@ -156,16 +156,12 @@ export async function sendPublicSvm( destinationTransfer = { recipient: target, token, amount }; transferValue = quote.isNative ? amount : BigInt(0); } - const [pc, balance, allowance] = await Promise.all([ + const [pc, balance] = await Promise.all([ runtime.reader.getBalance({ address: wallet, blockNumber: snap.blockNumber, }), snap.read(token, erc20Abi, 'balanceOf', [wallet]), - snap.read(token, erc20Abi, 'allowance', [ - wallet, - gen.addresses.gateway, - ]), ]); const value = quote.protocolFee + quote.nativeValueForGas; if (pc < value || balance < amount) @@ -173,11 +169,6 @@ export async function sendPublicSvm( AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, 'wallet cannot cover Solana outbound' ); - if (allowance < amount) - throw new AgenticError( - AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, - 'set the wallet gateway allowance separately' - ); const call = buildAgwSvmAction({ wallet, gateway: gen.addresses.gateway, diff --git a/packages/core/src/lib/agentic/execution/svm-send.ts b/packages/core/src/lib/agentic/execution/svm-send.ts index b28d80d78..68b4be573 100644 --- a/packages/core/src/lib/agentic/execution/svm-send.ts +++ b/packages/core/src/lib/agentic/execution/svm-send.ts @@ -123,17 +123,6 @@ export async function prepareSvmAgentExecution( AGENTIC_ERROR_CODE.WALLET_BALANCE_INSUFFICIENT, 'wallet token balance is below the SVM amount' ); - const allowance = await snap.read( - input.token, - erc20Abi, - 'allowance', - [input.wallet, gen.addresses.gateway] - ); - if (allowance < input.amount) - throw new AgenticError( - AGENTIC_ERROR_CODE.GATEWAY_ALLOWANCE_INSUFFICIENT, - 'owner must establish the SVM gateway allowance separately' - ); } const signerPc = await runtime.signerBalance(); const gasReserve = (await runtime.getGasPrice()) * BigInt(10_000_000); diff --git a/packages/core/src/lib/agentic/management/common.ts b/packages/core/src/lib/agentic/management/common.ts index 5a67bd01a..84271b3c6 100644 --- a/packages/core/src/lib/agentic/management/common.ts +++ b/packages/core/src/lib/agentic/management/common.ts @@ -1,5 +1,8 @@ import { getAddress, type Address, type Hex, type Log } from 'viem'; -import type { MultiCall, UniversalTxResponse } from '../../orchestrator/orchestrator.types'; +import type { + MultiCall, + UniversalTxResponse, +} from '../../orchestrator/orchestrator.types'; import { PushChainBatchExecutionError, PushChainExecutionError, @@ -8,7 +11,11 @@ import { import PROGRESS_HOOKS from '../../progress-hook/progress-hook'; import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; import type { AgenticGeneration } from '../deployments'; -import { AGENTIC_ERROR_CODE, AgenticError, AgenticRevertError } from '../errors'; +import { + AGENTIC_ERROR_CODE, + AgenticError, + AgenticRevertError, +} from '../errors'; import type { AgenticRuntime } from '../runtime'; import type { AgenticProgressHook } from '../agentic.types'; import { Snapshot } from '../reads/snapshot'; @@ -23,7 +30,10 @@ export function emitAgentic( runtime.emit(PROGRESS_HOOKS[id](...args), perCall); } -export function assertCanSign(runtime: AgenticRuntime, operation: string): void { +export function assertCanSign( + runtime: AgenticRuntime, + operation: string +): void { if (runtime.isReadOnly) { throw new AgenticError( AGENTIC_ERROR_CODE.READ_ONLY, @@ -39,13 +49,17 @@ export async function assertOwner( wallet: Address, snap: Snapshot ): Promise
{ - const owner = getAddress(await snap.read
(wallet, gen.contracts.abis.wallet, 'owner')); + const owner = getAddress( + await snap.read
(wallet, gen.contracts.abis.wallet, 'owner') + ); const signer = runtime.signerPushAccount(); if (owner !== signer) { throw new AgenticError( AGENTIC_ERROR_CODE.NOT_WALLET_OWNER, `${signer} is not the owner of ${wallet} (owner is ${owner})`, - { hint: 'Rule and label writes are owner-only. Initialize with the owner signer.' } + { + hint: 'Rule and label writes are owner-only. Initialize with the owner signer.', + } ); } return owner; @@ -62,12 +76,19 @@ export async function sendOwnerCalls( calls: MultiCall[], progressHook: AgenticProgressHook | undefined ): Promise { - if (calls.length === 0) throw new Error('sendOwnerCalls needs at least one call'); + if (calls.length === 0) + throw new Error('sendOwnerCalls needs at least one call'); const options = progressHook ? { progressHook } : undefined; if (calls.length === 1) { - return runtime.execute({ to: calls[0].to, value: calls[0].value, data: calls[0].data }, options); + return runtime.execute( + { to: calls[0].to, value: calls[0].value, data: calls[0].data }, + options + ); } - return runtime.execute({ to: calls[0].to, value: BigInt(0), data: calls }, options); + return runtime.execute( + { to: calls[0].to, value: BigInt(0), data: calls }, + options + ); } /** @@ -83,7 +104,9 @@ export async function confirmedLogs( const unavailable = (cause: unknown) => new AgenticError( AGENTIC_ERROR_CODE.RECEIPT_UNAVAILABLE, - `submitted ${tx.hash} but could not read its receipt: ${cause instanceof Error ? cause.message : String(cause)}`, + `submitted ${tx.hash} but could not read its receipt: ${ + cause instanceof Error ? cause.message : String(cause) + }`, { hint: 'The transaction may still land. Check these hashes before retrying.', details: { txHash: tx.hash, transactionHashes: hashes }, @@ -99,7 +122,7 @@ export async function confirmedLogs( if (hashes.length === 1) { if (receipt.status !== 1) { throw new AgenticRevertError(`Push transaction ${tx.hash} reverted`, { - transactionHashes: hashes, + cause: { txHash: tx.hash }, }); } return { logs: receipt.logs as Log[], hashes }; @@ -113,7 +136,9 @@ export async function confirmedLogs( throw unavailable(cause); } if (r.status !== 'success') { - throw new AgenticRevertError(`Push transaction ${hash} reverted`, { transactionHashes: hashes }); + throw new AgenticRevertError(`Push transaction ${hash} reverted`, { + cause: { txHash: hash }, + }); } logs.push(...(r.logs as Log[])); } @@ -122,18 +147,21 @@ export async function confirmedLogs( /** Normalize a send failure: keep batch hashes and decoded revert data. */ export function wrapSendError(err: unknown): unknown { - if (err instanceof AgenticError || err instanceof AgenticRevertError) return err; + if (err instanceof AgenticError || err instanceof AgenticRevertError) + return err; const decoded = decodeAgenticRevert(err); if (err instanceof PushChainBatchExecutionError) { return new AgenticRevertError(err.message, { decodedError: decoded ?? err.decodedError, - transactionHashes: err.transactionHashes, - pendingTransactionHash: err.pendingTransactionHash, cause: err, }); } if (decoded || err instanceof PushChainExecutionError) { - const e = err as { message?: string; gatewayTxHash?: string; decodedError?: DecodedErrorPayload }; + const e = err as { + message?: string; + gatewayTxHash?: string; + decodedError?: DecodedErrorPayload; + }; return new AgenticRevertError(e?.message ?? decoded?.name ?? 'AGW revert', { decodedError: decoded ?? e?.decodedError, gatewayTxHash: e?.gatewayTxHash, diff --git a/packages/core/src/lib/agentic/management/create.ts b/packages/core/src/lib/agentic/management/create.ts index a021a99a2..fbeb576f8 100644 --- a/packages/core/src/lib/agentic/management/create.ts +++ b/packages/core/src/lib/agentic/management/create.ts @@ -1,8 +1,7 @@ import { assertNoDuplicateRules } from './duplicates'; -import { getAddress, type Address, type Hex, type Log } from 'viem'; +import { getAddress, type Address, type Hex } from 'viem'; import { PROGRESS_HOOK } from '../../progress-hook/progress-hook.types'; import type { MultiCall } from '../../orchestrator/orchestrator.types'; -import { PushChainBatchExecutionError } from '../../orchestrator/internals/errors'; import { AgenticCapability, requireCapability } from '../capabilities'; import { currentGeneration, verifyFactoryWiring } from '../deployments'; import { deriveWallet, rulesId as computeRulesId } from '../codec/ids'; @@ -11,8 +10,8 @@ import { assertLabel } from './label'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; import type { AgenticRuntime } from '../runtime'; import type { InternalCreateOptions, CreateResult } from '../agentic.types'; -import { Snapshot, scanLogs } from '../reads/snapshot'; -import { walletCount, walletInfo } from '../reads/wallets'; +import { Snapshot } from '../reads/snapshot'; +import { walletCount } from '../reads/wallets'; import { assertCanSign, confirmedLogs, @@ -21,19 +20,7 @@ import { wrapSendError, } from './common'; -/** - * client.agentic.create(label, { rules }): deploy + grants only. Funding and - * approvals are separate ordinary sends. Every input is validated and every - * prediction re-read immediately before signing; IDs are returned from - * receipts in input order. - * - * Strategy follows the signer: an external owner's UEA batches atomically, a - * Push EOA uses an EIP-7702 batch when available and otherwise the documented - * sequential fallback. A sequential failure raises CREATE_PARTIAL with the - * deployed wallet, confirmed/pending hashes and granted IDs so the caller can - * finish with `wallet(w).rules.add(remaining)` — the SDK never deploys another - * wallet on its own. - */ +/** One index-bound v6 factory call deploys and grants atomically for EOA/UEA owners. */ export async function createWallet( runtime: AgenticRuntime, label: string, @@ -110,40 +97,30 @@ export async function createWallet( grantNonce: BigInt(i), }) ); + if (prepared.length > 8) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'create accepts at most eight initial rules' + ); const calls: MultiCall[] = [ - // Index-bound: reverts (nothing committed) if another creation took this slot. { to: gen.addresses.factory, value: BigInt(0), - data: gen.contracts.encodeDeployWalletAt(owner, index, wallet, label), + data: gen.contracts.encodeCreateWalletAt( + owner, + index, + wallet, + label, + prepared.map((p) => p.session) + ), }, - ...prepared.map((p) => ({ - to: wallet, - value: BigInt(0), - data: gen.contracts.encodeGrantRules(p.session), - })), ]; - const strategy = - calls.length === 1 - ? 'single' - : runtime.signerIsPushNative() - ? 'push-eoa-batch' - : 'uea-multicall'; - emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_102, wallet, strategy); - + emitAgentic(runtime, hook, PROGRESS_HOOK.AGENTIC_TX_102, wallet, 'single'); let tx; try { tx = await sendOwnerCalls(runtime, calls, hook); } catch (err) { - const wrapped = await createFailure( - runtime, - gen, - wallet, - Number(index), - owner, - label, - err - ); + const wrapped = atomicCreateFailure(err); emitFailure(runtime, hook, wrapped); throw wrapped; } @@ -152,15 +129,7 @@ export async function createWallet( try { ({ logs } = await confirmedLogs(runtime, tx)); } catch (err) { - const wrapped = await createFailure( - runtime, - gen, - wallet, - Number(index), - owner, - label, - err - ); + const wrapped = atomicCreateFailure(err); emitFailure(runtime, hook, wrapped); throw wrapped; } @@ -177,13 +146,13 @@ export async function createWallet( deployedEv[0].index !== index ) { const err = new AgenticError( - AGENTIC_ERROR_CODE.INDEX_RACE, - `deployWallet produced ${ + AGENTIC_ERROR_CODE.RECEIPT_MISMATCH, + `createWallet produced ${ deployedEv.map((d) => `${d.wallet}#${d.index}`).join(', ') || 'no wallet' }, expected ${wallet}#${index}; grants targeted ${wallet}`, { - hint: 'Another deployment by this owner raced this call. Inspect both wallets with client.agentic.list() before retrying.', + hint: 'The receipt does not describe the requested atomic creation. Inspect the submitted hash before retrying.', details: { expected: wallet, deployed: deployedEv, @@ -228,163 +197,26 @@ function sameIds(a: readonly Hex[], b: readonly Hex[]): boolean { ); } -const RACE_ERRORS = new Set(['IndexMismatch', 'IntentWalletMismatch']); - -/** - * Turn a create failure into an exact report. What committed is derived from - * THIS operation's own receipts, never from a later state read; when those - * receipts cannot be fetched the outcome is reported as unknown so callers do - * not retry a creation that may have landed. - */ -async function createFailure( - runtime: AgenticRuntime, - gen: ReturnType, - wallet: Address, - index: number, - owner: Address, - label: string, - err: unknown -): Promise { - const wrapped = wrapSendError(err); - const batch = err instanceof PushChainBatchExecutionError ? err : undefined; - const confirmed = (batch?.transactionHashes ?? []) as Hex[]; - const pending = batch?.pendingTransactionHash; - const receiptUnavailable = - wrapped instanceof AgenticError && - wrapped.code === AGENTIC_ERROR_CODE.RECEIPT_UNAVAILABLE; - - // The index-bound deploy reverted: another creation took the slot first and - // nothing from this call committed. - const decodedName = (wrapped as { decodedError?: { name?: string } }) - .decodedError?.name; +function atomicCreateFailure(error: unknown): unknown { + const wrapped = wrapSendError(error) as { decodedError?: { name?: string } }; if ( - confirmed.length === 0 && - !pending && - decodedName && - RACE_ERRORS.has(decodedName) - ) { + ['IndexMismatch', 'IntentWalletMismatch'].includes( + wrapped?.decodedError?.name ?? '' + ) + ) return new AgenticError( AGENTIC_ERROR_CODE.INDEX_RACE, - `wallet slot ${index} (${wallet}) was taken by a concurrent creation; nothing was committed`, + 'the predicted wallet slot changed; atomic creation committed nothing', { - hint: 'Retry agentic.create; it re-reads the next slot.', - details: { wallet, index, committed: false }, cause: wrapped, + details: { + committed: false, + walletDeployed: false, + grantedRulesIds: [], + }, } ); - } - if (confirmed.length === 0 && !pending && !receiptUnavailable) { - // Some send paths flatten the factory revert to a generic execution - // error, losing the IndexMismatch selector. If the owner's next index has - // moved and immutable deployment metadata proves another label owns the - // predicted slot, this call could not have deployed or granted. - try { - const snap = await Snapshot.at(runtime.reader); - const next = await walletCount(snap, gen, owner); - if (next > BigInt(index)) { - const competing = await walletInfo(runtime.reader, gen, wallet); - // label() is mutable in v5: only the immutable deploy event proves - // which creation occupied this slot. A later rename proves nothing. - const logs = await scanLogs( - runtime.reader, - { - address: gen.addresses.factory, - event: gen.contracts.events.walletDeployed, - args: { wallet }, - }, - gen.startBlock, - snap.blockNumber - ); - const deployed = gen.contracts - .parseWalletDeployed(logs, gen.addresses.factory) - .find((entry) => entry.wallet === wallet); - const competingLabel = deployed?.label; - if ( - competing.deployed && - competing.index === index && - getAddress(competing.owner) === owner && - competingLabel !== undefined && - competingLabel !== label - ) { - return new AgenticError( - AGENTIC_ERROR_CODE.INDEX_RACE, - 'wallet slot ' + index + ' was consumed by a concurrent deployment', - { - hint: - 'Slot ' + - index + - ' belongs to "' + - competingLabel + - '". No wallet or rules from this create call were committed; retry agentic.create to use the next slot.', - details: { - wallet, - index, - committed: false, - competingLabel, - }, - cause: wrapped, - } - ); - } - } - } catch { - // If the owner counter/event cannot be reconciled, preserve the original - // error; never infer that a retry is safe from an incomplete read. - } - // Nothing was confirmed (pre-broadcast failure or a reverted atomic batch). - return wrapped; - } - - let walletDeployed: boolean | 'unknown' = 'unknown'; - let grantedRulesIds: Hex[] | undefined; - let recoveryError: string | undefined; - if (!receiptUnavailable) { - try { - const logs: Log[] = []; - for (const hash of confirmed) { - const r = await runtime.reader.getTransactionReceipt({ hash }); - if (r.status === 'success') logs.push(...(r.logs as Log[])); - } - const deployed = gen.contracts - .parseWalletDeployed(logs, gen.addresses.factory) - .some((d) => d.wallet === wallet); - grantedRulesIds = gen.contracts - .parseRulesGranted(logs, wallet) - .map((g) => g.rulesId); - // A pending hash may itself be the deployment; only a confirmed deploy is certain. - walletDeployed = deployed ? true : pending ? 'unknown' : false; - } catch (e) { - recoveryError = e instanceof Error ? e.message : String(e); - } - } - const hint = - walletDeployed === true - ? `Wallet ${wallet} is deployed. Do not call create again; once any pending hash settles, finish with client.agentic.wallet('${wallet}').rules.add().` - : walletDeployed === false - ? 'No wallet was deployed by this call; it is safe to retry.' - : 'The outcome is unknown. Do not retry create until the listed hashes are reconciled (client.agentic.list() shows deployed wallets).'; - return new AgenticError( - AGENTIC_ERROR_CODE.CREATE_PARTIAL, - `agentic.create did not complete: ${ - (wrapped as Error)?.message ?? String(err) - }`, - { - hint, - details: { - wallet, - index, - walletDeployed, - grantedRulesIds, - confirmedHashes: confirmed, - pendingHash: pending, - ...(receiptUnavailable - ? { submitted: (wrapped as AgenticError).details } - : {}), - ...(recoveryError ? { recoveryError } : {}), - }, - cause: wrapped, - } - ); + return wrapped; } function emitFailure( diff --git a/packages/core/src/lib/agentic/management/svm.ts b/packages/core/src/lib/agentic/management/svm.ts index 14aa58ab7..d2f6b2233 100644 --- a/packages/core/src/lib/agentic/management/svm.ts +++ b/packages/core/src/lib/agentic/management/svm.ts @@ -17,7 +17,7 @@ import { import { encodeSvmTerms, type SvmTermsWire } from '../codec/svm-terms'; import { buildSession, encodeEnvelope } from '../codec/session'; import { rulesId as predictRulesId } from '../codec/ids'; -import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v6'; import { deriveAgwSvmCea } from '../codec/svm-accounts'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; import { PRC20_SOURCE_ABI } from '../contracts/prc20-metadata'; diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index 60f5a842b..826286799 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -153,7 +153,7 @@ export function buildPresetApi( } const rules: PresetRuleTemplate[] = chains.map((chain) => { const calls = chain.allowedCalls.map(copyCall); - const approvals = new Map(); + const approvals = new Set(); for (const asset of assets.get(chain.chainNamespace) ?? []) { const token = tokenAddress(asset.token); if ( @@ -170,14 +170,9 @@ export function buildPresetApi( ); const spender = getAddress(approval.spender); const target = getAddress(token); - const previous = approvals.get(target); - if (previous && previous !== spender) - throw capabilityUnavailable( - 'presetApproval', - 'multiple spenders for one token need a reviewed policy representation; no unrestricted approval is generated' - ); - if (previous) continue; - approvals.set(target, spender); + const approvalKey = `${target}:${spender}`; + if (approvals.has(approvalKey)) continue; + approvals.add(approvalKey); calls.push({ target, selector: 'approve(address,uint256)', @@ -195,7 +190,7 @@ export function buildPresetApi( 'presetCalls', 'preset must contain 1..32 allowed calls including approvals' ); - const pairs = new Set(); + const pairs = new Map(); for (const call of calls) { if (!/^[A-Za-z_$][\w$]*\(.*\)$/.test(call.selector)) throw invalid('preset selectors must be full signatures'); @@ -207,12 +202,17 @@ export function buildPresetApi( ? call.target.toLowerCase() : call.target }:${selector}`; - if (pairs.has(pair)) + if (pairs.has(pair) && !chain.chainNamespace.startsWith('eip155:')) + throw capabilityUnavailable( + 'presetCalls', + 'Solana does not support alternative entries for one program/instruction' + ); + if (pairs.has(pair) && (!call.pins?.length || !pairs.get(pair))) throw capabilityUnavailable( 'presetCalls', - 'duplicate target/function entries need a reviewed policy representation' + 'an unpinned alternative would bypass restrictions for this target/function' ); - pairs.add(pair); + pairs.set(pair, !!call.pins?.length); } return { chainNamespace: chain.chainNamespace, diff --git a/packages/core/src/lib/agentic/public-rule.ts b/packages/core/src/lib/agentic/public-rule.ts index bf293415b..0f8319359 100644 --- a/packages/core/src/lib/agentic/public-rule.ts +++ b/packages/core/src/lib/agentic/public-rule.ts @@ -1,4 +1,9 @@ -/** Public section-6 rule normalization into the deployed v5 wire model. */ +import { + UNLIMITED_CALLS, + ANY_ACCOUNT_COUNT, + type ArgPinWire, +} from './codec/pins'; +/** Public section-6 rule normalization into the deployed v6 wire model. */ import type { Idl } from '@coral-xyz/anchor'; import { PublicKey } from '@solana/web3.js'; import { @@ -180,75 +185,54 @@ export async function normalizePublicRule( if (native && r.allowedCalls.length > 8) throw capabilityUnavailable( 'nativeActions', - 'the deployed v5 wallet permits at most eight native actions' + 'the deployed v6 wallet permits at most eight native actions' ); if (native && (r.assets !== undefined || r.maxGasPerCall !== undefined)) throw invalid('native rules do not carry assets or maxGasPerCall'); const own = native ? wallet - : (await runtime.resolveCEA(wallet, r.chainNamespace as CHAIN)).cea; + : getAddress( + (await runtime.resolveCEA(wallet, r.chainNamespace as CHAIN)).cea + ); const nativeActions: NativeAction[] = []; const calls: UniversalRule['allowedCalls'] = []; for (const call of r.allowedCalls) { const pins = call.pins ?? []; - let amount: NativeAction['amount']; - const eq: NonNullable = []; - let beneficiaryOffset: number | undefined; + const wirePins: ArgPinWire[] = []; const occupied = new Set(); for (const pin of pins) { if ('account' in pin) throw invalid('account pins only apply to Solana'); const offset = evmPinOffset(call.selector, pin.arg); if (occupied.has(offset)) throw invalid('duplicate pin argument'); occupied.add(offset); - if (!native) { - if ( - !('expected' in pin) || - pin.expected !== WALLET || - beneficiaryOffset !== undefined - ) - throw capabilityUnavailable( - 'evmDestinationPins', - 'general or multiple EVM destination pins require contract change 6.3; v5 supports one WALLET pin per call' - ); - beneficiaryOffset = offset; - } else if ('max' in pin) { - if (amount) - throw capabilityUnavailable( - 'nativeMaxPins', - 'v5 supports one max pin per native call' - ); - amount = { - offset, - maxPerCall: uint(pin.max, 'pin.max'), - maxTotal: UINT256_MAX, - }; - } else { - eq.push({ - offset, - expected: expectedWord( - call.selector, - pin.arg, - pin.expected === WALLET ? own : pin.expected - ), - }); - } + wirePins.push({ + offset, + op: 'max' in pin ? 1 : 0, + value: + 'max' in pin + ? numberToHex(uint(pin.max, 'pin.max'), { size: 32 }) + : expectedWord( + call.selector, + pin.arg, + pin.expected === WALLET ? own : pin.expected + ), + }); } if (native) nativeActions.push({ target: getAddress(call.target), selector: call.selector, - pins: eq, - amount, + wirePins, maxValuePerCall: call.maxValue ?? BigInt(0), maxValueTotal: UINT256_MAX, - maxCalls: 0, + maxCalls: UNLIMITED_CALLS, }); else calls.push({ target: getAddress(call.target), selector: call.selector as UniversalRule['allowedCalls'][number]['selector'], - beneficiaryOffset, + pins: wirePins, maxValue: call.maxValue ?? BigInt(0), }); } @@ -375,7 +359,7 @@ function svmRule(rule: Rule, cea: Hex): SolanaRule { ], }; return { - maxAccounts: 0, + maxAccounts: ANY_ACCOUNT_COUNT, program: call.target, instruction: { idl, name }, accounts: accounts.map((p) => ({ diff --git a/packages/core/src/lib/agentic/reads/rules.ts b/packages/core/src/lib/agentic/reads/rules.ts index 44298144a..cceddd319 100644 --- a/packages/core/src/lib/agentic/reads/rules.ts +++ b/packages/core/src/lib/agentic/reads/rules.ts @@ -8,7 +8,7 @@ import type { NativeConfigRead, ModeRead, UniversalConfigRead, -} from '../contracts/v5'; +} from '../contracts/v6'; import { configId } from '../codec/ids'; import { nativeActionTermsToRule } from '../codec/native'; import { UINT256_MAX } from '../codec/defaults'; @@ -287,9 +287,26 @@ export async function decodeActiveRule( target: c.target, selector: c.selector, maxValue: c.maxValue, - pins: c.hasBeneficiary - ? [{ arg: storedArg(c.beneficiaryOffset), expected: WALLET }] - : [], + pins: [ + ...c.pins.map((p) => + p.op === 1 + ? { arg: storedArg(p.offset), max: BigInt(p.value) } + : { + arg: storedArg(p.offset), + expected: + p.value.toLowerCase() === + `0x${cfg.expectedCEA + .slice(2) + .toLowerCase() + .padStart(64, '0')}` + ? WALLET + : p.value, + } + ), + ...(c.hasBeneficiary + ? [{ arg: storedArg(c.beneficiaryOffset), expected: WALLET }] + : []), + ], })), }, }; @@ -322,7 +339,7 @@ export async function decodeActiveRule( configs.some( (c) => c.maxValueTotal !== UINT256_MAX || - Number(c.maxCalls) !== 0 || + Number(c.maxCalls) !== 0xffffffff || (c.amount.enabled && c.amount.maxTotal !== UINT256_MAX) || Number(c.validUntil) !== decoded.validUntil ) @@ -338,14 +355,18 @@ export async function decodeActiveRule( c.selector === '0xffffffff' ? ('value-only' as const) : c.selector, maxValue: c.maxValuePerCall, pins: [ - ...c.pins.map((p) => ({ - arg: storedArg(p.offset), - expected: - p.expected.toLowerCase() === - `0x${wallet.slice(2).toLowerCase().padStart(64, '0')}` - ? WALLET - : p.expected, - })), + ...c.pins.map((p) => + p.op === 1 + ? { arg: storedArg(p.offset), max: BigInt(p.value) } + : { + arg: storedArg(p.offset), + expected: + p.value.toLowerCase() === + `0x${wallet.slice(2).toLowerCase().padStart(64, '0')}` + ? WALLET + : p.value, + } + ), ...(c.amount.enabled ? [{ arg: storedArg(c.amount.offset), max: c.amount.maxPerCall }] : []), diff --git a/packages/core/src/lib/agentic/reads/svm.ts b/packages/core/src/lib/agentic/reads/svm.ts index 56767cff0..42c9535bd 100644 --- a/packages/core/src/lib/agentic/reads/svm.ts +++ b/packages/core/src/lib/agentic/reads/svm.ts @@ -1,10 +1,10 @@ /** Internal lossless SVM state shared by public reads and execution. */ import type { Address, Hex } from 'viem'; import type { AgenticGeneration } from '../deployments'; -import type { SvmConfigRead } from '../contracts/v5'; +import type { SvmConfigRead } from '../contracts/v6'; import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; import { actionId, configId } from '../codec/ids'; -import { SEND_OUTBOUND_SELECTOR } from '../contracts/v5'; +import { SEND_OUTBOUND_SELECTOR } from '../contracts/v6'; import { validateSvmTerms } from '../codec/svm-terms'; import { chainFromHash, readActiveRules, type ActiveRule } from './rules'; import type { Snapshot } from './snapshot'; diff --git a/packages/core/src/lib/agentic/response.ts b/packages/core/src/lib/agentic/response.ts index 111fdf285..c9b2db192 100644 --- a/packages/core/src/lib/agentic/response.ts +++ b/packages/core/src/lib/agentic/response.ts @@ -22,11 +22,11 @@ import type { UniversalTxResponse, } from '../orchestrator/orchestrator.types'; import { - v5, + v6, SEND_OUTBOUND_SELECTOR, UEA_MULTICALL_PREFIX, type Call, -} from './contracts/v5'; +} from './contracts/v6'; import { generationsFor, resolveWalletGeneration } from './deployments'; import type { AgenticRuntime } from './runtime'; import type { @@ -115,7 +115,7 @@ export async function adaptTrackedResponse( if (resp.agentic || generationsFor(runtime.network).length === 0) return resp; const batch = await adaptNativeAgentBatch(runtime, resp); if (batch) return batch; - const decoded = v5.decodeWalletCall(resp.data as Hex); + const decoded = v6.decodeWalletCall(resp.data as Hex); if ( !decoded || (decoded.kind !== 'execute' && decoded.kind !== 'executeAsAgent') @@ -276,7 +276,7 @@ async function adaptNativeAgentBatch( data: c.data, })); } else { - const batch = v5.decodeWalletCall(resp.data as Hex); + const batch = v6.decodeWalletCall(resp.data as Hex); if (batch?.kind !== 'execute' || batch.mode !== `0x01${'00'.repeat(31)}`) return; outer = batch.calls; @@ -286,7 +286,7 @@ async function adaptNativeAgentBatch( outer.some((c) => c.value !== BigInt(0) || c.target !== outer[0].target) ) return; - const inner = outer.map((c) => v5.decodeWalletCall(c.data)); + const inner = outer.map((c) => v6.decodeWalletCall(c.data)); const first = inner[0]; if (first?.kind !== 'executeAsAgent' || first.calls.length !== 1) return; const nativeCalls: { to: Address; value: bigint; data: Hex }[] = []; diff --git a/packages/core/src/lib/agentic/revert.ts b/packages/core/src/lib/agentic/revert.ts index a6a07daae..0c4430ff2 100644 --- a/packages/core/src/lib/agentic/revert.ts +++ b/packages/core/src/lib/agentic/revert.ts @@ -11,7 +11,7 @@ import { AGW_FACTORY_ABI, SMART_SESSION_ABI, UNIVERSAL_RULES_POLICY_ABI, -} from './contracts/abi/v5'; +} from './contracts/abi/v6'; /** URP reverts reached through the engine are truncated to this wrapper. */ export const POLICY_CHECK_REVERTED_SELECTOR = '0xf4270752'; diff --git a/packages/core/src/lib/agentic/utils.ts b/packages/core/src/lib/agentic/utils.ts index e2dbcfd9d..bbe20b41a 100644 --- a/packages/core/src/lib/agentic/utils.ts +++ b/packages/core/src/lib/agentic/utils.ts @@ -5,7 +5,7 @@ import { CHAIN, PUSH_NETWORK } from '../constants/enums'; import { MOVEABLE_TOKENS } from '../constants/tokens'; import { getPRC20Address } from '../universal/prc20-address'; import { AgenticCapability } from './capabilities'; -import { V5_CAPABILITIES } from './deployments'; +import { V6_CAPABILITIES } from './deployments'; import { actionId as actionIdRaw, configId as configIdRaw, @@ -116,7 +116,7 @@ export const internalAgenticUtils = { rulesPolicy: getAddress(ctx.rulesPolicy), gateway: ctx.gateway, universal: ctx.universal, - capabilities: V5_CAPABILITIES, + capabilities: V6_CAPABILITIES, nowSeconds: ctx.nowSeconds ?? Math.floor(Date.now() / 1000), }).map((p) => encodeSession(p.session) as AgenticHex); }, From 4147afb32e4611089cdee4d6b64da9930de2f10e Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Thu, 15 Oct 2026 07:07:11 +0530 Subject: [PATCH 05/27] feat(core): add verified testnet AGW preset templates --- packages/core/AGW.md | 34 ++- .../core/__agw-local__/v5-multiasset.spec.ts | 68 ++++++ .../agentic/__tests__/preset-catalog.spec.ts | 198 ++++++++++++++++++ .../src/lib/agentic/__tests__/presets.spec.ts | 38 ++-- .../lib/agentic/__tests__/public-rule.spec.ts | 7 +- .../core/src/lib/agentic/agentic.types.ts | 6 +- .../core/src/lib/agentic/codec/abi-layout.ts | 93 +++++++- packages/core/src/lib/agentic/constants.ts | 1 + packages/core/src/lib/agentic/index.ts | 1 + .../core/src/lib/agentic/presets/catalog.ts | 166 ++++++++++++++- .../core/src/lib/agentic/presets/index.ts | 92 +++++++- .../core/src/lib/agentic/presets/types.ts | 4 +- packages/core/src/lib/agentic/public-rule.ts | 31 +-- packages/core/src/lib/index.ts | 1 + 14 files changed, 671 insertions(+), 69 deletions(-) create mode 100644 packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 3a92f727d..cb3106d75 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -87,7 +87,7 @@ On deployed v6, native rules allow at most **eight** calls, eight equality/maxim ## Pins and zero defaults -An equality pin fixes an argument; a maximum pin bounds it. For EVM signatures, `arg` is a zero-based static argument position. With a raw four-byte selector, it is a 32-byte ABI head-word position. Reads return a raw selector and these word positions. Unsupported dynamic/compound layouts fail explicitly; no guessed tuple-field indexes are produced. +An equality pin fixes an argument; a maximum pin bounds it. For EVM signatures, `arg` is a zero-based static argument position. With a raw four-byte selector, it is a 32-byte ABI head-word position. Reads return a raw selector and these word positions. For full signatures, a path such as `arg: [0, 3]` selects a field within a static tuple or fixed array. Dynamic layouts fail explicitly. Reads retain exact word positions rather than guessing missing ABI metadata. `PushChain.CONSTANTS.AGENTIC.WALLET` resolves to the AGW on Push, its CEA on an EVM destination, or its CEA/selected token ATA on Solana. Deployment addresses and encoding helpers are internal. @@ -167,29 +167,41 @@ The wire encoder uses uint32 maximum for unlimited native calls and 255 for unsp ## Preset discovery and templates (section 7) -`PushChain.utils.agentic` contains only `presets.list/get`. The enumerable categories are `AGENTIC.PRESETS.CATEGORY.DEXES`, `LENDING` and `PREDICTION`. All three currently have empty `chains` lists because the app/address catalog is not approved yet; hide them in a UI. No production DEX/lending addresses ship in this framework. +`PushChain.utils.agentic` contains `presets.list/get`. The categories are `AGENTIC.PRESETS.CATEGORY.DEXES`, `LENDING`, `STAKING` and `PREDICTION`. The catalog ships testnet templates only: + +- DEXES: Uniswap V3 on Sepolia, Base Sepolia and Arbitrum Sepolia; PancakeSwap V3 on BSC testnet. +- LENDING: Aave on the three Ethereum testnets above, Moonwell on Base Sepolia and Venus on BSC testnet. +- STAKING: EigenLayer deposits on Sepolia; withdrawals are not included. +- PREDICTION: no entries yet. Hide categories with empty `chains` lists. ```ts const { presets } = PushChain.utils.agentic.presets.list({ type: 'CATEGORY' }); const availablePresets = presets.filter(preset => preset.chains.length > 0); -const { rules: reviewTemplates } = PushChain.utils.agentic.presets.get( - PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, -); -// Currently [], since there is no approved catalog. Never treat an empty result as an active preset. -function filledPreset(agent: Address) { +function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { return PushChain.utils.agentic.presets.get( PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, - { agent, validUntil: Math.floor(Date.now() / 1000) + 3600 }, + { + chainNamespace: 'eip155:11155111', + agent, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(18), + assets: [ + { token: tokenIn, maxPerCall: BigInt(100), maxTotal: BigInt(1000) }, + { token: tokenOut, maxPerCall: BigInt(100), maxTotal: BigInt(1000) }, + ], + }, ).rules; } ``` -With catalog entries, `get` groups calls into one rule per chain. With agent and expiry supplied, its return type is `Rule[]`; otherwise it is `PresetRuleTemplate[]`, requiring those fields before `create`. Assets must name their chain unless a single chain filter is supplied. Missing `maxPerCall` or `maxTotal` defaults to zero (user decision, October 12), so missing caps do not grant token movement. +The example caps are illustrative raw units; choose caps for the actual token decimals and gas requirements. `get` groups calls into one rule per chain. With agent and expiry supplied, its return type is `Rule[]`; otherwise it is `PresetRuleTemplate[]`, requiring those fields before `create`. Assets must name their chain unless a single chain filter is supplied. Omitted asset caps and `maxGasPerCall` default to zero. + +DEX templates require explicit ERC20 tokens for each selected chain. They expand ordered token pairs into exact-input and exact-output calls with token, wallet-recipient and input-amount pins. Missing tokens, native markers and expansions exceeding the policy's 32-pin limit are rejected. Two tokens fit the shipped router template; three do not. Static tuple fields use paths such as `[0, 3]`. -Reviewed EVM catalog spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Native assets need no approval. Distinct spenders for the same token become separate fully pinned alternatives under v6; unrestricted alternatives are rejected. Presets do not sign, fetch app ABIs or modify wallets. +Reviewed EVM spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Money-market approvals are restricted to the market's underlying token. Distinct spenders become separate pinned alternatives under v6. These destination approvals are ordinary ERC20 allowances; the wallet's automatic Push-side gateway approval is separate. -The remaining catalog work includes selecting verified apps/functions/chains, struct-field indexing and checking approval combinations against the delivered policy. App ABIs remain with the UI. +Templates describe permissions, not available liquidity or guaranteed execution. Verify destination token balances, supported reserves and source PRC20 routes before use: protocol testnet mock tokens may differ from MOVEABLE tokens. Presets do not sign, fetch app ABIs or modify wallets. No mainnet templates, dynamic-path swaps, Solana protocol templates or unconfirmed deployments ship in this catalog. Local policy tests do not execute the destination protocols. ## Validation diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 40659c47b..36a33b9bc 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -1,3 +1,4 @@ +import { agenticPresets } from '../src/lib/agentic/presets'; import { buildPresetApi } from '../src/lib/agentic/presets'; import { publicRules } from '../src/lib/agentic/public-rule'; /** Actual v6 AGW/URP/engine. Token/core/gateway and destination lookup are local fixtures; no bridge/TSS proof. */ @@ -228,6 +229,73 @@ describe('v6 multi-asset SDK management and execution', () => { expect(await owner.agentic.derive()).toEqual(before); }); + it('the shipped DEX catalog enforces tuple receiver/token/amount pins on actual v6 URP', async () => { + const { rules } = agenticPresets.get('DEXES', { + chainNamespace: CHAIN_NS, + agent: h.wallets[1].account!.address, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(16), + assets: [ + { token: a, maxPerCall: BigInt(5), maxTotal: BigInt(20) }, + { token: b, maxPerCall: BigInt(7), maxTotal: BigInt(20) }, + ], + }); + const { wallet, id, agent } = await setup(rules[0]); + const router = rules[0].allowedCalls[0].target as Address; + const abi = parseAbi([ + 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns (uint256)', + ]); + const send = (recipient = CEA, input = a, amount = BigInt(5)) => + agent.universal.sendTransaction({ + to: { address: router, chain: CHAIN_NS }, + data: [ + { + to: router, + value: BigInt(0), + data: encodeFunctionData({ + abi, + functionName: 'exactInputSingle', + args: [ + { + tokenIn: input, + tokenOut: b, + fee: 3000, + recipient, + amountIn: amount, + amountOutMinimum: BigInt(0), + sqrtPriceLimitX96: BigInt(0), + }, + ], + }), + }, + ], + }); + await waitPush(send()); + await expect(send(DEST)).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect(send(CEA, DEST)).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect(send(CEA, a, BigInt(6))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + const handle = owner.agentic.wallet(wallet), + record = await handle.rules.get(id); + const changed = await handle.rules.update({ + rules: [ + { + rulesId: id, + rule: { ...record.rule, validUntil: record.validUntil + 60 }, + }, + ], + }); + expect((await handle.rules.get(changed.rules[0].rulesId)).rule).toEqual({ + ...record.rule, + validUntil: record.validUntil + 60, + }); + }); + it('a fixture preset produces rules accepted by public create and enforced by real contracts', async () => { const presets = buildPresetApi([ { diff --git a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts new file mode 100644 index 000000000..8e28e839e --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts @@ -0,0 +1,198 @@ +import { + encodeFunctionData, + parseAbi, + getAddress, + type Address, + type Hex, +} from 'viem'; +import { agenticPresets } from '../presets'; +import { AGENTIC } from '../constants'; +import { evmPinOffset, normalizePublicRule, publicRules } from '../public-rule'; +import { parseSelector } from '../codec/selectors'; +import type { Rule, UniversalRule } from '../agentic.types'; +import { FakeChain, ADDR } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; +const tokenA = ADDR.target, + tokenB = ADDR.other; +const chain = 'eip155:11155111'; +const input: import('../presets/types').ReadyPresetOptions = { + chainNamespace: chain, + agent: ADDR.agent, + validUntil: 2_000_000_000, + maxGasPerCall: BigInt(1), + assets: [ + { token: tokenA, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, + { token: tokenB, maxPerCall: BigInt(7), maxTotal: BigInt(14) }, + ], +}; +it('expands both token directions and exact-input/output with wallet and amount pins', () => { + const { rules } = agenticPresets.get('DEXES', input); + expect(rules).toHaveLength(1); + expect(rules[0].allowedCalls).toHaveLength(6); + const swaps = rules[0].allowedCalls.filter((c) => + c.selector.startsWith('exact') + ); + expect(swaps).toHaveLength(4); + expect(swaps[0].pins).toEqual([ + { arg: [0, 0], expected: tokenA }, + { arg: [0, 1], expected: tokenB }, + { arg: [0, 3], expected: AGENTIC.WALLET }, + { arg: [0, 4], max: BigInt(5) }, + ]); + expect(swaps[1].pins?.at(-1)).toEqual({ arg: [0, 5], max: BigInt(5) }); + expect(swaps[2].pins?.at(-1)).toEqual({ arg: [0, 4], max: BigInt(7) }); + expect( + rules[0].allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) + ).toBe(20); +}); +it('rejects native-token markers, omitted tokens and oversized pin expansion', () => { + expect(() => agenticPresets.get('DEXES', { ...input, assets: [] })).toThrow( + /two explicit/ + ); + expect(() => + agenticPresets.get('DEXES', { + ...input, + assets: [...input.assets!, { token: ADDR.owner }], + }) + ).toThrow(/32 pins/); + expect(() => + agenticPresets.get('DEXES', { + ...input, + assets: [ + { token: '0x0000000000000000000000000000000000000000' }, + input.assets![1], + ], + }) + ).toThrow(/ERC20/); +}); +it('defaults omitted caps to zero without introducing an unlimited approval', () => { + const r = agenticPresets.get('DEXES', { + ...input, + assets: [{ token: tokenA }, { token: tokenB }], + }).rules[0]; + expect( + r.assets?.every( + (a) => a.maxPerCall === BigInt(0) && a.maxTotal === BigInt(0) + ) + ).toBe(true); + expect( + r.allowedCalls.every((c) => c.pins?.some((p) => p.max === BigInt(0))) + ).toBe(true); +}); +it('scopes money-market approvals to their actual underlying token', () => { + const underlying = '0x4616975f7E6045FD9737e95bC527e9e14c727BD2' as Address; + const r = agenticPresets.get('LENDING', { + chainNamespace: 'eip155:84532', + assets: [{ token: underlying, maxPerCall: BigInt(2) }], + }).rules[0]; + const approvals = r.allowedCalls.filter( + (c) => c.selector === 'approve(address,uint256)' + ); + expect(approvals).toHaveLength(2); // Aave and mUSDbC, never the mWETH market. + expect(approvals.flatMap((c) => c.pins ?? [])).not.toContainEqual({ + arg: 0, + expected: getAddress('0x2F39a349A79492a70E152760ce7123A1933eCf28'), + }); +}); +it.each([ + 'eip155:11155111', + 'eip155:84532', + 'eip155:421614', + 'eip155:97', +] as const)( + 'normalizes every DEX call on %s to the exact ABI bytes', + async (chainNamespace) => { + const r = agenticPresets.get('DEXES', { ...input, chainNamespace }) + .rules[0]; + const rt = mockRuntime(new FakeChain()); + const out = (await normalizePublicRule( + publicRules([r])[0], + ADDR.owner, + rt + )) as UniversalRule; + const cea = getAddress( + (await rt.resolveCEA(ADDR.owner, chainNamespace as never)).cea + ); + for (const call of out.allowedCalls.filter((c) => + String(c.selector).startsWith('exact') + )) { + const pins = call.pins!; + const values = [ + `0x${pins[0].value.slice(-40)}`, + `0x${pins[1].value.slice(-40)}`, + 3000, + cea, + BigInt(1), + BigInt(1), + BigInt(0), + ] as const; + const data = encodeFunctionData({ + abi: parseAbi([`function ${call.selector}`] as string[]), + functionName: String(call.selector).split('(')[0], + args: [values], + }); + for (const pin of pins.filter((p) => p.op === 0)) + expect( + `0x${data.slice( + 2 + pin.offset * 2, + 2 + (pin.offset + 32) * 2 + )}`.toLowerCase() + ).toBe(pin.value.toLowerCase()); + expect(pins.map((p) => p.offset)).toEqual([ + 4, + 36, + 100, + String(call.selector).startsWith('exactInput') ? 132 : 164, + ]); + } + } +); +it('supports tuple/fixed-array positions but never descends into dynamic storage', () => { + const sig = + 'exchange(address[11],uint256[5][5],uint256,uint256,address[5],address)' as const; + expect(evmPinOffset(sig, 5)).toBe(4 + 43 * 32); + expect(evmPinOffset(sig, [0, 0])).toBe(4); + expect(evmPinOffset(sig, [1, 2, 3])).toBe(4 + 24 * 32); + expect(evmPinOffset('f((address,address,uint256))', [0, 1])).toBe(36); + expect(() => evmPinOffset('f((bytes,address))', [0, 1])).toThrow(/dynamic/); + expect(() => evmPinOffset('f(address[])', [0, 0])).toThrow(/dynamic/); + expect(() => evmPinOffset('f((address,address))', [0, 2])).toThrow(/range/); + expect(() => evmPinOffset('0x12345678', [0, 1])).toThrow(/scalar/); +}); +it('tuple-authored pins re-encode using the raw-selector word indexes returned by reads', async () => { + const r = agenticPresets.get('DEXES', input).rules[0]; + const rt = mockRuntime(new FakeChain()); + const first = (await normalizePublicRule( + publicRules([r])[0], + ADDR.owner, + rt + )) as UniversalRule; + const decoded: Rule = { + ...r, + allowedCalls: first.allowedCalls.map((c) => ({ + target: c.target, + selector: parseSelector(c.selector).selector, + maxValue: c.maxValue, + pins: c.pins?.map((p) => + p.op === 0 + ? { arg: (p.offset - 4) / 32, expected: p.value } + : { arg: (p.offset - 4) / 32, max: BigInt(p.value) } + ), + })), + }; + const second = (await normalizePublicRule( + publicRules([decoded])[0], + ADDR.owner, + rt + )) as UniversalRule; + expect(second.allowedCalls.map((c) => c.pins)).toEqual( + first.allowedCalls.map((c) => c.pins) + ); +}); +it('mutating a returned tuple path cannot change a later preset', () => { + const r = agenticPresets.get('DEXES', input).rules[0]; + (r.allowedCalls[0].pins![0].arg as number[])[1] = 99; + expect( + agenticPresets.get('DEXES', input).rules[0].allowedCalls[0].pins![0].arg + ).toEqual([0, 0]); +}); diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts index bf0a1dde8..da6f3e98d 100644 --- a/packages/core/src/lib/agentic/__tests__/presets.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -45,27 +45,37 @@ function catalog(): PresetDefinition[] { }, ]; } -it('exposes enumerable frozen categories with no invented production addresses', () => { +it('ships only verified testnet catalog entries and keeps prediction empty', () => { expect(Object.values(AGENTIC.PRESETS.CATEGORY)).toEqual([ 'DEXES', 'LENDING', + 'STAKING', 'PREDICTION', ]); - expect(Object.isFrozen(AGENTIC.PRESETS.CATEGORY)).toBe(true); - expect( - agenticPresets.list().presets.map((p) => [p.constant, p.chains]) - ).toEqual([ - ['DEXES', []], - ['LENDING', []], - ['PREDICTION', []], + const entries = agenticPresets.list().presets; + expect(entries.map((p) => [p.constant, p.chains.length])).toEqual([ + ['DEXES', 4], + ['LENDING', 4], + ['STAKING', 1], + ['PREDICTION', 0], ]); - expect(agenticPresets.get('DEXES')).toEqual({ rules: [] }); - expect(() => agenticPresets.get('DEXES', { chainNamespace: chain })).toThrow( - /no approved entry/ - ); + expect( + entries + .flatMap((p) => p.chains) + .every((c) => + [ + 'eip155:11155111', + 'eip155:84532', + 'eip155:421614', + 'eip155:97', + ].includes(c) + ) + ).toBe(true); + expect(agenticPresets.get('PREDICTION')).toEqual({ rules: [] }); + expect(() => agenticPresets.get('DEXES')).toThrow(/two explicit ERC20/); expect(() => - agenticPresets.get('DEXES', { assets: [{ chainNamespace: chain, token }] }) - ).toThrow(/no approved app entries/); + agenticPresets.get('LENDING', { chainNamespace: 'eip155:1' }) + ).toThrow(/no approved entry/); }); it('groups all apps on a chain into one editable template and omits unspecified fields', () => { const data = catalog(); diff --git a/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts b/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts index ddf1d5d94..684b62301 100644 --- a/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/public-rule.spec.ts @@ -105,16 +105,15 @@ it('supports general destination EQ/MAX pins', async () => { })) as UniversalRule; expect(out.allowedCalls[0].pins?.map((p) => p.op)).toEqual([0, 1]); }); -it('refuses layouts whose top-level argument indexes cannot be reconstructed', async () => { +it('computes top-level offsets after static arrays and preserves a raw-word round trip', async () => { const r = base(); r.allowedCalls[0] = { target: ADDR.target, selector: 'f(uint256[2],uint256)', pins: [{ arg: 1, max: BigInt(1) }], }; - await expect(normalize(r)).rejects.toMatchObject({ - code: 'CAPABILITY_UNAVAILABLE', - }); + const out = (await normalize(r)) as NativeMultiRule; + expect(out.actions[0].wirePins?.[0].offset).toBe(68); }); it('compiles numeric Solana arg/account pins without a caller IDL', async () => { const program = new PublicKey(Buffer.alloc(32, 3)).toBase58(); diff --git a/packages/core/src/lib/agentic/agentic.types.ts b/packages/core/src/lib/agentic/agentic.types.ts index 34acefad7..b8c5d465b 100644 --- a/packages/core/src/lib/agentic/agentic.types.ts +++ b/packages/core/src/lib/agentic/agentic.types.ts @@ -184,14 +184,16 @@ export interface InternalRulesRecord { /** Current SDK section 6 public rule shape; contract wire types above are internal. */ export type WalletMarker = 'agentic:wallet'; +/** Top-level ABI argument, or a path through static tuple/fixed-array fields. */ +export type ArgumentPath = number | readonly number[]; export type Pin = | { - arg: number; + arg: ArgumentPath; expected: AgenticHex | AgenticAddress | bigint | WalletMarker; max?: never; account?: never; } - | { arg: number; max: bigint; expected?: never; account?: never } + | { arg: ArgumentPath; max: bigint; expected?: never; account?: never } | { account: number; expected: string | WalletMarker; diff --git a/packages/core/src/lib/agentic/codec/abi-layout.ts b/packages/core/src/lib/agentic/codec/abi-layout.ts index 2e1d46290..430ae3b3a 100644 --- a/packages/core/src/lib/agentic/codec/abi-layout.ts +++ b/packages/core/src/lib/agentic/codec/abi-layout.ts @@ -24,7 +24,9 @@ function components(p: AbiParameter): readonly AbiParameter[] { } /** Parse a trailing fixed/dynamic array suffix: 'uint256[3]' → base 'uint256', length 3. */ -function arraySuffix(type: string): { base: string; length: number | null } | null { +function arraySuffix( + type: string +): { base: string; length: number | null } | null { const m = /^(.*)\[(\d*)\]$/.exec(type); if (!m) return null; return { base: m[1], length: m[2] === '' ? null : Number(m[2]) }; @@ -54,7 +56,8 @@ export function headSize(p: AbiParameter): number { return 32; } -const ELEMENTARY = /^(address|bool|u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?|bytes([1-9]|[12][0-9]|3[0-2]))$/; +const ELEMENTARY = + /^(address|bool|u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?|bytes([1-9]|[12][0-9]|3[0-2]))$/; export function isSingleWordStatic(p: AbiParameter): boolean { return ELEMENTARY.test(p.type); @@ -75,7 +78,9 @@ export function argumentOffset( ); } if (!Number.isInteger(arg) || arg < 0 || arg >= inputs.length) { - throw invalid(`${what} argument index ${arg} is out of range (0..${inputs.length - 1})`); + throw invalid( + `${what} argument index ${arg} is out of range (0..${inputs.length - 1})` + ); } const target = inputs[arg]; if (!isSingleWordStatic(target)) { @@ -86,7 +91,9 @@ export function argumentOffset( let offset = 4; for (let i = 0; i < arg; i++) offset += headSize(inputs[i]); if (offset + 32 > UINT16_MAX) { - throw invalid(`${what} argument ${arg} lies beyond the uint16 offset range`); + throw invalid( + `${what} argument ${arg} lies beyond the uint16 offset range` + ); } return offset; } @@ -98,19 +105,32 @@ export function encodeArgWord( what: string ): AgenticHex { try { - const isWord = typeof expected === 'string' && isHex(expected) && expected.length === 66; + const isWord = + typeof expected === 'string' && isHex(expected) && expected.length === 66; if (param.type === 'address' && !isWord) { - if (typeof expected !== 'string' || !isAddress(expected, { strict: false })) { + if ( + typeof expected !== 'string' || + !isAddress(expected, { strict: false }) + ) { throw new Error('expected an address'); } } else if (param.type.startsWith('bytes') && !isWord) { - if (typeof expected !== 'string' || !isHex(expected)) throw new Error('expected hex'); + if (typeof expected !== 'string' || !isHex(expected)) + throw new Error('expected hex'); } // An already-encoded 32-byte word (as decoded rules return) is accepted // when it is a canonical encoding of this argument type. - if (typeof expected === 'string' && isHex(expected) && expected.length === 66 && param.type !== 'bytes32') { + if ( + typeof expected === 'string' && + isHex(expected) && + expected.length === 66 && + param.type !== 'bytes32' + ) { const [value] = decodeAbiParameters([param], expected as AgenticHex); - if (encodeAbiParameters([param], [value]).toLowerCase() !== expected.toLowerCase()) { + if ( + encodeAbiParameters([param], [value]).toLowerCase() !== + expected.toLowerCase() + ) { throw new Error('word is not a canonical encoding'); } return expected.toLowerCase() as AgenticHex; @@ -129,7 +149,11 @@ export function encodeArgWord( /** Validate a raw calldata offset given directly (offset form). */ export function rawOffset(offset: number, what: string): number { if (!Number.isInteger(offset) || offset < 4 || offset + 32 > UINT16_MAX) { - throw invalid(`${what} offset ${offset} must be an integer in [4, ${UINT16_MAX - 32}] (selector included)`); + throw invalid( + `${what} offset ${offset} must be an integer in [4, ${ + UINT16_MAX - 32 + }] (selector included)` + ); } return offset; } @@ -137,3 +161,52 @@ export function rawOffset(offset: number, what: string): number { function invalid(message: string, cause?: unknown): AgenticError { return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message, { cause }); } + +/** Absolute location of a scalar reached through static tuple/fixed-array indexes. */ +export function argumentLocation( + inputs: readonly AbiParameter[] | undefined, + arg: number | readonly number[], + what: string +): { offset: number; param: AbiParameter } { + if (!inputs) throw invalid(`${what} paths require a full function signature`); + const path = typeof arg === 'number' ? [arg] : arg; + if ( + !Array.isArray(path) || + path.length === 0 || + path.length > 16 || + path.some((i) => !Number.isSafeInteger(i) || i < 0) + ) + throw invalid(`${what} has an invalid argument path`); + if (path[0] >= inputs.length) + throw invalid(`${what} argument path is out of range`); + let offset = + 4 + inputs.slice(0, path[0]).reduce((n, p) => n + headSize(p), 0); + let param = inputs[path[0]]; + for (const index of path.slice(1)) { + if (isDynamic(param)) + throw invalid(`${what} cannot descend into a dynamic argument`); + const arr = arraySuffix(param.type); + if (arr && arr.length !== null) { + if (!Number.isSafeInteger(arr.length) || index >= arr.length) + throw invalid(`${what} fixed-array index is out of range`); + const child = { ...param, type: arr.base } as AbiParameter; + offset += index * headSize(child); + param = child; + } else if (param.type === 'tuple') { + const fields = components(param); + if (index >= fields.length) + throw invalid(`${what} tuple index is out of range`); + offset += fields.slice(0, index).reduce((n, p) => n + headSize(p), 0); + param = fields[index]; + } else throw invalid(`${what} path descends into a scalar`); + } + if ( + !isSingleWordStatic(param) || + !Number.isSafeInteger(offset) || + offset + 32 > UINT16_MAX + ) + throw invalid( + `${what} must identify one static ABI word within the uint16 offset range` + ); + return { offset, param }; +} diff --git a/packages/core/src/lib/agentic/constants.ts b/packages/core/src/lib/agentic/constants.ts index 1b3640a20..943b896c7 100644 --- a/packages/core/src/lib/agentic/constants.ts +++ b/packages/core/src/lib/agentic/constants.ts @@ -5,6 +5,7 @@ export const AGENTIC = Object.freeze({ CATEGORY: Object.freeze({ DEXES: 'DEXES' as const, LENDING: 'LENDING' as const, + STAKING: 'STAKING' as const, PREDICTION: 'PREDICTION' as const, }), }), diff --git a/packages/core/src/lib/agentic/index.ts b/packages/core/src/lib/agentic/index.ts index 667d51550..520fb306d 100644 --- a/packages/core/src/lib/agentic/index.ts +++ b/packages/core/src/lib/agentic/index.ts @@ -19,6 +19,7 @@ export type { Pin, Spent, WalletMarker, + ArgumentPath, RulesRecord, Selector, WalletInfo, diff --git a/packages/core/src/lib/agentic/presets/catalog.ts b/packages/core/src/lib/agentic/presets/catalog.ts index 5edbbec6e..31e26546b 100644 --- a/packages/core/src/lib/agentic/presets/catalog.ts +++ b/packages/core/src/lib/agentic/presets/catalog.ts @@ -1,4 +1,4 @@ -import type { Address } from 'viem'; +import { getAddress, type Address } from 'viem'; import type { RuleCall, ForeignChainNamespace } from '../agentic.types'; import type { PresetCategory } from './types'; @@ -6,8 +6,10 @@ import type { PresetCategory } from './types'; export interface PresetChainDefinition { chainNamespace: ForeignChainNamespace; allowedCalls: readonly RuleCall[]; + /** Static single-hop routers; calls are expanded only for the supplied token pairs. */ + swapRouters?: readonly { address: Address; app: string }[]; /** Exact spenders that need an approval for each non-native supplied asset. */ - approvals?: readonly { spender: Address; app: string }[]; + approvals?: readonly { spender: Address; app: string; token?: Address }[]; maxGasPerCall?: bigint; } export interface PresetDefinition { @@ -15,9 +17,163 @@ export interface PresetDefinition { name: string; chains: readonly PresetChainDefinition[]; } -/** No approved app/address catalog yet. Empty categories must be hidden by UIs. */ +/** Testnet-only catalog from the October 15 Notion export and protocol sources. + * Read-only deployment/code evidence and exclusions: plan/agw/research/preset-catalog-2026-10-15/. + * No mainnet addresses, unmaintained Compound market or unconfirmed Morpho deployment. + */ +const address = (value: string): Address => getAddress(value.toLowerCase()); +const wallet = 'agentic:wallet' as const; +const aave = (pool: Address): RuleCall[] => + [ + ['supply(address,uint256,address,uint16)', 2, 'Supply'], + ['withdraw(address,uint256,address)', 2, 'Withdraw'], + ['borrow(address,uint256,uint256,uint16,address)', 4, 'Borrow'], + ['repay(address,uint256,uint256,address)', 3, 'Repay'], + ].map(([selector, arg, label]) => ({ + target: pool, + selector: selector as RuleCall['selector'], + pins: [{ arg: arg as number, expected: wallet }], + app: 'Aave V3', + label: label as string, + })); +const market = (target: Address, app: string): RuleCall[] => + [ + 'mint(uint256)', + 'redeem(uint256)', + 'redeemUnderlying(uint256)', + 'borrow(uint256)', + 'repayBorrow(uint256)', + ].map((selector) => ({ + target, + selector: selector as RuleCall['selector'], + app, + label: selector.split('(')[0], + })); +const sepoliaPool = address('0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951'); +const basePool = address('0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27'); +const arbitrumPool = address('0xBfC91D59fdAA134A4ED45f7B584cAf96D7792Eff'); +const moonUSDBC = address('0x876852425331a113d8E432eFFB3aC5BEf38f033a'); +const moonWETH = address('0x2F39a349A79492a70E152760ce7123A1933eCf28'); +const venusUSDT = address('0xb7526572FFE56AB9D7489838Bf2E18e3323b441A'); +const venusUSDC = address('0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7'); +const eigen = address('0x2E3D6c0744b10eb0A4e6F679F71554a39Ec47a5D'); export const PRESET_CATALOG: readonly PresetDefinition[] = [ - { constant: 'DEXES', name: 'Popular DEXes', chains: [] }, - { constant: 'LENDING', name: 'Lending', chains: [] }, + { + constant: 'DEXES', + name: 'Popular DEXes', + chains: [ + [ + 'eip155:11155111', + '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', + 'Uniswap V3', + ], + [ + 'eip155:84532', + '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4', + 'Uniswap V3', + ], + [ + 'eip155:421614', + '0x101F443B4d1b059569D643917553c771E1b9663E', + 'Uniswap V3', + ], + [ + 'eip155:97', + '0x9a489505a00cE272eAa5e07Dba6491314CaE3796', + 'PancakeSwap V3', + ], + ].map(([chain, router, app]) => ({ + chainNamespace: chain as ForeignChainNamespace, + allowedCalls: [], + swapRouters: [{ address: address(router), app }], + approvals: [{ spender: address(router), app }], + })), + }, + { + constant: 'LENDING', + name: 'Lending', + chains: [ + { + chainNamespace: 'eip155:11155111', + allowedCalls: aave(sepoliaPool), + approvals: [{ spender: sepoliaPool, app: 'Aave V3' }], + }, + { + chainNamespace: 'eip155:421614', + allowedCalls: aave(arbitrumPool), + approvals: [{ spender: arbitrumPool, app: 'Aave V3' }], + }, + { + chainNamespace: 'eip155:84532', + allowedCalls: [ + ...aave(basePool), + ...market(moonUSDBC, 'Moonwell mUSDbC'), + ...market(moonWETH, 'Moonwell mWETH'), + { + target: address('0xC72ba45f2Ae134F21DAEb2b7B5fd5De0064Ee9bB'), + selector: 'enterMarkets(address[])', + app: 'Moonwell', + label: 'Enter markets', + }, + ], + approvals: [ + { spender: basePool, app: 'Aave V3' }, + { + spender: moonUSDBC, + app: 'Moonwell mUSDbC', + token: address('0x4616975f7E6045FD9737e95bC527e9e14c727BD2'), + }, + { + spender: moonWETH, + app: 'Moonwell mWETH', + token: address('0x663A6F02d547dFd639BF689FED298712f2FB69Fb'), + }, + ], + }, + { + chainNamespace: 'eip155:97', + allowedCalls: [ + ...market(venusUSDT, 'Venus vUSDT'), + ...market(venusUSDC, 'Venus vUSDC'), + { + target: address('0x94d1820b2D1c7c7452A163983Dc888CEC546b77D'), + selector: 'enterMarkets(address[])', + app: 'Venus', + label: 'Enter markets', + }, + ], + approvals: [ + { + spender: venusUSDT, + app: 'Venus vUSDT', + token: address('0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c'), + }, + { + spender: venusUSDC, + app: 'Venus vUSDC', + token: address('0x16227D60f7a0e586C66B005219dfc887D13C9531'), + }, + ], + }, + ], + }, + { + constant: 'STAKING', + name: 'Staking', + chains: [ + { + chainNamespace: 'eip155:11155111', + allowedCalls: [ + { + target: eigen, + selector: 'depositIntoStrategy(address,address,uint256)', + app: 'EigenLayer', + label: 'Deposit into strategy (withdrawals not included)', + }, + ], + approvals: [{ spender: eigen, app: 'EigenLayer' }], + }, + ], + }, { constant: 'PREDICTION', name: 'Prediction markets', chains: [] }, ]; diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index 826286799..0b1d521f7 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -4,7 +4,7 @@ import { AGENTIC_ERROR_CODE, capabilityUnavailable, } from '../errors'; -import type { RuleAsset, RuleCall } from '../agentic.types'; +import type { RuleAsset, RuleCall, Pin } from '../agentic.types'; import type { AgenticPresets, PresetOptions, @@ -27,12 +27,19 @@ function copyCall(call: RuleCall): RuleCall { ...call, ...(call.pins ? { - pins: call.pins.map((p) => ({ - ...p, - ...('token' in p && p.token && typeof p.token === 'object' - ? { token: { ...p.token } } - : {}), - })), + pins: call.pins.map((pin): Pin => { + if (pin.account !== undefined) + return { + ...pin, + ...(pin.token && typeof pin.token === 'object' + ? { token: { ...pin.token } } + : {}), + }; + return { + ...pin, + arg: typeof pin.arg === 'number' ? pin.arg : [...pin.arg], + }; + }), } : {}), }; @@ -54,6 +61,7 @@ export function buildPresetApi( ...c, allowedCalls: c.allowedCalls.map(copyCall), approvals: c.approvals?.map((a) => ({ ...a })), + swapRouters: c.swapRouters?.map((r) => ({ ...r })), })), })); if ( @@ -95,6 +103,8 @@ export function buildPresetApi( options.validUntil >= 2 ** 48) ) throw invalid('preset validUntil must be a future uint48 timestamp'); + if (options.maxGasPerCall !== undefined) + cap(options.maxGasPerCall, 'maxGasPerCall'); if (options.assets !== undefined && !Array.isArray(options.assets)) throw invalid('preset assets must be an array'); const chains = @@ -153,6 +163,55 @@ export function buildPresetApi( } const rules: PresetRuleTemplate[] = chains.map((chain) => { const calls = chain.allowedCalls.map(copyCall); + if (chain.swapRouters?.length) { + const tokens = assets.get(chain.chainNamespace) ?? []; + if (tokens.length < 2) + throw invalid( + 'DEX presets require at least two explicit ERC20 assets per selected chain; select one chain or supply assets for every chain' + ); + if ( + tokens.some( + (a) => + tokenAddress(a.token) === zeroAddress || + (typeof a.token !== 'string' && a.token.mechanism === 'native') + ) + ) + throw invalid( + 'DEX token pins require explicit ERC20 addresses; use wrapped-native tokens instead of the native marker' + ); + for (const router of chain.swapRouters) + for (const input of tokens) + for (const output of tokens) { + if ( + tokenAddress(input.token).toLowerCase() === + tokenAddress(output.token).toLowerCase() + ) + continue; + for (const exactInput of [true, false]) + calls.push({ + target: router.address, + selector: `${ + exactInput ? 'exactInputSingle' : 'exactOutputSingle' + }((address,address,uint24,address,uint256,uint256,uint160))`, + pins: [ + { + arg: [0, 0], + expected: getAddress(tokenAddress(input.token)), + }, + { + arg: [0, 1], + expected: getAddress(tokenAddress(output.token)), + }, + { arg: [0, 3], expected: 'agentic:wallet' }, + { arg: [0, exactInput ? 4 : 5], max: input.maxPerCall }, + ], + app: router.app, + label: exactInput + ? 'Swap exact input' + : 'Swap exact output', + }); + } + } const approvals = new Set(); for (const asset of assets.get(chain.chainNamespace) ?? []) { const token = tokenAddress(asset.token); @@ -168,6 +227,11 @@ export function buildPresetApi( 'presetApproval', 'Solana approvals require a separately reviewed instruction template' ); + if ( + approval.token && + getAddress(token) !== getAddress(approval.token) + ) + continue; const spender = getAddress(approval.spender); const target = getAddress(token); const approvalKey = `${target}:${spender}`; @@ -190,6 +254,11 @@ export function buildPresetApi( 'presetCalls', 'preset must contain 1..32 allowed calls including approvals' ); + if (calls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) > 32) + throw capabilityUnavailable( + 'presetPins', + 'preset expansion exceeds 32 pins; reduce the token list or split rules across wallets' + ); const pairs = new Map(); for (const call of calls) { if (!/^[A-Za-z_$][\w$]*\(.*\)$/.test(call.selector)) @@ -226,8 +295,13 @@ export function buildPresetApi( ...(options.assets !== undefined ? { assets: assets.get(chain.chainNamespace) ?? [] } : {}), - ...(chain.maxGasPerCall !== undefined - ? { maxGasPerCall: cap(chain.maxGasPerCall, 'maxGasPerCall') } + ...((options.maxGasPerCall ?? chain.maxGasPerCall) !== undefined + ? { + maxGasPerCall: cap( + options.maxGasPerCall ?? chain.maxGasPerCall, + 'maxGasPerCall' + ), + } : {}), }; }); diff --git a/packages/core/src/lib/agentic/presets/types.ts b/packages/core/src/lib/agentic/presets/types.ts index 5e3a6bc74..ec0e82bb8 100644 --- a/packages/core/src/lib/agentic/presets/types.ts +++ b/packages/core/src/lib/agentic/presets/types.ts @@ -1,7 +1,7 @@ import type { Address } from 'viem'; import type { Rule, RuleAsset, ForeignChainNamespace } from '../agentic.types'; -export type PresetCategory = 'DEXES' | 'LENDING' | 'PREDICTION'; +export type PresetCategory = 'DEXES' | 'LENDING' | 'STAKING' | 'PREDICTION'; export interface Preset { type: 'CATEGORY'; constant: PresetCategory; @@ -21,6 +21,8 @@ export interface PresetAsset { maxTotal?: bigint; } export interface PresetOptions { + /** Explicit Push-PC gas budget per outbound; omitted = zero. */ + maxGasPerCall?: bigint; chainNamespace?: ForeignChainNamespace; agent?: Address; validUntil?: number; diff --git a/packages/core/src/lib/agentic/public-rule.ts b/packages/core/src/lib/agentic/public-rule.ts index 0f8319359..8dd8f9f0f 100644 --- a/packages/core/src/lib/agentic/public-rule.ts +++ b/packages/core/src/lib/agentic/public-rule.ts @@ -25,6 +25,7 @@ import type { SolanaRule, UniversalRule, Pin, + ArgumentPath, } from './agentic.types'; import { AGENTIC_ERROR_CODE, @@ -32,7 +33,7 @@ import { capabilityUnavailable, } from './errors'; import { parseSelector } from './codec/selectors'; -import { argumentOffset, encodeArgWord, headSize } from './codec/abi-layout'; +import { argumentLocation, encodeArgWord } from './codec/abi-layout'; import { UINT256_MAX } from './codec/defaults'; import { deriveAgwSvmCea, svmKey } from './codec/svm-accounts'; import type { AgenticRuntime } from './runtime'; @@ -69,29 +70,33 @@ function uint(value: unknown, name: string): bigint { } export function evmPinOffset( selector: Rule['allowedCalls'][number]['selector'], - arg: number + arg: ArgumentPath ): number { - if (!Number.isInteger(arg) || arg < 0 || 4 + 32 * arg + 32 > 65535) - throw invalid('pin arg is outside the supported calldata range'); const parsed = parseSelector(selector); if (parsed.valueOnly) throw invalid('value-only calls cannot have argument pins'); - if (!parsed.inputs) return 4 + 32 * arg; - if (parsed.inputs.slice(0, arg).some((p) => headSize(p) !== 32)) - throw capabilityUnavailable( - 'argumentLayout', - 'pins after multi-word tuple/array arguments need an explicit lossless index convention' - ); - return argumentOffset(parsed.inputs, arg, 'pin'); + if (parsed.inputs) return argumentLocation(parsed.inputs, arg, 'pin').offset; + if ( + typeof arg !== 'number' || + !Number.isSafeInteger(arg) || + arg < 0 || + 4 + 32 * arg + 32 > 65535 + ) + throw invalid('a raw selector needs a valid scalar ABI word index'); + return 4 + 32 * arg; } function expectedWord( selector: Rule['allowedCalls'][number]['selector'], - arg: number, + arg: ArgumentPath, value: unknown ): Hex { const parsed = parseSelector(selector); if (parsed.inputs) - return encodeArgWord(parsed.inputs[arg], value as Hex | bigint, 'pin'); + return encodeArgWord( + argumentLocation(parsed.inputs, arg, 'pin').param, + value as Hex | bigint, + 'pin' + ); if (typeof value === 'bigint') return numberToHex(uint(value, 'pin.expected'), { size: 32 }); if (typeof value === 'string' && isAddress(value, { strict: false })) diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 1767a5228..25edbc944 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -225,6 +225,7 @@ export type { Pin, Spent, WalletMarker, + ArgumentPath, RulesRecord, Selector, WalletInfo, From 20ea13603efe89e33c9ce888b7f591b6fba26a99 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Thu, 15 Oct 2026 07:22:59 +0530 Subject: [PATCH 06/27] fix(core): return safe AGW preset review templates --- packages/core/AGW.md | 8 +- .../core/__agw-local__/v5-multiasset.spec.ts | 37 ++++++++++ .../agentic/__tests__/preset-catalog.spec.ts | 73 ++++++++++++++++--- .../src/lib/agentic/__tests__/presets.spec.ts | 2 +- .../core/src/lib/agentic/presets/catalog.ts | 2 +- .../core/src/lib/agentic/presets/index.ts | 32 +++++++- .../core/src/lib/agentic/presets/types.ts | 2 + 7 files changed, 138 insertions(+), 18 deletions(-) diff --git a/packages/core/AGW.md b/packages/core/AGW.md index cb3106d75..af3cd9145 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -177,6 +177,10 @@ The wire encoder uses uint32 maximum for unlimited native calls and 255 for unsp ```ts const { presets } = PushChain.utils.agentic.presets.list({ type: 'CATEGORY' }); const availablePresets = presets.filter(preset => preset.chains.length > 0); +const { rules: reviewTemplates } = PushChain.utils.agentic.presets.get( + PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, +); +// Choose tokens in the review UI, then call get again as below. function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { return PushChain.utils.agentic.presets.get( @@ -197,7 +201,9 @@ function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { The example caps are illustrative raw units; choose caps for the actual token decimals and gas requirements. `get` groups calls into one rule per chain. With agent and expiry supplied, its return type is `Rule[]`; otherwise it is `PresetRuleTemplate[]`, requiring those fields before `create`. Assets must name their chain unless a single chain filter is supplied. Omitted asset caps and `maxGasPerCall` default to zero. -DEX templates require explicit ERC20 tokens for each selected chain. They expand ordered token pairs into exact-input and exact-output calls with token, wallet-recipient and input-amount pins. Missing tokens, native markers and expansions exceeding the policy's 32-pin limit are rejected. Two tokens fit the shipped router template; three do not. Static tuple fields use paths such as `[0, 3]`. +DEX discovery works without options, with an empty asset list, or with assets for only some chains. A chain with fewer than two tokens returns full-signature review calls with wallet-recipient pins and an explicit zero input-amount maximum. A single supplied token is also pinned as the input. These calls cannot spend swap input tokens, even if asset budgets are filled later. Call `get` again after selecting both tokens to replace the review calls with the complete token-pinned rules; merely assigning `rule.assets` is insufficient. + +With two ERC20 tokens, `get` expands ordered pairs into exact-input and exact-output calls with token, wallet-recipient and input-amount pins. Native markers and expansions exceeding the policy's 32-pin limit are rejected. Two tokens fit the shipped router template; three do not. Static tuple fields use paths such as `[0, 3]`. Reviewed EVM spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Money-market approvals are restricted to the market's underlying token. Distinct spenders become separate pinned alternatives under v6. These destination approvals are ordinary ERC20 allowances; the wallet's automatic Push-side gateway approval is separate. diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 36a33b9bc..57290f618 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -296,6 +296,43 @@ describe('v6 multi-asset SDK management and execution', () => { }); }); + it.each(['exactInputSingle', 'exactOutputSingle'] as const)( + 'a review-only DEX %s call cannot spend tokens even after asset budgets are filled', + async (functionName) => { + const preview = agenticPresets.get('DEXES', { chainNamespace: CHAIN_NS }) + .rules[0]; + const { agent } = await setup({ + ...preview, + agent: h.wallets[1].account!.address, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(16), + assets: [{ token: a, maxPerCall: BigInt(5), maxTotal: BigInt(20) }], + }); + const router = preview.allowedCalls[0].target as Address; + const abi = parseAbi([ + `function ${functionName}((address,address,uint24,address,uint256,uint256,uint160))`, + ]); + await expect( + agent.universal.sendTransaction({ + to: { address: router, chain: CHAIN_NS }, + data: [ + { + to: router, + value: BigInt(0), + data: encodeFunctionData({ + abi, + functionName, + args: [[a, b, 3000, CEA, BigInt(1), BigInt(1), BigInt(0)]], + }), + }, + ], + }) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + } + ); + it('a fixture preset produces rules accepted by public create and enforced by real contracts', async () => { const presets = buildPresetApi([ { diff --git a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts index 8e28e839e..c93bc2bd5 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts @@ -1,10 +1,4 @@ -import { - encodeFunctionData, - parseAbi, - getAddress, - type Address, - type Hex, -} from 'viem'; +import { encodeFunctionData, parseAbi, getAddress, type Address } from 'viem'; import { agenticPresets } from '../presets'; import { AGENTIC } from '../constants'; import { evmPinOffset, normalizePublicRule, publicRules } from '../public-rule'; @@ -45,10 +39,7 @@ it('expands both token directions and exact-input/output with wallet and amount rules[0].allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) ).toBe(20); }); -it('rejects native-token markers, omitted tokens and oversized pin expansion', () => { - expect(() => agenticPresets.get('DEXES', { ...input, assets: [] })).toThrow( - /two explicit/ - ); +it('rejects native-token markers and oversized pin expansion', () => { expect(() => agenticPresets.get('DEXES', { ...input, @@ -196,3 +187,63 @@ it('mutating a returned tuple path cannot change a later preset', () => { agenticPresets.get('DEXES', input).rules[0].allowedCalls[0].pins![0].arg ).toEqual([0, 0]); }); + +it('returns one zero-input-cap review template per chain without options', () => { + const { rules } = agenticPresets.get('DEXES'); + expect(rules.map((r) => r.chainNamespace)).toEqual( + agenticPresets.list().presets.find((p) => p.constant === 'DEXES')!.chains + ); + for (const rule of rules) { + expect(rule).not.toHaveProperty('agent'); + expect(rule).not.toHaveProperty('validUntil'); + expect(rule).not.toHaveProperty('assets'); + expect(rule.allowedCalls).toHaveLength(2); + rule.allowedCalls.forEach((call, i) => { + expect(call.pins).toEqual([ + { arg: [0, 3], expected: AGENTIC.WALLET }, + { arg: [0, i === 0 ? 4 : 5], max: BigInt(0) }, + ]); + expect(call.label).toContain('select two tokens'); + }); + } +}); +it('keeps single-token swaps disabled and generates approvals only for the supplied asset', () => { + const rule = agenticPresets.get('DEXES', { + ...input, + assets: [input.assets![0]], + }).rules[0]; + expect(rule.assets).toEqual([input.assets![0]]); + expect(rule.allowedCalls).toHaveLength(3); + for (const call of rule.allowedCalls.slice(0, 2)) { + expect(call.pins).toContainEqual({ arg: [0, 0], expected: tokenA }); + expect(call.pins?.at(-1)?.max).toBe(BigInt(0)); + } + expect(rule.allowedCalls[2].selector).toBe('approve(address,uint256)'); + expect(rule.allowedCalls[2].target).toBe(tokenA); +}); +it('keeps chains without supplied assets discoverable and regenerates complete pairs without disabled alternatives', () => { + const { rules } = agenticPresets.get('DEXES', { + assets: input.assets!.map((a) => ({ ...a, chainNamespace: chain })), + }); + expect(rules).toHaveLength(4); + expect( + rules.find((r) => r.chainNamespace === chain)?.allowedCalls + ).toHaveLength(6); + for (const r of rules.filter((r) => r.chainNamespace !== chain)) { + expect(r.assets).toEqual([]); + expect(r.allowedCalls).toHaveLength(2); + expect(r.allowedCalls.every((c) => c.pins?.at(-1)?.max === BigInt(0))).toBe( + true + ); + } + const preview = agenticPresets.get('DEXES', { + chainNamespace: chain, + assets: [], + }).rules[0]; + const ready = agenticPresets.get('DEXES', input).rules[0]; + expect(preview.allowedCalls).toHaveLength(2); + expect(ready.allowedCalls).toHaveLength(6); + expect( + ready.allowedCalls.every((c) => !c.label?.includes('select two tokens')) + ).toBe(true); +}); diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts index da6f3e98d..6a8d75950 100644 --- a/packages/core/src/lib/agentic/__tests__/presets.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -72,7 +72,7 @@ it('ships only verified testnet catalog entries and keeps prediction empty', () ) ).toBe(true); expect(agenticPresets.get('PREDICTION')).toEqual({ rules: [] }); - expect(() => agenticPresets.get('DEXES')).toThrow(/two explicit ERC20/); + expect(agenticPresets.get('DEXES').rules).toHaveLength(4); expect(() => agenticPresets.get('LENDING', { chainNamespace: 'eip155:1' }) ).toThrow(/no approved entry/); diff --git a/packages/core/src/lib/agentic/presets/catalog.ts b/packages/core/src/lib/agentic/presets/catalog.ts index 31e26546b..9c7c1ebf6 100644 --- a/packages/core/src/lib/agentic/presets/catalog.ts +++ b/packages/core/src/lib/agentic/presets/catalog.ts @@ -6,7 +6,7 @@ import type { PresetCategory } from './types'; export interface PresetChainDefinition { chainNamespace: ForeignChainNamespace; allowedCalls: readonly RuleCall[]; - /** Static single-hop routers; calls are expanded only for the supplied token pairs. */ + /** Static single-hop routers; incomplete pairs return zero-input-cap review calls. */ swapRouters?: readonly { address: Address; app: string }[]; /** Exact spenders that need an approval for each non-native supplied asset. */ approvals?: readonly { spender: Address; app: string; token?: Address }[]; diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index 0b1d521f7..61c5ce4bf 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -165,10 +165,6 @@ export function buildPresetApi( const calls = chain.allowedCalls.map(copyCall); if (chain.swapRouters?.length) { const tokens = assets.get(chain.chainNamespace) ?? []; - if (tokens.length < 2) - throw invalid( - 'DEX presets require at least two explicit ERC20 assets per selected chain; select one chain or supply assets for every chain' - ); if ( tokens.some( (a) => @@ -179,6 +175,34 @@ export function buildPresetApi( throw invalid( 'DEX token pins require explicit ERC20 addresses; use wrapped-native tokens instead of the native marker' ); + // Keep discovery useful before a swap pair is selected. These review + // calls cannot spend input tokens: the amount maximum is explicitly zero. + // Re-run get with both tokens to generate the executable pair pins. + if (tokens.length < 2) + for (const router of chain.swapRouters) + for (const exactInput of [true, false]) + calls.push({ + target: router.address, + selector: `${ + exactInput ? 'exactInputSingle' : 'exactOutputSingle' + }((address,address,uint24,address,uint256,uint256,uint160))`, + pins: [ + ...(tokens.length + ? [ + { + arg: [0, 0], + expected: getAddress(tokenAddress(tokens[0].token)), + }, + ] + : []), + { arg: [0, 3], expected: 'agentic:wallet' }, + { arg: [0, exactInput ? 4 : 5], max: BigInt(0) }, + ], + app: router.app, + label: `${ + exactInput ? 'Swap exact input' : 'Swap exact output' + } (select two tokens to enable)`, + }); for (const router of chain.swapRouters) for (const input of tokens) for (const output of tokens) { diff --git a/packages/core/src/lib/agentic/presets/types.ts b/packages/core/src/lib/agentic/presets/types.ts index ec0e82bb8..d7c886422 100644 --- a/packages/core/src/lib/agentic/presets/types.ts +++ b/packages/core/src/lib/agentic/presets/types.ts @@ -26,6 +26,8 @@ export interface PresetOptions { chainNamespace?: ForeignChainNamespace; agent?: Address; validUntil?: number; + /** DEX swaps with fewer than two tokens are zero-input-cap review templates. + * Call get again with a complete pair to generate token pins and amount limits. */ assets?: PresetAsset[]; } export interface ReadyPresetOptions extends PresetOptions { From 0b7be4d839dda0ce2e17a37e401ea66c2a3f5758 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Thu, 15 Oct 2026 07:44:07 +0530 Subject: [PATCH 07/27] feat(core): add mainnet AGW presets and live protocol coverage --- packages/core/AGW.md | 17 +- .../core/__e2e__/agw/presets-live.spec.ts | 453 ++++++++++++++++++ packages/core/__e2e__/ci/suite.ts | 17 + .../agentic/__tests__/preset-catalog.spec.ts | 53 +- .../src/lib/agentic/__tests__/presets.spec.ts | 18 +- .../core/src/lib/agentic/presets/catalog.ts | 158 +++++- 6 files changed, 701 insertions(+), 15 deletions(-) create mode 100644 packages/core/__e2e__/agw/presets-live.spec.ts diff --git a/packages/core/AGW.md b/packages/core/AGW.md index af3cd9145..772201aaa 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -167,11 +167,11 @@ The wire encoder uses uint32 maximum for unlimited native calls and 255 for unsp ## Preset discovery and templates (section 7) -`PushChain.utils.agentic` contains `presets.list/get`. The categories are `AGENTIC.PRESETS.CATEGORY.DEXES`, `LENDING`, `STAKING` and `PREDICTION`. The catalog ships testnet templates only: +`PushChain.utils.agentic` contains `presets.list/get`. The categories are `AGENTIC.PRESETS.CATEGORY.DEXES`, `LENDING`, `STAKING` and `PREDICTION`. The catalog includes mainnet and testnet templates. Discovery is network-independent: pass `chainNamespace` to select the intended chain. A mainnet template does not imply that the connected Push network supports execution to it: -- DEXES: Uniswap V3 on Sepolia, Base Sepolia and Arbitrum Sepolia; PancakeSwap V3 on BSC testnet. -- LENDING: Aave on the three Ethereum testnets above, Moonwell on Base Sepolia and Venus on BSC testnet. -- STAKING: EigenLayer deposits on Sepolia; withdrawals are not included. +- DEXES: Uniswap V3 on Ethereum, Base and Arbitrum (mainnet and their Sepolia testnets); PancakeSwap V3 on BNB mainnet and BSC testnet. +- LENDING: Aave on Ethereum, Base, Arbitrum and BNB mainnets, plus Sepolia/Base Sepolia/Arbitrum Sepolia; Moonwell on Base mainnet/testnet and Venus on BNB mainnet/testnet. +- STAKING: EigenLayer deposits on Ethereum mainnet and Sepolia (EigenLayer withdrawals excluded); Lido stake, wrap/unwrap and withdrawal requests/claims on Ethereum mainnet. Lido ETH staking defaults to `maxValue: 0`; explicitly set a value cap before use. - PREDICTION: no entries yet. Hide categories with empty `chains` lists. ```ts @@ -207,8 +207,15 @@ With two ERC20 tokens, `get` expands ordered pairs into exact-input and exact-ou Reviewed EVM spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Money-market approvals are restricted to the market's underlying token. Distinct spenders become separate pinned alternatives under v6. These destination approvals are ordinary ERC20 allowances; the wallet's automatic Push-side gateway approval is separate. -Templates describe permissions, not available liquidity or guaranteed execution. Verify destination token balances, supported reserves and source PRC20 routes before use: protocol testnet mock tokens may differ from MOVEABLE tokens. Presets do not sign, fetch app ABIs or modify wallets. No mainnet templates, dynamic-path swaps, Solana protocol templates or unconfirmed deployments ship in this catalog. Local policy tests do not execute the destination protocols. +Templates describe permissions, not available liquidity or guaranteed execution. Verify destination token balances, supported reserves and source PRC20 routes before use: protocol testnet mock tokens may differ from MOVEABLE tokens. Presets do not sign, fetch app ABIs or modify wallets. Dynamic-path swaps, Solana protocol templates and unconfirmed deployments are excluded. Mainnet addresses were checked read-only; no mainnet transactions were executed. Other mainnet protocols in the proposal remain excluded until their deployment and permission constraints are verified. Local policy tests do not execute the destination protocols. ## Validation Run `node packages/core/scripts/check-agw-docs.js` to typecheck these examples. Unit tests use the normal core Jest configuration. `jest.agw-local.config.ts` uses pinned real AGW contracts and a stub gateway. Live E2Es are opt-in under `__e2e__/agw`; preflight verifies the deployment and funding limits. Local tests do not prove destination delivery, and historical live results do not count as validation of a changed public API. + + +### Live preset acceptance + +The opt-in `agw-presets` E2E group exercises real Donut-to-Sepolia protocol calls. From `packages/core`, run `npm run e2e:preflight -- --group agw-presets --dry-run`, then `npm run e2e:ci -- --group agw-presets`. It uses the existing E2E credentials in memory. Each scenario funds a fresh wallet with at most 65 PC plus a fresh agent's configured gas allocation; the Uniswap case also uses 0.000002 pETH. No mainnet transaction is part of this group. + +Uniswap uses destination WETH/Circle USDC; Aave uses its faucet LINK. Their generated preset calls and pins are retained, but bridge `assets` are set to `[]` because these destination tokens are not a supported SDK PRC20 pair. The owner funds the CEA separately. These tests establish swaps and lending against actual protocols, not the ability to bridge arbitrary assets. Missing liquidity, exhausted reserve caps or an unavailable faucet fail as prerequisites rather than silently skipping. Results are specific to Sepolia and do not certify the other catalog chains. diff --git a/packages/core/__e2e__/agw/presets-live.spec.ts b/packages/core/__e2e__/agw/presets-live.spec.ts new file mode 100644 index 000000000..d0567d5d6 --- /dev/null +++ b/packages/core/__e2e__/agw/presets-live.spec.ts @@ -0,0 +1,453 @@ +/** Real Donut -> Sepolia preset execution. No mainnet transactions. + * Destination WETH/Circle USDC are intentionally prefunded, not bridged assets: + * those two tokens have no usable SDK PRC20 pair. Generated call pins are unchanged. + */ +import { + createPublicClient, + encodeFunctionData, + erc20Abi, + http, + parseAbi, + parseEther, + parseEventLogs, + type Address, + type Hex, +} from 'viem'; +import { sepolia } from 'viem/chains'; +import { PushChain, CHAIN } from '../../src'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; +import { + AGW_E2E_ENABLED, + setupAgw, + evmClient, + inSeconds, + type AgwFixture, +} from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const WETH = '0xfff9976782d46cc05630d1f6ebab18b2324d6b14' as Address; +const USDC = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' as Address; +const FACTORY = '0x0227628f3F023bb0B980b67D528571c95c6DaC1c' as Address; +const AMOUNT = BigInt(10) ** BigInt(12); +const MAX_PC = parseEther('20'); +const SWAP = parseAbi([ + 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns(uint256)', +]); +const EXECUTED = parseAbi([ + 'event UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data)', +]); +const sep = createPublicClient({ + chain: sepolia, + transport: http('https://ethereum-sepolia-rpc.publicnode.com'), +}); + +d('agw presets live', () => { + let f: AgwFixture; + afterAll(() => f?.teardown()); + it('1. Uniswap preset swaps from the wallet CEA and rejects a different recipient', async () => { + // Read-only protocol prerequisites run before any funding. + const block = await sep.getBlockNumber(); + const pool = await sep.readContract({ + address: FACTORY, + abi: parseAbi([ + 'function getPool(address,address,uint24) view returns(address)', + ]), + functionName: 'getPool', + args: [WETH, USDC, 500], + blockNumber: block, + }); + if (/^0x0+$/.test(pool)) + throw new Error( + 'PRESET_PREREQUISITE: Sepolia WETH/Circle USDC pool is absent' + ); + const liquidity = await sep.readContract({ + address: pool, + abi: parseAbi(['function liquidity() view returns(uint128)']), + functionName: 'liquidity', + blockNumber: block, + }); + if (!liquidity) + throw new Error( + 'PRESET_PREREQUISITE: Sepolia pool has no active liquidity' + ); + f = await setupAgw(); + const { rules } = PushChain.utils.agentic.presets.get('DEXES', { + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + agent: f.agentAddress, + validUntil: inSeconds(3600), + maxGasPerCall: MAX_PC, + assets: [ + { token: WETH, maxPerCall: AMOUNT, maxTotal: AMOUNT * BigInt(2) }, + { + token: USDC, + maxPerCall: BigInt(1_000_000), + maxTotal: BigInt(2_000_000), + }, + ], + }); + // assets[] budgets bridge burns on Push. This test spends already-held CEA + // tokens, so omit bridge assets without changing any generated call or pin. + const { wallet, rulesIds } = await f.owner.agentic.create( + 'e2e-preset-uniswap', + { rules: [{ ...rules[0], assets: [] }] } + ); + const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) + .cea as Address; + const router = rules[0].allowedCalls[0].target as Address; + await f.fundPC(wallet, parseEther('65')); + const peth = getNativePRC20ForChain( + CHAIN.ETHEREUM_SEPOLIA, + f.manifest.network + ); + await ( + await f.owner.universal.sendTransaction({ + to: peth, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [wallet, AMOUNT * BigInt(2)], + }), + }) + ).wait(); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); + const wrap = await owner.universal.sendTransaction({ + to: { address: WETH, chain: CHAIN.ETHEREUM_SEPOLIA }, + data: [ + { + to: WETH, + value: AMOUNT * BigInt(2), + data: encodeFunctionData({ + abi: parseAbi(['function deposit() payable']), + functionName: 'deposit', + }), + }, + ], + funds: { + token: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH, + amount: AMOUNT * BigInt(2), + }, + gasLimit: BigInt(2_000_000), + }); + const wrapped = await wrap.wait(); + expect(wrapped.externalTxHash).toBeTruthy(); + expect( + (await sep.getTransactionReceipt({ hash: wrapped.externalTxHash as Hex })) + .status + ).toBe('success'); + const balance = (token: Address) => + sep.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [cea], + }); + expect(await balance(WETH)).toBe(AMOUNT * BigInt(2)); + const before = await balance(USDC); + const agent = await f.agent(wallet); + const swap = (recipient: Address) => + encodeFunctionData({ + abi: SWAP, + functionName: 'exactInputSingle', + args: [ + { + tokenIn: WETH, + tokenOut: USDC, + fee: 500, + recipient, + amountIn: AMOUNT, + amountOutMinimum: BigInt(1), + sqrtPriceLimitX96: BigInt(0), + }, + ], + }); + await expect( + agent.universal.sendTransaction({ + to: { address: router, chain: CHAIN.ETHEREUM_SEPOLIA }, + data: [{ to: router, value: BigInt(0), data: swap(f.agentAddress) }], + gasLimit: BigInt(700_000), + }) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + const tx = await agent.universal.sendTransaction({ + to: { address: router, chain: CHAIN.ETHEREUM_SEPOLIA }, + gasLimit: BigInt(700_000), + data: [ + { + to: WETH, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [router, AMOUNT], + }), + }, + { to: router, value: BigInt(0), data: swap(cea) }, + ], + }); + const receipt = await tx.wait(); + expect(receipt.externalTxHash).toBeTruthy(); + const dest = await sep.getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + expect(dest.status).toBe('success'); + expect(await balance(WETH)).toBe(AMOUNT); + const after = await balance(USDC); + expect(after).toBeGreaterThan(before); + expect( + parseEventLogs({ abi: EXECUTED, logs: dest.logs }).some( + (e) => + e.address.toLowerCase() === cea.toLowerCase() && + e.args.originCaller.toLowerCase() === wallet.toLowerCase() && + e.args.target.toLowerCase() === router.toLowerCase() && + e.args.data === swap(cea) + ) + ).toBe(true); + f.evidence('preset-uniswap-live', { + wallet, + cea, + rulesId: rulesIds[0], + pool, + prerequisiteBlock: block, + wrapTx: wrap.hash, + wrapExternal: wrapped.externalTxHash, + pushTx: tx.hash, + externalTx: receipt.externalTxHash, + amountIn: AMOUNT, + amountOut: after - before, + }); + }, 900_000); + it('2. Aave preset supplies and withdraws the wallet position', async () => { + const mockLINK = '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5' as Address; + const faucet = '0xC959483DBa39aa9E78757139af0e9a2EDEb3f42D' as Address; + const amount = BigInt(10) ** BigInt(12); + const faucetAbi = parseAbi(['function mint(address,address,uint256)']); + // Testnet USDC/DAI/USDT supply caps are exhausted; use faucet LINK. + // Mock LINK is a destination-only asset, not an SDK bridge token. + await sep.simulateContract({ + address: faucet, + abi: faucetAbi, + functionName: 'mint', + args: [mockLINK, '0x0000000000000000000000000000000000001234', amount], + account: '0x0000000000000000000000000000000000001234', + }); + const readinessPool = PushChain.utils.agentic.presets.get('LENDING', { + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + }).rules[0].allowedCalls[0].target as Address; + // Fail before funding if protocol gates (cap, pause, freeze) reject supply. + // An unfunded probe reaching token transfer is the expected result. + let readiness = ''; + try { + await sep.simulateContract({ + address: readinessPool, + abi: parseAbi(['function supply(address,uint256,address,uint16)']), + functionName: 'supply', + args: [ + mockLINK, + amount, + '0x0000000000000000000000000000000000001234', + 0, + ], + account: '0x0000000000000000000000000000000000001234', + }); + } catch (error) { + readiness = String( + (error as { shortMessage?: string }).shortMessage ?? error + ); + } + if (!readiness.includes('transfer amount exceeds balance')) + throw new Error( + `PRESET_PREREQUISITE: Aave reserve rejected unfunded supply probe: ${readiness}` + ); + f?.teardown(); + f = await setupAgw(); + const { rules } = PushChain.utils.agentic.presets.get('LENDING', { + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + agent: f.agentAddress, + validUntil: inSeconds(3600), + maxGasPerCall: MAX_PC, + assets: [{ token: mockLINK, maxPerCall: amount, maxTotal: amount }], + }); + const { wallet, rulesIds } = await f.owner.agentic.create( + 'e2e-preset-aave', + { rules: [{ ...rules[0], assets: [] }] } + ); + const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) + .cea as Address; + const pool = rules[0].allowedCalls[0].target as Address; + await f.fundPC(wallet, parseEther('65')); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); + const mint = await owner.universal.sendTransaction({ + to: { address: faucet, chain: CHAIN.ETHEREUM_SEPOLIA }, + gasLimit: BigInt(2_000_000), + data: [ + { + to: faucet, + value: BigInt(0), + data: encodeFunctionData({ + abi: faucetAbi, + functionName: 'mint', + args: [mockLINK, cea, amount], + }), + }, + ], + }); + f.evidence('preset-aave-stage', { + stage: 'mint-submitted', + wallet, + cea, + pushTx: mint.hash, + }); + const minted = await mint.wait(); + f.evidence('preset-aave-stage', { + stage: 'mint-completed', + wallet, + cea, + externalTx: minted.externalTxHash, + }); + expect( + (await sep.getTransactionReceipt({ hash: minted.externalTxHash as Hex })) + .status + ).toBe('success'); + const balance = () => + sep.readContract({ + address: mockLINK, + abi: erc20Abi, + functionName: 'balanceOf', + args: [cea], + }); + expect(await balance()).toBe(amount); + const abi = parseAbi([ + 'function supply(address,uint256,address,uint16)', + 'function withdraw(address,uint256,address) returns(uint256)', + 'event Supply(address indexed reserve,address user,address indexed onBehalfOf,uint256 amount,uint16 indexed referralCode)', + 'event Withdraw(address indexed reserve,address indexed user,address indexed to,uint256 amount)', + ]); + const agent = await f.agent(wallet); + const supplyData = encodeFunctionData({ + abi, + functionName: 'supply', + args: [mockLINK, amount, cea, 0], + }); + const supply = await agent.universal.sendTransaction({ + to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, + gasLimit: BigInt(900_000), + data: [ + { + to: mockLINK, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [pool, amount], + }), + }, + { to: pool, value: BigInt(0), data: supplyData }, + ], + }); + f.evidence('preset-aave-stage', { + stage: 'supply-submitted', + wallet, + cea, + pushTx: supply.hash, + }); + const supplied = await supply.wait(); + f.evidence('preset-aave-stage', { + stage: 'supply-completed', + wallet, + cea, + externalTx: supplied.externalTxHash, + }); + const supplyReceipt = await sep.getTransactionReceipt({ + hash: supplied.externalTxHash as Hex, + }); + expect(supplyReceipt.status).toBe('success'); + expect(await balance()).toBe(BigInt(0)); + const events = parseEventLogs({ + abi, + eventName: 'Supply', + logs: supplyReceipt.logs, + }); + expect( + events.some( + (e) => + e.address.toLowerCase() === pool.toLowerCase() && + e.args.user.toLowerCase() === cea.toLowerCase() && + e.args.onBehalfOf.toLowerCase() === cea.toLowerCase() && + e.args.amount === amount + ) + ).toBe(true); + const withdraw = await agent.universal.sendTransaction({ + to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, + gasLimit: BigInt(900_000), + data: [ + { + to: pool, + value: BigInt(0), + data: encodeFunctionData({ + abi, + functionName: 'withdraw', + args: [mockLINK, amount, cea], + }), + }, + ], + }); + f.evidence('preset-aave-stage', { + stage: 'withdraw-submitted', + wallet, + cea, + pushTx: withdraw.hash, + }); + const withdrawn = await withdraw.wait(); + f.evidence('preset-aave-stage', { + stage: 'withdraw-completed', + wallet, + cea, + externalTx: withdrawn.externalTxHash, + }); + const withdrawalReceipt = await sep.getTransactionReceipt({ + hash: withdrawn.externalTxHash as Hex, + }); + expect(withdrawalReceipt.status).toBe('success'); + expect(await balance()).toBe(amount); + expect( + parseEventLogs({ + abi, + eventName: 'Withdraw', + logs: withdrawalReceipt.logs, + }).some( + (e) => + e.address.toLowerCase() === pool.toLowerCase() && + e.args.user.toLowerCase() === cea.toLowerCase() && + e.args.to.toLowerCase() === cea.toLowerCase() && + e.args.amount === amount + ) + ).toBe(true); + f.evidence('preset-aave-live', { + wallet, + cea, + rulesId: rulesIds[0], + mockLINK, + pool, + mintTx: mint.hash, + mintExternal: minted.externalTxHash, + supplyTx: supply.hash, + supplyExternal: supplied.externalTxHash, + withdrawTx: withdraw.hash, + withdrawExternal: withdrawn.externalTxHash, + amount, + }); + }, 900_000); +}); diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index 8ad742d2b..c0869ece5 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -37,6 +37,7 @@ export const GROUPS = [ 'agw', 'agw-svm-wire', 'agw-svm-replay', + 'agw-presets', ] as const; export type Group = (typeof GROUPS)[number]; @@ -984,6 +985,22 @@ export const SCENARIOS: Scenario[] = [ needs: { masterPC: '0.5' }, env: { AGW_E2E: '1' }, }, + { + id: 'agw-preset-uniswap-live', + group: 'agw-presets', + file: '__e2e__/agw/presets-live.spec.ts', + grep: 'agw presets live 1', + needs: { masterPC: '75', masterPETH: '0.000002' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-preset-aave-live', + group: 'agw-presets', + file: '__e2e__/agw/presets-live.spec.ts', + grep: 'agw presets live 2', + needs: { masterPC: '75' }, + env: { AGW_E2E: '1' }, + }, { id: 'agw-v6-destination-pins', group: 'agw', diff --git a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts index c93bc2bd5..622abe7df 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts @@ -86,6 +86,10 @@ it('scopes money-market approvals to their actual underlying token', () => { }); }); it.each([ + 'eip155:1', + 'eip155:8453', + 'eip155:42161', + 'eip155:56', 'eip155:11155111', 'eip155:84532', 'eip155:421614', @@ -225,7 +229,7 @@ it('keeps chains without supplied assets discoverable and regenerates complete p const { rules } = agenticPresets.get('DEXES', { assets: input.assets!.map((a) => ({ ...a, chainNamespace: chain })), }); - expect(rules).toHaveLength(4); + expect(rules).toHaveLength(8); expect( rules.find((r) => r.chainNamespace === chain)?.allowedCalls ).toHaveLength(6); @@ -247,3 +251,50 @@ it('keeps chains without supplied assets discoverable and regenerates complete p ready.allowedCalls.every((c) => !c.label?.includes('select two tokens')) ).toBe(true); }); + +it.each(['eip155:1', 'eip155:8453', 'eip155:42161', 'eip155:56'] as const)( + 'mainnet %s templates preserve one rule per chain, selectors and wallet pins', + (chainNamespace) => { + for (const category of ['DEXES', 'LENDING'] as const) { + const { rules } = agenticPresets.get(category, { chainNamespace }); + expect(rules).toHaveLength(1); + expect(rules[0].chainNamespace).toBe(chainNamespace); + expect(rules[0].allowedCalls.length).toBeLessThanOrEqual(32); + expect(rules[0].allowedCalls.every((c) => c.selector.includes('('))).toBe( + true + ); + expect( + rules[0].allowedCalls.some((c) => + c.pins?.some((p) => p.expected === AGENTIC.WALLET) + ) + ).toBe(true); + expect(rules[0]).not.toHaveProperty('agent'); + } + } +); + +it('mainnet Lido templates pin withdrawal ownership and keep ETH staking disabled until explicitly capped', () => { + const stETH = getAddress('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'); + const { rules } = agenticPresets.get('STAKING', { + chainNamespace: 'eip155:1', + assets: [{ token: stETH, maxPerCall: BigInt(5), maxTotal: BigInt(10) }], + }); + expect(rules).toHaveLength(1); + const calls = rules[0].allowedCalls; + expect(calls.find((c) => c.selector === 'submit(address)')?.maxValue).toBe( + BigInt(0) + ); + const request = calls.find( + (c) => c.selector === 'requestWithdrawals(uint256[],address)' + )!; + expect(request.pins).toEqual([{ arg: 1, expected: AGENTIC.WALLET }]); + expect(evmPinOffset(request.selector, 1)).toBe(36); + expect( + calls.filter((c) => c.selector === 'approve(address,uint256)') + ).toHaveLength(3); + expect( + calls + .filter((c) => c.selector === 'approve(address,uint256)') + .every((c) => c.target === stETH && c.pins?.[1].max === BigInt(5)) + ).toBe(true); +}); diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts index 6a8d75950..cefe74799 100644 --- a/packages/core/src/lib/agentic/__tests__/presets.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -45,7 +45,7 @@ function catalog(): PresetDefinition[] { }, ]; } -it('ships only verified testnet catalog entries and keeps prediction empty', () => { +it('ships verified mainnet and testnet templates and keeps prediction empty', () => { expect(Object.values(AGENTIC.PRESETS.CATEGORY)).toEqual([ 'DEXES', 'LENDING', @@ -54,9 +54,9 @@ it('ships only verified testnet catalog entries and keeps prediction empty', () ]); const entries = agenticPresets.list().presets; expect(entries.map((p) => [p.constant, p.chains.length])).toEqual([ - ['DEXES', 4], - ['LENDING', 4], - ['STAKING', 1], + ['DEXES', 8], + ['LENDING', 8], + ['STAKING', 2], ['PREDICTION', 0], ]); expect( @@ -64,6 +64,10 @@ it('ships only verified testnet catalog entries and keeps prediction empty', () .flatMap((p) => p.chains) .every((c) => [ + 'eip155:1', + 'eip155:8453', + 'eip155:42161', + 'eip155:56', 'eip155:11155111', 'eip155:84532', 'eip155:421614', @@ -72,9 +76,9 @@ it('ships only verified testnet catalog entries and keeps prediction empty', () ) ).toBe(true); expect(agenticPresets.get('PREDICTION')).toEqual({ rules: [] }); - expect(agenticPresets.get('DEXES').rules).toHaveLength(4); + expect(agenticPresets.get('DEXES').rules).toHaveLength(8); expect(() => - agenticPresets.get('LENDING', { chainNamespace: 'eip155:1' }) + agenticPresets.get('LENDING', { chainNamespace: 'eip155:999999' }) ).toThrow(/no approved entry/); }); it('groups all apps on a chain into one editable template and omits unspecified fields', () => { @@ -203,7 +207,7 @@ it('rejects unknown presets, unsupported list types and unsupported chain filter const api = buildPresetApi(catalog()); expect(() => api.get('UNKNOWN' as never)).toThrow(/unknown preset/); expect(() => api.list({ type: 'APP' } as never)).toThrow(/only CATEGORY/); - expect(() => api.get('DEXES', { chainNamespace: 'eip155:1' })).toThrow( + expect(() => api.get('DEXES', { chainNamespace: 'eip155:999999' })).toThrow( /no approved entry/ ); }); diff --git a/packages/core/src/lib/agentic/presets/catalog.ts b/packages/core/src/lib/agentic/presets/catalog.ts index 9c7c1ebf6..732243fb6 100644 --- a/packages/core/src/lib/agentic/presets/catalog.ts +++ b/packages/core/src/lib/agentic/presets/catalog.ts @@ -17,9 +17,9 @@ export interface PresetDefinition { name: string; chains: readonly PresetChainDefinition[]; } -/** Testnet-only catalog from the October 15 Notion export and protocol sources. +/** Mainnet and testnet templates from the October 15 Notion export and protocol sources. * Read-only deployment/code evidence and exclusions: plan/agw/research/preset-catalog-2026-10-15/. - * No mainnet addresses, unmaintained Compound market or unconfirmed Morpho deployment. + * Mainnet entries are templates only; no mainnet deployment or execution guarantee. */ const address = (value: string): Address => getAddress(value.toLowerCase()); const wallet = 'agentic:wallet' as const; @@ -57,11 +57,86 @@ const moonWETH = address('0x2F39a349A79492a70E152760ce7123A1933eCf28'); const venusUSDT = address('0xb7526572FFE56AB9D7489838Bf2E18e3323b441A'); const venusUSDC = address('0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7'); const eigen = address('0x2E3D6c0744b10eb0A4e6F679F71554a39Ec47a5D'); +const stETH = address('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'); +const wstETH = address('0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'); +const lidoQueue = address('0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1'); +const mainnetMarkets = ( + chain: string +): Pick => { + const entries = + chain === 'eip155:8453' + ? [ + [ + '0xEdc817A28E8B93B03976FBd4a3dDBc9f7D176c22', + 'Moonwell mUSDC', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + ], + [ + '0x628ff693426583D9a7FB391E54366292F509D457', + 'Moonwell mWETH', + '0x4200000000000000000000000000000000000006', + ], + ] + : chain === 'eip155:56' + ? [ + [ + '0xfD5840Cd36d94D7229439859C0112a4185BC0255', + 'Venus vUSDT', + '0x55d398326f99059fF775485246999027B3197955', + ], + [ + '0xecA88125a5ADbe82614ffC12D0DB554E2e2867C8', + 'Venus vUSDC', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + ], + ] + : []; + const controller = + chain === 'eip155:8453' + ? '0xfBb21d0380beE3312B33c4353c8936a0F13EF26C' + : '0xfD36E2c2a6789Db23113685031d7F16329158384'; + return { + allowedCalls: [ + ...entries.flatMap(([target, app]) => market(address(target), app)), + ...(entries.length + ? [ + { + target: address(controller), + selector: 'enterMarkets(address[])' as const, + app: chain === 'eip155:8453' ? 'Moonwell' : 'Venus', + label: 'Enter markets', + }, + ] + : []), + ], + approvals: entries.map(([spender, app, token]) => ({ + spender: address(spender), + app, + token: address(token), + })), + }; +}; export const PRESET_CATALOG: readonly PresetDefinition[] = [ { constant: 'DEXES', name: 'Popular DEXes', chains: [ + ['eip155:1', '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', 'Uniswap V3'], + [ + 'eip155:8453', + '0x2626664c2603336E57B271c5C0b26F421741e481', + 'Uniswap V3', + ], + [ + 'eip155:42161', + '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + 'Uniswap V3', + ], + [ + 'eip155:56', + '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4', + 'PancakeSwap V3', + ], [ 'eip155:11155111', '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', @@ -93,6 +168,25 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ constant: 'LENDING', name: 'Lending', chains: [ + ...( + [ + ['eip155:1', '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2'], + ['eip155:8453', '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5'], + ['eip155:42161', '0x794a61358D6845594F94dc1DB02A252b5b4814aD'], + ['eip155:56', '0x6807dc923806fE8Fd134338EABCA509979a7e0cB'], + ] as const + ).map(([chainNamespace, pool]) => ({ + chainNamespace, + allowedCalls: [ + ...aave(address(pool)), + ...mainnetMarkets(chainNamespace).allowedCalls, + ], + approvals: [ + { spender: address(pool), app: 'Aave V3' }, + ...(mainnetMarkets(chainNamespace).approvals ?? []), + ], + })), + { chainNamespace: 'eip155:11155111', allowedCalls: aave(sepoliaPool), @@ -161,6 +255,66 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ constant: 'STAKING', name: 'Staking', chains: [ + { + chainNamespace: 'eip155:1', + allowedCalls: [ + { + target: stETH, + selector: 'submit(address)', + maxValue: BigInt(0), + app: 'Lido', + label: 'Stake ETH (set an explicit value cap)', + }, + { + target: wstETH, + selector: 'wrap(uint256)', + app: 'Lido wstETH', + label: 'Wrap stETH', + }, + { + target: wstETH, + selector: 'unwrap(uint256)', + app: 'Lido wstETH', + label: 'Unwrap wstETH', + }, + { + target: lidoQueue, + selector: 'requestWithdrawals(uint256[],address)', + pins: [{ arg: 1, expected: wallet }], + app: 'Lido withdrawal queue', + label: 'Request withdrawal', + }, + { + target: lidoQueue, + selector: 'claimWithdrawals(uint256[],uint256[])', + app: 'Lido withdrawal queue', + label: 'Claim finalized withdrawals', + }, + { + target: lidoQueue, + selector: 'claimWithdrawal(uint256)', + app: 'Lido withdrawal queue', + label: 'Claim finalized withdrawal', + }, + + { + target: address('0x858646372CC42E1A627fcE94aa7A7033e7CF075A'), + selector: 'depositIntoStrategy(address,address,uint256)', + app: 'EigenLayer', + label: 'Deposit into strategy (withdrawals not included)', + }, + ], + approvals: [ + { spender: wstETH, token: stETH, app: 'Lido wstETH' }, + { spender: lidoQueue, token: stETH, app: 'Lido withdrawal queue' }, + + { + spender: address('0x858646372CC42E1A627fcE94aa7A7033e7CF075A'), + app: 'EigenLayer', + }, + ], + }, + { chainNamespace: 'eip155:11155111', allowedCalls: [ From 574a4002fd94b11754e3fd945b60e4c2a9b935f6 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Thu, 15 Oct 2026 08:18:09 +0530 Subject: [PATCH 08/27] fix(core): resolve AGW tokens by destination and expand live coverage --- packages/core/AGW.md | 5 + .../__e2e__/agw/presets-expanded-live.spec.ts | 748 ++++++++++++++++++ packages/core/__e2e__/ci/suite.ts | 41 + .../__tests__/runtime-token-chain.spec.ts | 69 ++ packages/core/src/lib/agentic/runtime.ts | 34 +- 5 files changed, 887 insertions(+), 10 deletions(-) create mode 100644 packages/core/__e2e__/agw/presets-expanded-live.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/runtime-token-chain.spec.ts diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 772201aaa..2873ebfeb 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -219,3 +219,8 @@ Run `node packages/core/scripts/check-agw-docs.js` to typecheck these examples. The opt-in `agw-presets` E2E group exercises real Donut-to-Sepolia protocol calls. From `packages/core`, run `npm run e2e:preflight -- --group agw-presets --dry-run`, then `npm run e2e:ci -- --group agw-presets`. It uses the existing E2E credentials in memory. Each scenario funds a fresh wallet with at most 65 PC plus a fresh agent's configured gas allocation; the Uniswap case also uses 0.000002 pETH. No mainnet transaction is part of this group. Uniswap uses destination WETH/Circle USDC; Aave uses its faucet LINK. Their generated preset calls and pins are retained, but bridge `assets` are set to `[]` because these destination tokens are not a supported SDK PRC20 pair. The owner funds the CEA separately. These tests establish swaps and lending against actual protocols, not the ability to bridge arbitrary assets. Missing liquidity, exhausted reserve caps or an unavailable faucet fail as prerequisites rather than silently skipping. Results are specific to Sepolia and do not certify the other catalog chains. + + +The additional opt-in group `agw-presets-expanded` covers EigenLayer deposits on Sepolia; Uniswap swaps on Base/Arbitrum Sepolia; PancakeSwap plus Venus on BNB testnet; and Aave plus Moonwell on Base Sepolia. Use the same preflight/run commands with `--group agw-presets-expanded`. Each scenario funds a fresh wallet with at most 100 PC and checks any required destination-native PRC20 before transfer. Native-wrap cases use 10^12 raw native-token units. These are deposit/swap/redemption checks, not borrow/repay or every-market coverage. + +AGW token resolution uses the destination chain for ordinary token objects: an explicit Base or Arbitrum ETH token must not select Sepolia's PRC20 merely because its symbol/address is identical. An explicit Push PRC20 reference must name the same source chain as the destination; a mismatch fails before signing. diff --git a/packages/core/__e2e__/agw/presets-expanded-live.spec.ts b/packages/core/__e2e__/agw/presets-expanded-live.spec.ts new file mode 100644 index 000000000..67b44a327 --- /dev/null +++ b/packages/core/__e2e__/agw/presets-expanded-live.spec.ts @@ -0,0 +1,748 @@ +/** Real protocol tests on supported testnets only. All signing uses Donut. + * Destination-only assets are prefunded; generated preset calls/pins are retained. + */ +import { + createPublicClient, + encodeFunctionData, + erc20Abi, + http, + parseAbi, + parseEther, + parseEventLogs, + type Address, + type Hex, +} from 'viem'; +import { PushChain, CHAIN, type Rule } from '../../src'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; +import { + AGW_E2E_ENABLED, + setupAgw, + evmClient, + inSeconds, + type AgwFixture, +} from './_fixture'; +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const ZERO = BigInt(0), + SMALL = BigInt(10) ** BigInt(12), + MAX_PC = parseEther('20'); +const rpc = { + [CHAIN.ETHEREUM_SEPOLIA]: 'https://ethereum-sepolia-rpc.publicnode.com', + [CHAIN.BASE_SEPOLIA]: 'https://base-sepolia-rpc.publicnode.com', + [CHAIN.ARBITRUM_SEPOLIA]: 'https://arbitrum-sepolia-rpc.publicnode.com', + [CHAIN.BNB_TESTNET]: 'https://bsc-testnet-rpc.publicnode.com', +}; +type Destination = keyof typeof rpc; +type Call = { to: Address; value: bigint; data: Hex }; +const ERC20 = erc20Abi; +const EXECUTED = parseAbi([ + 'event UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data)', +]); +const WETH = parseAbi(['function deposit() payable']); +const AAVE = parseAbi([ + 'function supply(address,uint256,address,uint16)', + 'function withdraw(address,uint256,address) returns(uint256)', +]); +const MARKET = parseAbi([ + 'function mint(uint256) returns(uint256)', + 'function redeem(uint256) returns(uint256)', + 'function balanceOf(address) view returns(uint256)', +]); +const read = (chain: Destination) => + createPublicClient({ transport: http(rpc[chain], { timeout: 20_000 }) }); +const call = (to: Address, data: Hex, value = ZERO): Call => ({ + to, + data, + value, +}); +const approve = (token: Address, spender: Address, amount: bigint) => + call( + token, + encodeFunctionData({ + abi: ERC20, + functionName: 'approve', + args: [spender, amount], + }) + ); +const balance = (chain: Destination, token: Address, owner: Address) => + read(chain).readContract({ + address: token, + abi: ERC20, + functionName: 'balanceOf', + args: [owner], + }); +const preset = ( + category: 'DEXES' | 'LENDING' | 'STAKING', + chain: Destination, + agent: Address, + tokens: { token: Address; maxPerCall: bigint; maxTotal: bigint }[] +): Rule => ({ + ...PushChain.utils.agentic.presets.get(category, { + chainNamespace: chain, + agent, + validUntil: inSeconds(7200), + maxGasPerCall: MAX_PC, + assets: tokens, + }).rules[0], + assets: [], +}); + +async function walletFor( + f: AgwFixture, + chain: Destination, + rule: Rule, + label: string +) { + const { wallet, rulesIds } = await f.owner.agentic.create(label, { + rules: [rule], + }); + const cea = (await getCEAAddress(wallet, chain)).cea as Address; + await f.fundPC(wallet, parseEther('100')); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + wallet + ); + const agent = await f.agent(wallet); + f.evidence('preset-expanded-wallet', { + label, + chain, + wallet, + cea, + rulesId: rulesIds[0], + }); + return { wallet, cea, owner, agent, rulesId: rulesIds[0] }; +} +async function send( + f: AgwFixture, + w: Awaited>, + chain: Destination, + client: PushChain, + calls: Call[], + stage: string, + funds?: { + token: typeof MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH; + amount: bigint; + } +) { + const tx = await client.universal.sendTransaction({ + to: { address: calls[0].to, chain }, + data: calls, + gasLimit: BigInt(2_000_000), + ...(funds ? { funds } : {}), + }); + f.evidence('preset-expanded-stage', { + chain, + wallet: w.wallet, + cea: w.cea, + stage: `${stage}-submitted`, + pushTx: tx.hash, + }); + const receipt = await tx.wait(); + expect(receipt.externalTxHash).toBeTruthy(); + const dest = await read(chain).getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + f.evidence('preset-expanded-stage', { + chain, + wallet: w.wallet, + cea: w.cea, + stage: `${stage}-completed`, + pushTx: tx.hash, + externalTx: receipt.externalTxHash, + status: dest.status, + }); + expect(dest.status).toBe('success'); + const events = parseEventLogs({ abi: EXECUTED, logs: dest.logs }); + for (const c of calls) + expect( + events.some( + (e) => + e.address.toLowerCase() === w.cea.toLowerCase() && + e.args.originCaller.toLowerCase() === w.wallet.toLowerCase() && + e.args.target.toLowerCase() === c.to.toLowerCase() && + e.args.data === c.data + ) + ).toBe(true); + return dest; +} +// Moonwell and Venus use different Redeem event signatures. Require the +// protocol's entire burned-share amount and payout to match the CEA balances. +function assertRedemption( + receipt: Awaited>, + market: Address, + cea: Address, + shares: bigint, + received: bigint +) { + const events = parseEventLogs({ + abi: parseAbi([ + 'event Redeem(address redeemer,uint256 redeemAmount,uint256 redeemTokens)', + 'event Redeem(address redeemer,uint256 redeemAmount,uint256 redeemTokens,uint256 totalSupply)', + ]), + logs: receipt.logs, + }); + const event = events.find( + (e) => + e.address.toLowerCase() === market.toLowerCase() && + e.args.redeemer.toLowerCase() === cea.toLowerCase() + ); + expect(event?.args.redeemTokens).toBe(shares); + expect(event?.args.redeemAmount).toBe(received); +} + +async function wrap( + f: AgwFixture, + w: Awaited>, + chain: Destination, + token: Address, + amount: bigint +) { + const prc20 = getNativePRC20ForChain(chain, f.manifest.network); + if ( + (await f.push.readContract({ + address: prc20, + abi: ERC20, + functionName: 'balanceOf', + args: [f.ownerAddress], + })) < amount + ) + throw new Error( + `PRESET_PREREQUISITE: master lacks native PRC20 for ${chain}` + ); + await ( + await f.owner.universal.sendTransaction({ + to: prc20, + data: encodeFunctionData({ + abi: ERC20, + functionName: 'transfer', + args: [w.wallet, amount], + }), + }) + ).wait(); + const native = + chain === CHAIN.BNB_TESTNET + ? MOVEABLE_TOKEN_CONSTANTS.BNB_TESTNET.BNB + : chain === CHAIN.BASE_SEPOLIA + ? MOVEABLE_TOKEN_CONSTANTS.BASE_SEPOLIA.ETH + : chain === CHAIN.ARBITRUM_SEPOLIA + ? MOVEABLE_TOKEN_CONSTANTS.ARBITRUM_SEPOLIA.ETH + : MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH; + await send( + f, + w, + chain, + w.owner, + [ + call( + token, + encodeFunctionData({ abi: WETH, functionName: 'deposit' }), + amount + ), + ], + 'wrap', + { token: native, amount } + ); + expect(await balance(chain, token, w.cea)).toBe(amount); +} + +d('agw presets expanded', () => { + let f: AgwFixture; + afterEach(() => f?.teardown()); + it('1. EigenLayer Sepolia deposit credits shares to the wallet', async () => { + const chain = CHAIN.ETHEREUM_SEPOLIA; + const manager = '0x2E3D6c0744b10eb0A4e6F679F71554a39Ec47a5D' as Address; + const strategy = '0x424246eF71b01ee33aA33aC590fd9a0855F5eFbc' as Address; + const abi = parseAbi([ + 'function strategyIsWhitelistedForDeposit(address) view returns(bool)', + 'function paused() view returns(uint256)', + 'function depositIntoStrategy(address,address,uint256) returns(uint256)', + 'function stakerDepositShares(address,address) view returns(uint256)', + ]); + expect( + await read(chain).readContract({ + address: manager, + abi, + functionName: 'strategyIsWhitelistedForDeposit', + args: [strategy], + }) + ).toBe(true); + expect( + (await read(chain).readContract({ + address: manager, + abi, + functionName: 'paused', + })) & BigInt(1) + ).toBe(ZERO); + const token = await read(chain).readContract({ + address: strategy, + abi: parseAbi(['function underlyingToken() view returns(address)']), + functionName: 'underlyingToken', + }); + f = await setupAgw(); + const rule = preset('STAKING', chain, f.agentAddress, [ + { token, maxPerCall: SMALL, maxTotal: SMALL }, + ]); + const w = await walletFor(f, chain, rule, 'e2e-preset-eigen'); + await wrap(f, w, chain, token, SMALL); + const before = await read(chain).readContract({ + address: manager, + abi, + functionName: 'stakerDepositShares', + args: [w.cea, strategy], + }); + await send( + f, + w, + chain, + w.agent, + [ + approve(token, manager, SMALL), + call( + manager, + encodeFunctionData({ + abi, + functionName: 'depositIntoStrategy', + args: [strategy, token, SMALL], + }) + ), + ], + 'eigen-deposit' + ); + const after = await read(chain).readContract({ + address: manager, + abi, + functionName: 'stakerDepositShares', + args: [w.cea, strategy], + }); + expect(after).toBeGreaterThan(before); + expect(await balance(chain, token, w.cea)).toBe(ZERO); + f.evidence('preset-eigen-live', { + wallet: w.wallet, + cea: w.cea, + strategy, + amount: SMALL, + shares: after - before, + }); + }, 900_000); + const dexCases = [ + { + id: 2, + chain: CHAIN.BASE_SEPOLIA, + wrapped: '0x4200000000000000000000000000000000000006', + output: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + fee: 3000, + }, + { + id: 3, + chain: CHAIN.ARBITRUM_SEPOLIA, + wrapped: '0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', + output: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + fee: 3000, + }, + { + id: 4, + chain: CHAIN.BNB_TESTNET, + wrapped: '0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', + output: '0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c', + fee: 100, + }, + ] as const; + async function dexAndLend({ + chain, + wrapped, + output, + fee, + }: (typeof dexCases)[number]) { + const discovery = PushChain.utils.agentic.presets.get('DEXES', { + chainNamespace: chain, + }).rules[0]; + const router = discovery.allowedCalls[0].target as Address; + const factory = await read(chain).readContract({ + address: router, + abi: parseAbi(['function factory() view returns(address)']), + functionName: 'factory', + }); + const pool = await read(chain).readContract({ + address: factory, + abi: parseAbi([ + 'function getPool(address,address,uint24) view returns(address)', + ]), + functionName: 'getPool', + args: [wrapped, output, fee], + }); + if (/^0x0+$/.test(pool)) + throw new Error(`PRESET_PREREQUISITE: ${chain} pool missing`); + expect( + await read(chain).readContract({ + address: pool, + abi: parseAbi(['function liquidity() view returns(uint128)']), + functionName: 'liquidity', + }) + ).toBeGreaterThan(ZERO); + f = await setupAgw(); + const w = await walletFor( + f, + chain, + preset('DEXES', chain, f.agentAddress, [ + { token: wrapped, maxPerCall: SMALL, maxTotal: SMALL }, + { token: output, maxPerCall: SMALL, maxTotal: SMALL }, + ]), + `e2e-preset-dex-${chain}` + ); + await wrap(f, w, chain, wrapped, SMALL); + const abi = parseAbi([ + 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns(uint256)', + ]); + const swap = (recipient: Address, amountIn = SMALL) => + call( + router, + encodeFunctionData({ + abi, + functionName: 'exactInputSingle', + args: [ + { + tokenIn: wrapped, + tokenOut: output, + fee, + recipient, + amountIn, + amountOutMinimum: BigInt(1), + sqrtPriceLimitX96: ZERO, + }, + ], + }) + ); + await expect( + w.agent.universal.sendTransaction({ + to: { address: router, chain }, + gasLimit: BigInt(2_000_000), + data: [swap(f.agentAddress)], + }) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect( + w.agent.universal.sendTransaction({ + to: { address: router, chain }, + gasLimit: BigInt(2_000_000), + data: [swap(w.cea, SMALL + BigInt(1))], + }) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + const before = await balance(chain, output, w.cea); + await send( + f, + w, + chain, + w.agent, + [approve(wrapped, router, SMALL), swap(w.cea)], + 'dex-swap' + ); + const received = (await balance(chain, output, w.cea)) - before; + expect(received).toBeGreaterThan(ZERO); + expect(await balance(chain, wrapped, w.cea)).toBe(ZERO); + f.evidence('preset-expanded-dex', { + chain, + wallet: w.wallet, + cea: w.cea, + pool, + router, + amountIn: SMALL, + amountOut: received, + }); + if (chain === CHAIN.BASE_SEPOLIA) return; // its Aave reserve uses a different test token; exercised in case 5. + const rule = preset('LENDING', chain, f.agentAddress, [ + { token: output, maxPerCall: received, maxTotal: received }, + ]); + await f.owner.agentic + .wallet(w.wallet) + .rules.update({ rules: [{ rulesId: w.rulesId, rule }] }); + if (chain === CHAIN.ARBITRUM_SEPOLIA) { + const aave = rule.allowedCalls.find((c) => c.app === 'Aave V3')! + .target as Address; + await send( + f, + w, + chain, + w.agent, + [ + approve(output, aave, received), + call( + aave, + encodeFunctionData({ + abi: AAVE, + functionName: 'supply', + args: [output, received, w.cea, 0], + }) + ), + ], + 'aave-supply' + ); + expect(await balance(chain, output, w.cea)).toBe(before); + await send( + f, + w, + chain, + w.agent, + [ + call( + aave, + encodeFunctionData({ + abi: AAVE, + functionName: 'withdraw', + args: [output, received, w.cea], + }) + ), + ], + 'aave-withdraw' + ); + expect(await balance(chain, output, w.cea)).toBe(before + received); + f.evidence('preset-expanded-aave', { + chain, + wallet: w.wallet, + cea: w.cea, + amount: received, + asset: output, + }); + } else { + const venus = rule.allowedCalls.find((c) => c.app === 'Venus vUSDT')! + .target as Address; + await send( + f, + w, + chain, + w.agent, + [ + approve(output, venus, received), + call( + venus, + encodeFunctionData({ + abi: MARKET, + functionName: 'mint', + args: [received], + }) + ), + ], + 'venus-mint' + ); + const shares = await balance(chain, venus, w.cea); + expect(shares).toBeGreaterThan(ZERO); + expect(await balance(chain, output, w.cea)).toBe(before); + const redemption = await send( + f, + w, + chain, + w.agent, + [ + call( + venus, + encodeFunctionData({ + abi: MARKET, + functionName: 'redeem', + args: [shares], + }) + ), + ], + 'venus-redeem' + ); + const redeemed = (await balance(chain, output, w.cea)) - before; + expect(await balance(chain, venus, w.cea)).toBe(ZERO); + assertRedemption(redemption, venus, w.cea, shares, redeemed); + expect(redeemed).toBeGreaterThanOrEqual( + (received * BigInt(99)) / BigInt(100) + ); + f.evidence('preset-expanded-venus', { + chain, + wallet: w.wallet, + cea: w.cea, + deposited: received, + redeemed, + shares, + asset: output, + }); + } + } + it('2. DEX swap on eip155:84532', () => dexAndLend(dexCases[0]), 1_200_000); + it( + '3. DEX swap and Aave round trip on eip155:421614', + () => dexAndLend(dexCases[1]), + 1_200_000 + ); + it( + '4. DEX swap and Venus round trip on eip155:97', + () => dexAndLend(dexCases[2]), + 1_200_000 + ); + + it('5. Base Aave and both Moonwell markets supply and redeem wallet positions', async () => { + const chain = CHAIN.BASE_SEPOLIA; + const link = '0x810D46F9a9027E28F9B01F75E2bdde839dA61115' as Address; + const usdbc = '0x4616975f7E6045FD9737e95bC527e9e14c727BD2' as Address; + const weth = '0x663A6F02d547dFd639BF689FED298712f2FB69Fb' as Address; + const faucet = '0xD9145b5F45Ad4519c7ACcD6E0A4A82e83bB8A6Dc' as Address; + const amountUSDBC = BigInt(10_000); + expect(await read(chain).getCode({ address: faucet })).not.toBeUndefined(); + f = await setupAgw(); + const rule = preset('LENDING', chain, f.agentAddress, [ + { token: link, maxPerCall: SMALL, maxTotal: SMALL }, + { token: usdbc, maxPerCall: amountUSDBC, maxTotal: amountUSDBC }, + { token: weth, maxPerCall: SMALL, maxTotal: SMALL }, + ]); + const w = await walletFor(f, chain, rule, 'e2e-preset-base-lending'); + const aave = rule.allowedCalls.find((c) => c.app === 'Aave V3')! + .target as Address; + const mUSDBC = rule.allowedCalls.find((c) => c.app === 'Moonwell mUSDbC')! + .target as Address; + const mWETH = rule.allowedCalls.find((c) => c.app === 'Moonwell mWETH')! + .target as Address; + await send( + f, + w, + chain, + w.owner, + [ + call( + faucet, + encodeFunctionData({ + abi: parseAbi(['function mint(address,address,uint256)']), + functionName: 'mint', + args: [link, w.cea, SMALL], + }) + ), + call( + usdbc, + encodeFunctionData({ + abi: parseAbi(['function allocateTo(address,uint256)']), + functionName: 'allocateTo', + args: [w.cea, amountUSDBC], + }) + ), + call( + weth, + encodeFunctionData({ + abi: parseAbi(['function mint(address,uint256)']), + functionName: 'mint', + args: [w.cea, SMALL], + }) + ), + ], + 'faucets' + ); + expect(await balance(chain, link, w.cea)).toBe(SMALL); + expect(await balance(chain, usdbc, w.cea)).toBe(amountUSDBC); + expect(await balance(chain, weth, w.cea)).toBe(SMALL); + await send( + f, + w, + chain, + w.agent, + [ + approve(link, aave, SMALL), + call( + aave, + encodeFunctionData({ + abi: AAVE, + functionName: 'supply', + args: [link, SMALL, w.cea, 0], + }) + ), + approve(usdbc, mUSDBC, amountUSDBC), + call( + mUSDBC, + encodeFunctionData({ + abi: MARKET, + functionName: 'mint', + args: [amountUSDBC], + }) + ), + approve(weth, mWETH, SMALL), + call( + mWETH, + encodeFunctionData({ + abi: MARKET, + functionName: 'mint', + args: [SMALL], + }) + ), + ], + 'lending-supply' + ); + const sharesUSDBC = await balance(chain, mUSDBC, w.cea), + sharesWETH = await balance(chain, mWETH, w.cea); + expect(sharesUSDBC).toBeGreaterThan(ZERO); + expect(sharesWETH).toBeGreaterThan(ZERO); + expect(await balance(chain, link, w.cea)).toBe(ZERO); + expect(await balance(chain, usdbc, w.cea)).toBe(ZERO); + expect(await balance(chain, weth, w.cea)).toBe(ZERO); + const redemption = await send( + f, + w, + chain, + w.agent, + [ + call( + aave, + encodeFunctionData({ + abi: AAVE, + functionName: 'withdraw', + args: [link, SMALL, w.cea], + }) + ), + call( + mUSDBC, + encodeFunctionData({ + abi: MARKET, + functionName: 'redeem', + args: [sharesUSDBC], + }) + ), + call( + mWETH, + encodeFunctionData({ + abi: MARKET, + functionName: 'redeem', + args: [sharesWETH], + }) + ), + ], + 'lending-withdraw' + ); + expect(await balance(chain, link, w.cea)).toBe(SMALL); + expect(await balance(chain, usdbc, w.cea)).toBeGreaterThanOrEqual( + (amountUSDBC * BigInt(99)) / BigInt(100) + ); + expect(await balance(chain, weth, w.cea)).toBeGreaterThanOrEqual( + (SMALL * BigInt(99)) / BigInt(100) + ); + expect(await balance(chain, mUSDBC, w.cea)).toBe(ZERO); + expect(await balance(chain, mWETH, w.cea)).toBe(ZERO); + assertRedemption( + redemption, + mUSDBC, + w.cea, + sharesUSDBC, + await balance(chain, usdbc, w.cea) + ); + assertRedemption( + redemption, + mWETH, + w.cea, + sharesWETH, + await balance(chain, weth, w.cea) + ); + f.evidence('preset-expanded-base-lending', { + chain, + wallet: w.wallet, + cea: w.cea, + link: SMALL, + usdbc: amountUSDBC, + weth: SMALL, + sharesUSDBC, + sharesWETH, + }); + }, 1_200_000); +}); diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index c0869ece5..56dd7c9a3 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -38,6 +38,7 @@ export const GROUPS = [ 'agw-svm-wire', 'agw-svm-replay', 'agw-presets', + 'agw-presets-expanded', ] as const; export type Group = (typeof GROUPS)[number]; @@ -985,6 +986,46 @@ export const SCENARIOS: Scenario[] = [ needs: { masterPC: '0.5' }, env: { AGW_E2E: '1' }, }, + { + id: 'agw-preset-eigen-sepolia', + group: 'agw-presets-expanded', + file: '__e2e__/agw/presets-expanded-live.spec.ts', + grep: 'agw presets expanded 1', + needs: { masterPC: '110', masterPETH: '0.000001' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-preset-dex-base', + group: 'agw-presets-expanded', + file: '__e2e__/agw/presets-expanded-live.spec.ts', + grep: 'agw presets expanded 2', + needs: { masterPC: '110' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-preset-dex-aave-arbitrum', + group: 'agw-presets-expanded', + file: '__e2e__/agw/presets-expanded-live.spec.ts', + grep: 'agw presets expanded 3', + needs: { masterPC: '110' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-preset-dex-venus-bnb', + group: 'agw-presets-expanded', + file: '__e2e__/agw/presets-expanded-live.spec.ts', + grep: 'agw presets expanded 4', + needs: { masterPC: '110' }, + env: { AGW_E2E: '1' }, + }, + { + id: 'agw-preset-base-aave-moonwell', + group: 'agw-presets-expanded', + file: '__e2e__/agw/presets-expanded-live.spec.ts', + grep: 'agw presets expanded 5', + needs: { masterPC: '110' }, + env: { AGW_E2E: '1' }, + }, { id: 'agw-preset-uniswap-live', group: 'agw-presets', diff --git a/packages/core/src/lib/agentic/__tests__/runtime-token-chain.spec.ts b/packages/core/src/lib/agentic/__tests__/runtime-token-chain.spec.ts new file mode 100644 index 000000000..aff9e9c98 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/runtime-token-chain.spec.ts @@ -0,0 +1,69 @@ +import { getAddress } from 'viem'; +import { createAgenticRuntime } from '../runtime'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { SYNTHETIC_PUSH_ERC20 } from '../../constants/chain'; +import { + MOVEABLE_TOKEN_CONSTANTS, + type PushChainMoveableToken, +} from '../../constants/tokens'; +import type { Orchestrator } from '../../orchestrator/orchestrator'; +const runtime = () => + createAgenticRuntime( + { + getNetwork: () => PUSH_NETWORK.TESTNET_DONUT, + getRpcUrls: () => ({}), + } as unknown as Orchestrator, + false + ); +const addresses = SYNTHETIC_PUSH_ERC20[PUSH_NETWORK.TESTNET_DONUT]; +it.each([ + [ + CHAIN.ETHEREUM_SEPOLIA, + MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH, + addresses.pETH, + ], + [ + CHAIN.BASE_SEPOLIA, + MOVEABLE_TOKEN_CONSTANTS.BASE_SEPOLIA.ETH, + addresses.pETH_BASE, + ], + [ + CHAIN.ARBITRUM_SEPOLIA, + MOVEABLE_TOKEN_CONSTANTS.ARBITRUM_SEPOLIA.ETH, + addresses.pETH_ARB, + ], +] as const)( + 'resolves explicit native ETH on %s to the same burn token as omitted native funds', + (chain, token, expected) => { + const rt = runtime(); + expect(rt.resolvePrc20(token, chain)).toBe(getAddress(expected)); + expect(rt.resolvePrc20({ ...token }, chain)).toBe( + rt.resolvePrc20(undefined, chain) + ); + expect(rt.resolvePrc20(token.address, chain)).toBe(getAddress(expected)); + } +); +it('does not infer a token from a different destination chain', () => { + expect(() => + runtime().resolvePrc20( + MOVEABLE_TOKEN_CONSTANTS.BASE_SEPOLIA.USDC, + CHAIN.ARBITRUM_SEPOLIA + ) + ).toThrow(); +}); +it('preserves a matching explicit Push PRC20 reference and rejects a different destination', () => { + const token: PushChainMoveableToken = { + symbol: 'pETH_ARB', + decimals: 18, + mechanism: 'approve', + address: addresses.pETH_ARB, + prc20Address: addresses.pETH_ARB, + sourceChain: CHAIN.ARBITRUM_SEPOLIA, + }; + expect(runtime().resolvePrc20(token, CHAIN.ARBITRUM_SEPOLIA)).toBe( + getAddress(addresses.pETH_ARB) + ); + expect(() => runtime().resolvePrc20(token, CHAIN.BASE_SEPOLIA)).toThrow( + 'source chain must match' + ); +}); diff --git a/packages/core/src/lib/agentic/runtime.ts b/packages/core/src/lib/agentic/runtime.ts index 0068f20ea..530e672ed 100644 --- a/packages/core/src/lib/agentic/runtime.ts +++ b/packages/core/src/lib/agentic/runtime.ts @@ -2,7 +2,11 @@ import { getAddress, type Address } from 'viem'; import { CHAIN, PUSH_NETWORK } from '../constants/enums'; import type { MoveableToken } from '../constants/tokens'; import { AtomicBatchUnavailableError } from '../orchestrator/internals/errors'; -import { capabilityUnavailable } from './errors'; +import { + AgenticError, + AGENTIC_ERROR_CODE, + capabilityUnavailable, +} from './errors'; import type { Orchestrator } from '../orchestrator/orchestrator'; import type { ExecuteParams, @@ -162,15 +166,25 @@ export function createAgenticRuntime( ); return { cea: getAddress(r.cea), isDeployed: r.isDeployed }; }, - resolvePrc20: (token, chain) => - getAddress( - token - ? getPRC20Address( - typeof token === 'string' ? { chain, address: token } : token, - { network } - ).address - : getNativePRC20ForChain(chain, network) - ), + resolvePrc20: (token, chain) => { + if (!token) return getAddress(getNativePRC20ForChain(chain, network)); + // Native ETH shares a symbol/address across chains. A plain token must + // resolve within the known destination, never the first global match. + if (typeof token === 'object' && 'prc20Address' in token) { + if (!('sourceChain' in token) || token.sourceChain !== chain) + throw new AgenticError( + AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, + 'Push token source chain must match the destination chain' + ); + return getAddress(getPRC20Address(token, { network }).address); + } + return getAddress( + getPRC20Address( + { chain, address: typeof token === 'string' ? token : token.address }, + { network } + ).address + ); + }, nowSeconds: () => Math.floor(Date.now() / 1000), }; } From 5bc31222071034cd03b904fe3084b09a9bda759c Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Thu, 15 Oct 2026 08:45:44 +0530 Subject: [PATCH 09/27] fix(release): sync lockfile for core alpha version --- yarn.lock | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 1a09b3638..aa597ef3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4223,7 +4223,38 @@ __metadata: languageName: node linkType: hard -"@pushchain/core@npm:^6.0.26, @pushchain/core@workspace:packages/core": +"@pushchain/core@npm:^6.0.26": + version: 6.0.26 + resolution: "@pushchain/core@npm:6.0.26" + dependencies: + "@bufbuild/protobuf": "npm:^2.0.0" + "@cosmjs/crypto": "npm:0.33.1" + "@cosmjs/encoding": "npm:0.33.1" + "@cosmjs/proto-signing": "npm:^0.33.1" + "@cosmjs/stargate": "npm:^0.33.1" + "@cosmjs/tendermint-rpc": "npm:0.33.1" + "@solana/web3.js": "npm:^1.68.0" + bs58: "npm:^4.0.0" + buffer: "npm:^6.0.3" + cosmjs-types: "npm:0.9.0" + tslib: "npm:^2.3.0" + viem: "npm:^2.27.2" + peerDependencies: + "@coral-xyz/anchor": ^0.31.0 + ethers: ^6.0.0 + tweetnacl: ^1.0.3 + peerDependenciesMeta: + "@coral-xyz/anchor": + optional: true + ethers: + optional: true + tweetnacl: + optional: true + checksum: 10/dea82562aed21d42c7fa1d4a3bb6c852baa82cccc01e687de4b978b304fdf7f3709198dd4a51f0b2530f8677b202a82c6632b3a159e5ca3ea03efcd81cc4f360 + languageName: node + linkType: hard + +"@pushchain/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@pushchain/core@workspace:packages/core" dependencies: From 3e9bcd67bf930d256fb53fb0911317f0c6506991 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Thu, 15 Oct 2026 08:45:38 +0530 Subject: [PATCH 10/27] fix(release): refresh and commit lockfile after version bumps --- scripts/release-automated.sh | 8 ++++++-- scripts/release.sh | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/release-automated.sh b/scripts/release-automated.sh index 04b4476c8..187a4dba5 100755 --- a/scripts/release-automated.sh +++ b/scripts/release-automated.sh @@ -44,6 +44,10 @@ else npx ts-node scripts/bumpAndGenerateChangelog.ts "$SCOPE" "$BUMP" fi +# Version bumps can change workspace semver matching (especially stable -> alpha). +# Refresh the lockfile deliberately; the workflow's initial install stays immutable. +YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --mode=update-lockfile + # Step 2: Build the package npx nx run "$SCOPE:build" @@ -68,8 +72,8 @@ else npm publish "$PACKAGE_DIR" --access public fi -# Step 5: Commit version + changelog -git add "packages/$SCOPE/package.json" "packages/$SCOPE/CHANGELOG.md" +# Step 5: Commit version + changelog + lockfile +git add "packages/$SCOPE/package.json" "packages/$SCOPE/CHANGELOG.md" yarn.lock git commit -m "release($SCOPE): bump to $VERSION [skip ci]" # Step 6: Git tag + push diff --git a/scripts/release.sh b/scripts/release.sh index f1f9fb078..56fa80450 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -36,6 +36,10 @@ echo "🚀 Releasing @$SCOPE with a $BUMP bump..." # Step 1: Bump version and generate changelog markdown ts-node scripts/bumpAndGenerateChangelog.ts "$SCOPE" "$BUMP" +# Version bumps can change workspace semver matching (especially stable -> alpha). +# Refresh the lockfile deliberately; the workflow's initial install stays immutable. +YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --mode=update-lockfile + # Step 2: Build the package nx run "$SCOPE:build" @@ -46,8 +50,8 @@ VERSION=$(node -p "require('./packages/$SCOPE/package.json').version") npm publish "$PACKAGE_DIR" --access public --otp="$OTP" -# Step 4: Commit version + changelog -git add "packages/$SCOPE/package.json" "packages/$SCOPE/CHANGELOG.md" +# Step 4: Commit version + changelog + lockfile +git add "packages/$SCOPE/package.json" "packages/$SCOPE/CHANGELOG.md" yarn.lock git commit -m "release($SCOPE): bump to $VERSION" # Step 5: Git tag + push From 50578791b45c495931fe4400a6dcc4e8162b3e54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 15 Oct 2026 03:19:17 +0000 Subject: [PATCH 11/27] release(core): bump to 6.0.27-alpha.1 [skip ci] --- packages/core/CHANGELOG.md | 10 ++++++++++ packages/core/package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 5e5f06df6..012da9dde 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,13 @@ +@pushchain/core@6.0.27-alpha.1 (2026-10-15) + +- fix: resolve AGW tokens by destination and expand live coverage +- feat: add mainnet AGW presets and live protocol coverage +- fix: return safe AGW preset review templates +- feat: add verified testnet AGW preset templates +- feat: migrate AGW SDK to deployed v6 contracts + +--- + @pushchain/core@6.0.27-alpha.0 (2026-10-12) - feat: align AGW rules and presets with October SDK feedback diff --git a/packages/core/package.json b/packages/core/package.json index 012472b99..a27b13ca6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.27-alpha.0", + "version": "6.0.27-alpha.1", "homepage": "https://push.org", "keywords": [ "pushchain", From a611305c1a815629a02420fcacddabce73f73113 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Fri, 16 Oct 2026 19:42:26 +0530 Subject: [PATCH 12/27] feat(core): add bounded preset operations and configuration readiness --- packages/core/AGW.md | 61 ++++- .../core/__agw-local__/v5-multiasset.spec.ts | 123 +++++++++- .../__e2e__/agw/presets-expanded-live.spec.ts | 97 ++++++-- .../core/__e2e__/agw/presets-live.spec.ts | 116 +++++++-- .../agentic/__tests__/preset-catalog.spec.ts | 43 ++-- .../__tests__/preset-operation-limits.spec.ts | 220 ++++++++++++++++++ .../src/lib/agentic/__tests__/presets.spec.ts | 5 +- packages/core/src/lib/agentic/index.ts | 4 + .../src/lib/agentic/presets/aave-reserves.ts | 146 ++++++++++++ .../core/src/lib/agentic/presets/catalog.ts | 129 ++++++++-- .../core/src/lib/agentic/presets/index.ts | 111 ++++++++- .../lib/agentic/presets/operation-limits.ts | 116 +++++++++ .../core/src/lib/agentic/presets/types.ts | 62 ++++- packages/core/src/lib/index.ts | 4 + 14 files changed, 1147 insertions(+), 90 deletions(-) create mode 100644 packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts create mode 100644 packages/core/src/lib/agentic/presets/aave-reserves.ts create mode 100644 packages/core/src/lib/agentic/presets/operation-limits.ts diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 2873ebfeb..7d5b71be6 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -91,7 +91,7 @@ An equality pin fixes an argument; a maximum pin bounds it. For EVM signatures, `PushChain.CONSTANTS.AGENTIC.WALLET` resolves to the AGW on Push, its CEA on an EVM destination, or its CEA/selected token ATA on Solana. Deployment addresses and encoding helpers are internal. -Omitted `maxValue`, `assets[].maxTotal`, and `maxGasPerCall` default to **zero**, which permits no corresponding movement. Specify positive caps for sends that need value, tokens or destination gas. Native value is limited per call; there is no public native lifetime-total cap. +Omitted `maxValue`, `assets[].maxTotal`, and `maxGasPerCall` default to **zero**. Asset totals meter tokens sent from Push; they do not meter trading or lending using capital already in the destination CEA. Specify positive caps for sends that need value, tokens or destination gas. Native value is limited per call; there is no public native lifetime-total cap. ## EVM destinations @@ -171,7 +171,7 @@ The wire encoder uses uint32 maximum for unlimited native calls and 255 for unsp - DEXES: Uniswap V3 on Ethereum, Base and Arbitrum (mainnet and their Sepolia testnets); PancakeSwap V3 on BNB mainnet and BSC testnet. - LENDING: Aave on Ethereum, Base, Arbitrum and BNB mainnets, plus Sepolia/Base Sepolia/Arbitrum Sepolia; Moonwell on Base mainnet/testnet and Venus on BNB mainnet/testnet. -- STAKING: EigenLayer deposits on Ethereum mainnet and Sepolia (EigenLayer withdrawals excluded); Lido stake, wrap/unwrap and withdrawal requests/claims on Ethereum mainnet. Lido ETH staking defaults to `maxValue: 0`; explicitly set a value cap before use. +- STAKING: EigenLayer deposits on Ethereum mainnet and Sepolia; Lido stake and wrap/unwrap on Ethereum mainnet. Withdrawal requests with dynamic amounts, claims with position-derived amounts and EigenLayer withdrawals are excluded from these bounded presets. A selected native asset supplies the Lido `submit` value cap; omitted caps remain zero. - PREDICTION: no entries yet. Hide categories with empty `chains` lists. ```ts @@ -195,11 +195,11 @@ function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { { token: tokenOut, maxPerCall: BigInt(100), maxTotal: BigInt(1000) }, ], }, - ).rules; + ); } ``` -The example caps are illustrative raw units; choose caps for the actual token decimals and gas requirements. `get` groups calls into one rule per chain. With agent and expiry supplied, its return type is `Rule[]`; otherwise it is `PresetRuleTemplate[]`, requiring those fields before `create`. Assets must name their chain unless a single chain filter is supplied. Omitted asset caps and `maxGasPerCall` default to zero. +The example caps are illustrative raw units; choose caps for the actual token decimals and gas requirements. `get` groups calls into one rule per chain. The result is `{ rules, readiness }`. With agent and expiry supplied, `rules` is typed as `Rule[]`; otherwise it is `PresetRuleTemplate[]`. This type distinction alone does not establish readiness. Assets must name their chain unless a single chain filter is supplied. Omitted asset caps and `maxGasPerCall` default to zero. DEX discovery works without options, with an empty asset list, or with assets for only some chains. A chain with fewer than two tokens returns full-signature review calls with wallet-recipient pins and an explicit zero input-amount maximum. A single supplied token is also pinned as the input. These calls cannot spend swap input tokens, even if asset budgets are filled later. Call `get` again after selecting both tokens to replace the review calls with the complete token-pinned rules; merely assigning `rule.assets` is insufficient. @@ -209,6 +209,59 @@ Reviewed EVM spenders generate approvals pinned to that spender and bounded by t Templates describe permissions, not available liquidity or guaranteed execution. Verify destination token balances, supported reserves and source PRC20 routes before use: protocol testnet mock tokens may differ from MOVEABLE tokens. Presets do not sign, fetch app ABIs or modify wallets. Dynamic-path swaps, Solana protocol templates and unconfirmed deployments are excluded. Mainnet addresses were checked read-only; no mainnet transactions were executed. Other mainnet protocols in the proposal remain excluded until their deployment and permission constraints are verified. Local policy tests do not execute the destination protocols. +### Configure, inspect and regenerate + +`readiness.ready` means the returned chains have locally complete inputs and at least one enabled asset operation. It does not check PRC20 availability, deployments, balances, live reserve flags or liquidity. `readiness.chains` gives per-chain `issues`, `disabledCalls`, `excludedCalls`, catalog `knownTokens` and `zeroBridgeLimits`. These are additive result fields; `rules` remains accepted by the existing create/update methods. + +The UI should collect chain, agent, token selections, raw-unit limits, gas budget and expiry. Show zero-cap calls as disabled. Present excluded calls explicitly. There is no SDK-selected 0.1 PC gas budget or 30-day expiry. A zero bridge limit can be intentional when assets are already at the destination; it is reported separately from operation readiness. + +```ts +async function createConfiguredDex( + client: PushChain, agent: Address, tokenIn: Address, tokenOut: Address, +) { + const configured = filledPreset(agent, tokenIn, tokenOut); + if (!configured.readiness.ready) { + // Render these issues in the configuration screen; do not present it as ready. + return { issues: configured.readiness.chains }; + } + return client.agentic.create('My DEX agent', { rules: configured.rules }); +} + +async function replaceConfiguredDex( + client: PushChain, wallet: Address, rulesId: Hex, + agent: Address, tokenIn: Address, tokenOut: Address, +) { + // Re-run get after editing tokens or limits. Changing rule.assets alone + // does not regenerate allowedCalls, token pins or approvals. + const configured = filledPreset(agent, tokenIn, tokenOut); + if (!configured.readiness.ready) throw new Error('Complete the preset inputs'); + return client.agentic.wallet(wallet).rules.update({ + rules: [{ rulesId, rule: configured.rules[0] }], + }); +} +``` + +Save the preset category and editable inputs in the UI if subsequent edits should regenerate the preset. On-chain rules do not retain preset provenance or display metadata. A regenerated result is a replacement: do not append it to the old allowedCalls or keep the old token approvals. An update creates a new rulesId and resets counters, as documented above. + +### Amount units and enforcement scope + +| Preset operation | Generated restriction | +| --- | --- | +| DEX swap | Selected input/output token equality, wallet recipient, maximum input amount | +| Aave supply/withdraw/borrow/repay | Listed reserve token equality, wallet beneficiary, amount maximum | +| Moonwell/Venus mint/redeemUnderlying/borrow/repayBorrow | Maximum in the market's underlying-token units | +| EigenLayer deposit | Selected token equality and deposit amount maximum | +| Lido wrap/unwrap | stETH/wstETH input limits respectively; their units are not interchangeable | +| Lido submit | Native-value cap from the selected native asset's maxPerCall | + +Aave reserve choices come from the reviewed official address book; fixed-token markets only enable for their actual underlying. Unknown assets do not receive an Aave approval or a positive Aave operation cap. A reserve listing is not a guarantee that its current supply cap, pause state or liquidity allows a transaction. + +Market `redeem(uint256)` takes shares: it is excluded and `redeemUnderlying(uint256)` is provided instead. Lido's dynamic withdrawal arrays and position-based claims are also excluded because an underlying amount cap cannot safely be converted into those arguments by this pure helper. Custom rules remain possible through the normal rule API. + +**A per-call limit applies to each allowed contract call, not the sum of several calls in one transaction. `maxTotal` is the cumulative Push-outbound amount, not lifetime turnover on the destination.** These statements also hold when a call's tokens are already in the CEA. Destination lifetime or batch-total accounting requires contract support. Bounded approvals do not supply such accounting. + +The contract allows at most 32 pins across a universal rule. Expansion fails explicitly if the selected assets/apps exceed that ceiling; no restrictions are silently dropped. Two Aave reserves fit its four-operation template; three do not. Blank/incomplete inputs return zero-cap review calls, and regeneration replaces them with the selected token-specific calls. + ## Validation Run `node packages/core/scripts/check-agw-docs.js` to typecheck these examples. Unit tests use the normal core Jest configuration. `jest.agw-local.config.ts` uses pinned real AGW contracts and a stub gateway. Live E2Es are opt-in under `__e2e__/agw`; preflight verifies the deployment and funding limits. Local tests do not prove destination delivery, and historical live results do not count as validation of a changed public API. diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 57290f618..ba06384a2 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -1,4 +1,5 @@ import { agenticPresets } from '../src/lib/agentic/presets'; +import * as accountLookup from '../src/lib/universal/account/account'; import { buildPresetApi } from '../src/lib/agentic/presets'; import { publicRules } from '../src/lib/agentic/public-rule'; /** Actual v6 AGW/URP/engine. Token/core/gateway and destination lookup are local fixtures; no bridge/TSS proof. */ @@ -39,7 +40,13 @@ const internal = (c: PushChain) => describe('v6 multi-asset SDK management and execution', () => { let h: Harness, owner: PushChain, a: Address, b: Address; + let originLookup: jest.SpyInstance; beforeAll(async () => { + // This harness deploys no UEA factory; its receivers are local contracts. + // Keep actual AGW/RPC execution while isolating response-origin metadata. + originLookup = jest + .spyOn(accountLookup, 'convertExecutorToOrigin') + .mockResolvedValue({ account: null, exists: false }); h = await startHarness(18551); owner = await h.client(0); a = await h.deploy(0, 'HarnessPRC20', 'HarnessFixtures.sol', [CHAIN_NS, 6]); @@ -54,7 +61,11 @@ describe('v6 multi-asset SDK management and execution', () => { configure(internal(owner)); }); afterAll(async () => { - await h?.stop(); + try { + await h?.stop(); + } finally { + originLookup?.mockRestore(); + } }); function configure(rt: AgenticRuntime) { // Fixture source token addresses equal fixture PRC20s; production uses the token registry. @@ -333,6 +344,116 @@ describe('v6 multi-asset SDK management and execution', () => { } ); + it.each(['LENDING', 'STAKING'] as const)( + 'regenerated %s presets enforce new token/amount pins through atomic rules.update', + async (category) => { + const firstToken = getAddress( + '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5' + ); + const nextToken = getAddress( + '0x29f2D40B0605204364af54EC677bD022dA425d03' + ); + const opts = { + chainNamespace: CHAIN_NS, + agent: h.wallets[1].account!.address, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(16), + }; + const preview = agenticPresets.get(category, opts); + expect(preview.readiness.ready).toBe(false); + const { wallet, id, agent } = await setup({ + ...preview.rules[0], + assets: [], + }); + const target = preview.rules[0].allowedCalls[0].target as Address; + const abi = parseAbi([ + 'function supply(address,uint256,address,uint16)', + 'function depositIntoStrategy(address,address,uint256)', + ]); + const send = (token: Address, amount: bigint, count = 1) => + agent.universal.sendTransaction({ + to: { address: target, chain: CHAIN_NS }, + data: Array.from({ length: count }, () => ({ + to: target, + value: BigInt(0), + data: + category === 'LENDING' + ? encodeFunctionData({ + abi, + functionName: 'supply', + args: [token, amount, CEA, 0], + }) + : encodeFunctionData({ + abi, + functionName: 'depositIntoStrategy', + args: [DEST, token, amount], + }), + })), + }); + await expect(send(firstToken, BigInt(1))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + const w = owner.agentic.wallet(wallet); + const first = agenticPresets.get(category, { + ...opts, + assets: [ + { token: firstToken, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, + ], + }); + expect(first.readiness.ready).toBe(true); + const before = await h.publicClient.readContract({ + address: wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'checkpointCount', + }); + const added = await w.rules.update({ + rules: [{ rulesId: id, rule: { ...first.rules[0], assets: [] } }], + }); + expect( + await h.publicClient.readContract({ + address: wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'checkpointCount', + }) + ).toBe(before + BigInt(5)); + await waitPush(send(firstToken, BigInt(5))); + // Per-call MAX permits two calls of 5 in one outbound; it is not a batch-total cap. + await waitPush(send(firstToken, BigInt(5), 2)); + await expect(send(firstToken, BigInt(6))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + await expect(send(nextToken, BigInt(1))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + const changed = agenticPresets.get(category, { + ...opts, + assets: [ + { token: nextToken, maxPerCall: BigInt(2), maxTotal: BigInt(10) }, + ], + }); + const replaced = await w.rules.update({ + rules: [ + { + rulesId: added.rules[0].rulesId, + rule: { ...changed.rules[0], assets: [] }, + }, + ], + }); + expect(replaced.rules[0].rulesId).not.toBe(added.rules[0].rulesId); + await expect(send(firstToken, BigInt(1))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect(send(nextToken, BigInt(3))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + await waitPush(send(nextToken, BigInt(2))); + // Calls using destination-held capital do not consume the Push bridge counter. + expect( + (await stored(wallet, replaced.rules[0].rulesId)).assets[0].spent + ).toBe(BigInt(0)); + } + ); + it('a fixture preset produces rules accepted by public create and enforced by real contracts', async () => { const presets = buildPresetApi([ { diff --git a/packages/core/__e2e__/agw/presets-expanded-live.spec.ts b/packages/core/__e2e__/agw/presets-expanded-live.spec.ts index 67b44a327..59470e62b 100644 --- a/packages/core/__e2e__/agw/presets-expanded-live.spec.ts +++ b/packages/core/__e2e__/agw/presets-expanded-live.spec.ts @@ -46,7 +46,8 @@ const AAVE = parseAbi([ ]); const MARKET = parseAbi([ 'function mint(uint256) returns(uint256)', - 'function redeem(uint256) returns(uint256)', + 'function redeemUnderlying(uint256) returns(uint256)', + 'function exchangeRateStored() view returns(uint256)', 'function balanceOf(address) view returns(uint256)', ]); const read = (chain: Destination) => @@ -193,6 +194,22 @@ function assertRedemption( expect(event?.args.redeemAmount).toBe(received); } +async function underlyingRedemption( + chain: Destination, + market: Address, + shares: bigint, + cap: bigint +) { + const rate = await read(chain).readContract({ + address: market, + abi: MARKET, + functionName: 'exchangeRateStored', + }); + const amount = (shares * rate) / BigInt(10) ** BigInt(18); + const bounded = amount < cap ? amount : cap; + expect(bounded).toBeGreaterThan(ZERO); + return bounded; +} async function wrap( f: AgwFixture, w: Awaited>, @@ -293,6 +310,29 @@ d('agw presets expanded', () => { functionName: 'stakerDepositShares', args: [w.cea, strategy], }); + for (const [inputToken, amount, error] of [ + [token, SMALL + BigInt(1), 'ArgPinAboveMax'], + [strategy, SMALL, 'ArgPinMismatch'], + ] as const) { + await expect( + w.agent.universal.sendTransaction({ + to: { address: manager, chain }, + gasLimit: BigInt(2_000_000), + data: [ + call( + manager, + encodeFunctionData({ + abi, + functionName: 'depositIntoStrategy', + args: [strategy, inputToken, amount], + }) + ), + ], + }) + ).rejects.toMatchObject({ + decodedError: { name: `PolicyCheckReverted(${error})` }, + }); + } await send( f, w, @@ -532,6 +572,12 @@ d('agw presets expanded', () => { const shares = await balance(chain, venus, w.cea); expect(shares).toBeGreaterThan(ZERO); expect(await balance(chain, output, w.cea)).toBe(before); + const redeemAmount = await underlyingRedemption( + chain, + venus, + shares, + received + ); const redemption = await send( f, w, @@ -542,16 +588,24 @@ d('agw presets expanded', () => { venus, encodeFunctionData({ abi: MARKET, - functionName: 'redeem', - args: [shares], + functionName: 'redeemUnderlying', + args: [redeemAmount], }) ), ], 'venus-redeem' ); const redeemed = (await balance(chain, output, w.cea)) - before; - expect(await balance(chain, venus, w.cea)).toBe(ZERO); - assertRedemption(redemption, venus, w.cea, shares, redeemed); + const remainingShares = await balance(chain, venus, w.cea); + expect(remainingShares).toBeLessThan(shares); + expect(redeemed).toBe(redeemAmount); + assertRedemption( + redemption, + venus, + w.cea, + shares - remainingShares, + redeemed + ); expect(redeemed).toBeGreaterThanOrEqual( (received * BigInt(99)) / BigInt(100) ); @@ -562,6 +616,7 @@ d('agw presets expanded', () => { deposited: received, redeemed, shares, + remainingShares, asset: output, }); } @@ -678,6 +733,18 @@ d('agw presets expanded', () => { expect(await balance(chain, link, w.cea)).toBe(ZERO); expect(await balance(chain, usdbc, w.cea)).toBe(ZERO); expect(await balance(chain, weth, w.cea)).toBe(ZERO); + const redeemUSDBC = await underlyingRedemption( + chain, + mUSDBC, + sharesUSDBC, + amountUSDBC + ); + const redeemWETH = await underlyingRedemption( + chain, + mWETH, + sharesWETH, + SMALL + ); const redemption = await send( f, w, @@ -696,16 +763,16 @@ d('agw presets expanded', () => { mUSDBC, encodeFunctionData({ abi: MARKET, - functionName: 'redeem', - args: [sharesUSDBC], + functionName: 'redeemUnderlying', + args: [redeemUSDBC], }) ), call( mWETH, encodeFunctionData({ abi: MARKET, - functionName: 'redeem', - args: [sharesWETH], + functionName: 'redeemUnderlying', + args: [redeemWETH], }) ), ], @@ -718,20 +785,24 @@ d('agw presets expanded', () => { expect(await balance(chain, weth, w.cea)).toBeGreaterThanOrEqual( (SMALL * BigInt(99)) / BigInt(100) ); - expect(await balance(chain, mUSDBC, w.cea)).toBe(ZERO); - expect(await balance(chain, mWETH, w.cea)).toBe(ZERO); + const remainingUSDBC = await balance(chain, mUSDBC, w.cea), + remainingWETH = await balance(chain, mWETH, w.cea); + expect(remainingUSDBC).toBeLessThan(sharesUSDBC); + expect(remainingWETH).toBeLessThan(sharesWETH); + expect(await balance(chain, usdbc, w.cea)).toBe(redeemUSDBC); + expect(await balance(chain, weth, w.cea)).toBe(redeemWETH); assertRedemption( redemption, mUSDBC, w.cea, - sharesUSDBC, + sharesUSDBC - remainingUSDBC, await balance(chain, usdbc, w.cea) ); assertRedemption( redemption, mWETH, w.cea, - sharesWETH, + sharesWETH - remainingWETH, await balance(chain, weth, w.cea) ); f.evidence('preset-expanded-base-lending', { diff --git a/packages/core/__e2e__/agw/presets-live.spec.ts b/packages/core/__e2e__/agw/presets-live.spec.ts index d0567d5d6..dfcfe6113 100644 --- a/packages/core/__e2e__/agw/presets-live.spec.ts +++ b/packages/core/__e2e__/agw/presets-live.spec.ts @@ -341,6 +341,25 @@ d('agw presets live', () => { functionName: 'supply', args: [mockLINK, amount, cea, 0], }); + await expect( + agent.universal.sendTransaction({ + to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, + gasLimit: BigInt(900_000), + data: [ + { + to: pool, + value: BigInt(0), + data: encodeFunctionData({ + abi, + functionName: 'supply', + args: [mockLINK, amount + BigInt(1), cea, 0], + }), + }, + ], + }) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); const supply = await agent.universal.sendTransaction({ to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, gasLimit: BigInt(900_000), @@ -389,20 +408,57 @@ d('agw presets live', () => { e.args.amount === amount ) ).toBe(true); + const half = amount / BigInt(2); + const regenerated = PushChain.utils.agentic.presets.get('LENDING', { + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + agent: f.agentAddress, + validUntil: inSeconds(3600), + maxGasPerCall: MAX_PC, + assets: [{ token: mockLINK, maxPerCall: half, maxTotal: amount }], + }); + expect(regenerated.readiness.ready).toBe(true); + const changed = await f.owner.agentic + .wallet(wallet) + .rules.update({ + rules: [ + { + rulesId: rulesIds[0], + rule: { ...regenerated.rules[0], assets: [] }, + }, + ], + }); + await expect( + agent.universal.sendTransaction({ + to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, + gasLimit: BigInt(900_000), + data: [ + { + to: pool, + value: BigInt(0), + data: encodeFunctionData({ + abi, + functionName: 'withdraw', + args: [mockLINK, amount, cea], + }), + }, + ], + }) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + // The cap applies to each allowed call, not their aggregate in a batch. const withdraw = await agent.universal.sendTransaction({ to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, gasLimit: BigInt(900_000), - data: [ - { - to: pool, - value: BigInt(0), - data: encodeFunctionData({ - abi, - functionName: 'withdraw', - args: [mockLINK, amount, cea], - }), - }, - ], + data: [0, 1].map(() => ({ + to: pool, + value: BigInt(0), + data: encodeFunctionData({ + abi, + functionName: 'withdraw', + args: [mockLINK, half, cea], + }), + })), }); f.evidence('preset-aave-stage', { stage: 'withdraw-submitted', @@ -422,19 +478,31 @@ d('agw presets live', () => { }); expect(withdrawalReceipt.status).toBe('success'); expect(await balance()).toBe(amount); - expect( - parseEventLogs({ - abi, - eventName: 'Withdraw', - logs: withdrawalReceipt.logs, - }).some( - (e) => - e.address.toLowerCase() === pool.toLowerCase() && - e.args.user.toLowerCase() === cea.toLowerCase() && - e.args.to.toLowerCase() === cea.toLowerCase() && - e.args.amount === amount - ) - ).toBe(true); + const withdrawals = parseEventLogs({ + abi, + eventName: 'Withdraw', + logs: withdrawalReceipt.logs, + }).filter( + (e) => + e.address.toLowerCase() === pool.toLowerCase() && + e.args.user.toLowerCase() === cea.toLowerCase() && + e.args.to.toLowerCase() === cea.toLowerCase() + ); + expect(withdrawals).toHaveLength(2); + expect(withdrawals.every((e) => e.args.amount === half)).toBe(true); + expect(withdrawals.reduce((sum, e) => sum + e.args.amount, BigInt(0))).toBe( + amount + ); + f.evidence('preset-limits-regeneration-live', { + wallet, + cea, + oldRulesId: rulesIds[0], + newRulesId: changed.rules[0].rulesId, + oldCap: amount, + newCap: half, + rejectedAboveCap: true, + destinationTx: withdrawn.externalTxHash, + }); f.evidence('preset-aave-live', { wallet, cea, diff --git a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts index 622abe7df..1ce41fdae 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts @@ -79,7 +79,7 @@ it('scopes money-market approvals to their actual underlying token', () => { const approvals = r.allowedCalls.filter( (c) => c.selector === 'approve(address,uint256)' ); - expect(approvals).toHaveLength(2); // Aave and mUSDbC, never the mWETH market. + expect(approvals).toHaveLength(1); // Only mUSDbC: this mock token is not an Aave reserve. expect(approvals.flatMap((c) => c.pins ?? [])).not.toContainEqual({ arg: 0, expected: getAddress('0x2F39a349A79492a70E152760ce7123A1933eCf28'), @@ -273,28 +273,37 @@ it.each(['eip155:1', 'eip155:8453', 'eip155:42161', 'eip155:56'] as const)( } ); -it('mainnet Lido templates pin withdrawal ownership and keep ETH staking disabled until explicitly capped', () => { +it('mainnet Lido uses the selected token units and excludes unbounded withdrawal/claim methods', () => { const stETH = getAddress('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'); - const { rules } = agenticPresets.get('STAKING', { + const { rules, readiness } = agenticPresets.get('STAKING', { chainNamespace: 'eip155:1', assets: [{ token: stETH, maxPerCall: BigInt(5), maxTotal: BigInt(10) }], }); - expect(rules).toHaveLength(1); const calls = rules[0].allowedCalls; - expect(calls.find((c) => c.selector === 'submit(address)')?.maxValue).toBe( - BigInt(0) + expect( + calls.find((c) => c.selector === 'wrap(uint256)')?.pins + ).toContainEqual({ arg: 0, max: BigInt(5) }); + expect(calls.some((c) => c.selector === 'submit(address)')).toBe(false); + expect( + calls.some( + (c) => + c.selector.startsWith('requestWithdrawals') || + c.selector.startsWith('claimWithdraw') + ) + ).toBe(false); + expect(readiness.chains[0].excludedCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + selector: 'requestWithdrawals(uint256[],address)', + reason: 'DYNAMIC_AMOUNT', + }), + expect.objectContaining({ + selector: 'claimWithdrawal(uint256)', + reason: 'POSITION_AMOUNT', + }), + ]) ); - const request = calls.find( - (c) => c.selector === 'requestWithdrawals(uint256[],address)' - )!; - expect(request.pins).toEqual([{ arg: 1, expected: AGENTIC.WALLET }]); - expect(evmPinOffset(request.selector, 1)).toBe(36); expect( calls.filter((c) => c.selector === 'approve(address,uint256)') - ).toHaveLength(3); - expect( - calls - .filter((c) => c.selector === 'approve(address,uint256)') - .every((c) => c.target === stETH && c.pins?.[1].max === BigInt(5)) - ).toBe(true); + ).toHaveLength(2); }); diff --git a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts new file mode 100644 index 000000000..42a2dcbe0 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts @@ -0,0 +1,220 @@ +import { getAddress, zeroAddress } from 'viem'; +import { agenticPresets } from '../presets'; +import { AGENTIC } from '../constants'; +import { ADDR } from './fake-chain'; +const LINK = getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'); +const WBTC = getAddress('0x29f2D40B0605204364af54EC677bD022dA425d03'); +const chainNamespace = 'eip155:11155111' as const; +const options = { + chainNamespace, + agent: ADDR.agent, + validUntil: 2_000_000_000, + maxGasPerCall: BigInt(1), +}; +const asset = (token = LINK, limit = BigInt(5)) => ({ + token, + maxPerCall: limit, + maxTotal: BigInt(10), +}); + +it('Aave pins the selected reserve, operation amount and wallet beneficiary for every method', () => { + const { rules, readiness } = agenticPresets.get('LENDING', { + ...options, + assets: [asset(), asset(WBTC, BigInt(7))], + }); + expect(readiness.ready).toBe(true); + expect(rules).toHaveLength(1); + for (const [selector, walletArg] of [ + ['supply(address,uint256,address,uint16)', 2], + ['withdraw(address,uint256,address)', 2], + ['borrow(address,uint256,uint256,uint16,address)', 4], + ['repay(address,uint256,uint256,address)', 3], + ] as const) { + const calls = rules[0].allowedCalls.filter((c) => c.selector === selector); + expect(calls).toHaveLength(2); + for (const [i, token] of [LINK, WBTC].entries()) { + expect(calls[i].pins).toEqual( + expect.arrayContaining([ + { arg: 0, expected: token }, + { arg: 1, max: i ? BigInt(7) : BigInt(5) }, + { arg: walletArg, expected: AGENTIC.WALLET }, + ]) + ); + expect(calls[i]).not.toHaveProperty('budget'); + } + } +}); +it('returns actionable readiness without silently choosing tokens, caps, gas or expiry', () => { + const result = agenticPresets.get('LENDING', { chainNamespace }); + expect(result.readiness.ready).toBe(false); + expect(result.readiness.chains[0].issues.map((i) => i.code)).toEqual( + expect.arrayContaining([ + 'AGENT_REQUIRED', + 'EXPIRY_REQUIRED', + 'GAS_BUDGET_REQUIRED', + 'ASSETS_REQUIRED', + 'NO_ENABLED_OPERATION', + ]) + ); + expect(result.rules[0]).not.toHaveProperty('assets'); + expect(result.rules[0]).not.toHaveProperty('validUntil'); + expect(result.rules[0]).not.toHaveProperty('maxGasPerCall'); + expect( + result.rules[0].allowedCalls.every((c) => + c.pins?.some((p) => p.max === BigInt(0)) + ) + ).toBe(true); + expect(result.readiness.chains[0].knownTokens).toContain(LINK); +}); +it('zero operation caps remain valid disabled configurations, while zero bridge totals are separately reported', () => { + const disabled = agenticPresets.get('LENDING', { + ...options, + assets: [{ token: LINK }], + }); + expect(disabled.readiness.ready).toBe(false); + expect( + disabled.readiness.chains[0].disabledCalls.every( + (c) => c.reason === 'ZERO_CAP' + ) + ).toBe(true); + const enabled = agenticPresets.get('LENDING', { + ...options, + assets: [{ token: LINK, maxPerCall: BigInt(5) }], + }); + expect(enabled.readiness.ready).toBe(true); + expect(enabled.readiness.chains[0].zeroBridgeLimits).toEqual([LINK]); + expect(enabled.rules[0].assets?.[0].maxTotal).toBe(BigInt(0)); +}); +it('does not approve or grant a positive amount for an unlisted Aave asset', () => { + const { rules, readiness } = agenticPresets.get('LENDING', { + ...options, + assets: [asset(ADDR.other)], + }); + expect(readiness.ready).toBe(false); + expect( + rules[0].allowedCalls.some((c) => c.selector.startsWith('approve')) + ).toBe(false); + expect( + rules[0].allowedCalls.every((c) => c.pins?.some((p) => p.max === BigInt(0))) + ).toBe(true); +}); +it('regenerating a token/limit replaces old operation and approval constraints without mutating the old template', () => { + const first = agenticPresets.get('LENDING', { + ...options, + assets: [asset()], + }); + const next = agenticPresets.get('LENDING', { + ...options, + assets: [asset(WBTC, BigInt(2))], + }); + expect( + next.rules[0].allowedCalls.some( + (c) => c.target === LINK || c.pins?.some((p) => p.expected === LINK) + ) + ).toBe(false); + expect( + next.rules[0].allowedCalls.every((c) => + c.pins?.some((p) => p.max === BigInt(2)) + ) + ).toBe(true); + expect( + first.rules[0].allowedCalls.every((c) => + c.pins?.some((p) => p.max === BigInt(5)) + ) + ).toBe(true); +}); +it('Moonwell applies underlying units and reports the share-based redeem alternative', () => { + const token = getAddress('0x4616975f7E6045FD9737e95bC527e9e14c727BD2'); + const { rules, readiness } = agenticPresets.get('LENDING', { + ...options, + chainNamespace: 'eip155:84532', + assets: [asset(token)], + }); + const calls = rules[0].allowedCalls.filter( + (c) => c.app === 'Moonwell mUSDbC' && !c.selector.startsWith('approve') + ); + expect(calls.map((c) => c.selector)).toEqual([ + 'mint(uint256)', + 'redeemUnderlying(uint256)', + 'borrow(uint256)', + 'repayBorrow(uint256)', + ]); + expect( + calls.every((c) => c.pins?.some((p) => p.arg === 0 && p.max === BigInt(5))) + ).toBe(true); + expect(readiness.chains[0].excludedCalls).toContainEqual( + expect.objectContaining({ + selector: 'redeem(uint256)', + reason: 'SHARE_AMOUNT', + alternative: 'redeemUnderlying(uint256)', + }) + ); +}); +it('EigenLayer pins the chosen token and deposit amount', () => { + const { rules } = agenticPresets.get('STAKING', { + ...options, + assets: [asset()], + }); + const deposit = rules[0].allowedCalls.find((c) => + c.selector.startsWith('depositIntoStrategy') + )!; + expect(deposit.pins).toEqual([ + { arg: 1, expected: LINK }, + { arg: 2, max: BigInt(5) }, + ]); +}); +it('Lido distinguishes stETH, wstETH and native value units', () => { + const stETH = getAddress('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'), + wstETH = getAddress('0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'); + const { rules } = agenticPresets.get('STAKING', { + ...options, + chainNamespace: 'eip155:1', + assets: [ + asset(stETH, BigInt(2)), + asset(wstETH, BigInt(3)), + asset(zeroAddress, BigInt(4)), + ], + }); + expect( + rules[0].allowedCalls.find((c) => c.selector === 'wrap(uint256)')?.pins + ).toEqual([{ arg: 0, max: BigInt(2) }]); + expect( + rules[0].allowedCalls.find((c) => c.selector === 'unwrap(uint256)')?.pins + ).toEqual([{ arg: 0, max: BigInt(3) }]); + expect( + rules[0].allowedCalls.find((c) => c.selector === 'submit(address)') + ?.maxValue + ).toBe(BigInt(4)); +}); +it('readiness is per chain and cannot be mutated to change later results', () => { + const res = agenticPresets.get('LENDING', { + agent: options.agent, + validUntil: options.validUntil, + maxGasPerCall: options.maxGasPerCall, + assets: [{ ...asset(), chainNamespace }], + }); + expect(res.readiness.ready).toBe(false); + expect( + res.readiness.chains.find((c) => c.chainNamespace === chainNamespace)?.ready + ).toBe(true); + const expected = agenticPresets.get('LENDING', { chainNamespace }).readiness; + res.readiness.chains.forEach((c) => { + c.knownTokens.length = 0; + c.disabledCalls.length = 0; + }); + expect(agenticPresets.get('LENDING', { chainNamespace }).readiness).toEqual( + expected + ); +}); +it('refuses an overlarge token expansion without dropping restrictions', () => { + expect(() => + agenticPresets.get('LENDING', { + ...options, + assets: [ + asset(), + asset(WBTC), + asset(getAddress('0x88541670E55cC00bEEFD87eB59EDd1b7C511AC9a')), + ], + }) + ).toThrow(/32 pins/); +}); diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts index cefe74799..ffb417092 100644 --- a/packages/core/src/lib/agentic/__tests__/presets.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -75,7 +75,10 @@ it('ships verified mainnet and testnet templates and keeps prediction empty', () ].includes(c) ) ).toBe(true); - expect(agenticPresets.get('PREDICTION')).toEqual({ rules: [] }); + expect(agenticPresets.get('PREDICTION')).toEqual({ + rules: [], + readiness: { ready: false, chains: [] }, + }); expect(agenticPresets.get('DEXES').rules).toHaveLength(8); expect(() => agenticPresets.get('LENDING', { chainNamespace: 'eip155:999999' }) diff --git a/packages/core/src/lib/agentic/index.ts b/packages/core/src/lib/agentic/index.ts index 520fb306d..396c39c22 100644 --- a/packages/core/src/lib/agentic/index.ts +++ b/packages/core/src/lib/agentic/index.ts @@ -36,5 +36,9 @@ export type { PresetOptions, ReadyPresetOptions, PresetRuleTemplate, + PresetResult, + PresetChainReadiness, + PresetConfigurationIssue, + PresetCallIssue, AgenticPresets, } from './presets/types'; diff --git a/packages/core/src/lib/agentic/presets/aave-reserves.ts b/packages/core/src/lib/agentic/presets/aave-reserves.ts new file mode 100644 index 000000000..bdd076386 --- /dev/null +++ b/packages/core/src/lib/agentic/presets/aave-reserves.ts @@ -0,0 +1,146 @@ +/** Aave V3 reserve addresses from the official bgd-labs/aave-address-book + * snapshots reviewed October 15, 2026. Listings are not liquidity/availability guarantees. */ +import type { Address } from 'viem'; +export const AAVE_RESERVES: Readonly> = { + 'eip155:1': [ + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0x6B175474E89094C44Da98b954EedeAC495271d0F', + '0x514910771AF9Ca656af840dff83E8264EcF986CA', + '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', + '0xBe9895146f7AF43049ca1c1AE358B0541Ea49704', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xae78736Cd615f374D3085123A210448E74Fc6393', + '0x5f98805A4E8be255a32880FDeC7F6728C6568bA0', + '0xD533a949740bb3306d119CC777fa900bA034cd52', + '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', + '0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F', + '0xba100000625a3754423978a60c9317c58a424e3D', + '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', + '0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32', + '0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72', + '0x111111111117dC0aa78b770fA6A738034120C302', + '0x853d955aCEf822Db058eb8505911ED77F175b99e', + '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f', + '0xD33526068D116cE69F19A9ee46F0bd304F21A51f', + '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + '0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6', + '0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202', + '0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0', + '0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E', + '0x6c3ea9036406852006290770BEdFcAbA0e23A0e8', + '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', + '0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38', + '0x4c9EDD5852cd905f086C759E8383e09bff1E68B3', + '0xA35b1B31Ce002FBF2058D22F30f95D405200A15b', + '0x9D39A5DE30e57443BfF2A8307A4256c8797A3497', + '0x18084fbA666a33d37592fA2633fD49a74DD93a88', + '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', + '0xdC035D45d973E3EC169d2276DDab16f1e407384F', + '0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7', + '0x8236a87084f8B84306f72007F36F2618A5634494', + '0x657e8C867D8B37dCC18fA4Caead9C45EB088C642', + '0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD', + '0x50D2C7992b802Eef16c04FeADAB310f31866a545', + '0x3b3fB9C57858EF816833dC91565EFcd85D96f634', + '0xC139190F447e929f090Edeb554D95AbB8b18aC1C', + '0x917459337CaAC939D41d7493B3999f571D20D667', + '0x14Bdc3A3AE09f5518b923b69489CBcAfB238e617', + '0x90D2af7d622ca3141efA4d8f1F24d86E5974Cc8F', + '0xC96dE26018A54D51c097160568752c4E3BD6C364', + '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c', + '0x9F56094C450763769BA0EA9Fe2876070c0fD5F77', + '0xBC6736d346a5eBC0dEbc997397912CD9b8FAe10a', + '0xD11c452fc99cF405034ee446803b6F6c1F6d5ED8', + '0xbf5495Efe5DB9ce00f80364C8B423567e58d2110', + '0x68749665FF8D2d112Fa859AA293F07A622782F38', + '0xe6A934089BBEe34F832060CE98848359883749B3', + '0x62C6E813b9589C3631Ba0Cdb013acdB8544038B7', + '0x1F84a51296691320478c98b8d77f2Bbd17D34350', + '0xE8483517077afa11A9B07f849cee2552f040d7b2', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + '0x356B8d89c1e1239Cbbb9dE4815c39A1474d5BA7D', + '0xe343167631d89B6Ffc58B88d6b7fB0228795491D', + '0xAeBf0Bb9f57E89260d57f31AF34eB58657d96Ce0', + '0x3de0ff76E8b528C092d47b9DaC775931cef80F49', + '0x9Bf45ab47747F4B4dD09B3C2c73953484b4eB375', + '0xB0F70C0bD6FD87dbEb7C10dC692a2a6106817072', + '0x619D75E3b790eBC21c289f2805Bb7177A7D732E2', + '0x9db38D74a0D29380899aD354121DfB521aDb0548', + '0x59bC9FaE5D62B19d4f8d07D758047aCb9EE19d34', + ], + 'eip155:8453': [ + '0x4200000000000000000000000000000000000006', + '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22', + '0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA', + '0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + '0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A', + '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', + '0x2416092f143378750bb29b79eD961ab195CcEea5', + '0x6Bb7a212910682DCFdbd5BCBb3e28FB4E8da10Ee', + '0xEDfa23602D0EC14714057867A78d01e94176BEA0', + '0xecAc9C5F704e954931349Da37F60E39f515c11c1', + '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42', + '0x63706e401c06ac8513145b7687A14804d17f814b', + '0x236aa50979D5f3De3Bd1Eeb40E81137F22ab794b', + '0x660975730059246A68521a3e2FBD4740173100f5', + ], + 'eip155:42161': [ + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '0xf97f4df75117a78c1A5a0DBb814Af92458539FB4', + '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', + '0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f', + '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', + '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + '0xba5DdD1f9d7F570dc94a51479a000E3BCE967196', + '0xD22a58f79e9481D1a88e00c343885A588b34b68B', + '0x5979D7b546E38E414F7E9822514be443A4800529', + '0x3F56e0c36d275367b8C502090EDF38289b3dEa0d', + '0xEC70Dcb4A1EFa46b8F2D97C310C9c4790ba5ffA8', + '0x93b346b6BC2548dA6A1E7d98E9a421B42541425b', + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + '0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F', + '0x912CE59144191C1204E64559FE8253a0e49E6548', + '0x35751007a407ca6FEFfE80b3cB397736D2cf4dbe', + '0x7dfF72693f6A4149b17e7C6314655f6A9F7c8B33', + '0x2416092f143378750bb29b79eD961ab195CcEea5', + '0x4186BFC76E2E237523CBC30FD220FE055156b41F', + '0x6c84a8f1c29108F47a79964b5Fe888D4f4D0dE40', + ], + 'eip155:56': [ + '0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82', + '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', + '0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c', + '0x2170Ed0880ac9A755fd29B2688956BD959F933F8', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + '0x55d398326f99059fF775485246999027B3197955', + '0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409', + '0x26c5e01524d2E6280A48F2c50fF6De7e52E9611C', + ], + 'eip155:11155111': [ + '0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357', + '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5', + '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8', + '0x29f2D40B0605204364af54EC677bD022dA425d03', + '0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c', + '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + '0x88541670E55cC00bEEFD87eB59EDd1b7C511AC9a', + '0x6d906e526a4e2Ca02097BA9d0caA3c382F52278E', + '0xc4bF5CbDaBE595361438F8c6a187bDc330539c60', + ], + 'eip155:84532': [ + '0xba50Cd2A20f6DA35D788639E581bca8d0B5d4D5f', + '0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a', + '0x54114591963CF60EF3aA63bEfD6eC263D98145a4', + '0x4200000000000000000000000000000000000006', + '0xD171b9694f7A2597Ed006D41f7509aaD4B485c4B', + '0x810D46F9a9027E28F9B01F75E2bdde839dA61115', + ], + 'eip155:421614': [ + '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + '0x1dF462e2712496373A347f8ad10802a5E95f053D', + ], +}; diff --git a/packages/core/src/lib/agentic/presets/catalog.ts b/packages/core/src/lib/agentic/presets/catalog.ts index 732243fb6..c7d7b2e2a 100644 --- a/packages/core/src/lib/agentic/presets/catalog.ts +++ b/packages/core/src/lib/agentic/presets/catalog.ts @@ -1,15 +1,40 @@ import { getAddress, type Address } from 'viem'; import type { RuleCall, ForeignChainNamespace } from '../agentic.types'; import type { PresetCategory } from './types'; +import { AAVE_RESERVES } from './aave-reserves'; + +export type PresetBudget = + | { + kind: 'token-argument'; + tokenArg: number; + amountArg: number; + tokens?: readonly Address[]; + } + | { kind: 'fixed-token'; token: Address; amountArg: number } + | { kind: 'native-value' } + | { kind: 'setup' } + | { + kind: 'unsupported'; + reason: 'SHARE_AMOUNT' | 'DYNAMIC_AMOUNT' | 'POSITION_AMOUNT'; + alternative?: RuleCall['selector']; + }; +export interface PresetCallDefinition extends RuleCall { + budget?: PresetBudget; +} /** Internal catalog: reviewed addresses and explicit calls only; never fetched at runtime. */ export interface PresetChainDefinition { chainNamespace: ForeignChainNamespace; - allowedCalls: readonly RuleCall[]; + allowedCalls: readonly PresetCallDefinition[]; /** Static single-hop routers; incomplete pairs return zero-input-cap review calls. */ swapRouters?: readonly { address: Address; app: string }[]; /** Exact spenders that need an approval for each non-native supplied asset. */ - approvals?: readonly { spender: Address; app: string; token?: Address }[]; + approvals?: readonly { + spender: Address; + app: string; + token?: Address; + tokens?: readonly Address[]; + }[]; maxGasPerCall?: bigint; } export interface PresetDefinition { @@ -23,7 +48,7 @@ export interface PresetDefinition { */ const address = (value: string): Address => getAddress(value.toLowerCase()); const wallet = 'agentic:wallet' as const; -const aave = (pool: Address): RuleCall[] => +const aave = (pool: Address, chain: string): PresetCallDefinition[] => [ ['supply(address,uint256,address,uint16)', 2, 'Supply'], ['withdraw(address,uint256,address)', 2, 'Withdraw'], @@ -35,8 +60,18 @@ const aave = (pool: Address): RuleCall[] => pins: [{ arg: arg as number, expected: wallet }], app: 'Aave V3', label: label as string, + budget: { + kind: 'token-argument', + tokenArg: 0, + amountArg: 1, + tokens: AAVE_RESERVES[chain], + }, })); -const market = (target: Address, app: string): RuleCall[] => +const market = ( + target: Address, + app: string, + token: Address +): PresetCallDefinition[] => [ 'mint(uint256)', 'redeem(uint256)', @@ -48,6 +83,14 @@ const market = (target: Address, app: string): RuleCall[] => selector: selector as RuleCall['selector'], app, label: selector.split('(')[0], + budget: + selector === 'redeem(uint256)' + ? { + kind: 'unsupported', + reason: 'SHARE_AMOUNT', + alternative: 'redeemUnderlying(uint256)', + } + : { kind: 'fixed-token', token, amountArg: 0 }, })); const sepoliaPool = address('0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951'); const basePool = address('0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27'); @@ -97,7 +140,9 @@ const mainnetMarkets = ( : '0xfD36E2c2a6789Db23113685031d7F16329158384'; return { allowedCalls: [ - ...entries.flatMap(([target, app]) => market(address(target), app)), + ...entries.flatMap(([target, app, token]) => + market(address(target), app, address(token)) + ), ...(entries.length ? [ { @@ -105,6 +150,7 @@ const mainnetMarkets = ( selector: 'enterMarkets(address[])' as const, app: chain === 'eip155:8453' ? 'Moonwell' : 'Venus', label: 'Enter markets', + budget: { kind: 'setup' as const }, }, ] : []), @@ -178,40 +224,69 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ ).map(([chainNamespace, pool]) => ({ chainNamespace, allowedCalls: [ - ...aave(address(pool)), + ...aave(address(pool), chainNamespace), ...mainnetMarkets(chainNamespace).allowedCalls, ], approvals: [ - { spender: address(pool), app: 'Aave V3' }, + { + spender: address(pool), + app: 'Aave V3', + tokens: AAVE_RESERVES[chainNamespace], + }, ...(mainnetMarkets(chainNamespace).approvals ?? []), ], })), { chainNamespace: 'eip155:11155111', - allowedCalls: aave(sepoliaPool), - approvals: [{ spender: sepoliaPool, app: 'Aave V3' }], + allowedCalls: aave(sepoliaPool, 'eip155:11155111'), + approvals: [ + { + spender: sepoliaPool, + app: 'Aave V3', + tokens: AAVE_RESERVES['eip155:11155111'], + }, + ], }, { chainNamespace: 'eip155:421614', - allowedCalls: aave(arbitrumPool), - approvals: [{ spender: arbitrumPool, app: 'Aave V3' }], + allowedCalls: aave(arbitrumPool, 'eip155:421614'), + approvals: [ + { + spender: arbitrumPool, + app: 'Aave V3', + tokens: AAVE_RESERVES['eip155:421614'], + }, + ], }, { chainNamespace: 'eip155:84532', allowedCalls: [ - ...aave(basePool), - ...market(moonUSDBC, 'Moonwell mUSDbC'), - ...market(moonWETH, 'Moonwell mWETH'), + ...aave(basePool, 'eip155:84532'), + ...market( + moonUSDBC, + 'Moonwell mUSDbC', + address('0x4616975f7E6045FD9737e95bC527e9e14c727BD2') + ), + ...market( + moonWETH, + 'Moonwell mWETH', + address('0x663A6F02d547dFd639BF689FED298712f2FB69Fb') + ), { target: address('0xC72ba45f2Ae134F21DAEb2b7B5fd5De0064Ee9bB'), selector: 'enterMarkets(address[])', app: 'Moonwell', label: 'Enter markets', + budget: { kind: 'setup' as const }, }, ], approvals: [ - { spender: basePool, app: 'Aave V3' }, + { + spender: basePool, + app: 'Aave V3', + tokens: AAVE_RESERVES['eip155:84532'], + }, { spender: moonUSDBC, app: 'Moonwell mUSDbC', @@ -227,13 +302,22 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ { chainNamespace: 'eip155:97', allowedCalls: [ - ...market(venusUSDT, 'Venus vUSDT'), - ...market(venusUSDC, 'Venus vUSDC'), + ...market( + venusUSDT, + 'Venus vUSDT', + address('0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c') + ), + ...market( + venusUSDC, + 'Venus vUSDC', + address('0x16227D60f7a0e586C66B005219dfc887D13C9531') + ), { target: address('0x94d1820b2D1c7c7452A163983Dc888CEC546b77D'), selector: 'enterMarkets(address[])', app: 'Venus', label: 'Enter markets', + budget: { kind: 'setup' as const }, }, ], approvals: [ @@ -263,19 +347,22 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ selector: 'submit(address)', maxValue: BigInt(0), app: 'Lido', - label: 'Stake ETH (set an explicit value cap)', + label: 'Stake ETH', + budget: { kind: 'native-value' }, }, { target: wstETH, selector: 'wrap(uint256)', app: 'Lido wstETH', label: 'Wrap stETH', + budget: { kind: 'fixed-token', token: stETH, amountArg: 0 }, }, { target: wstETH, selector: 'unwrap(uint256)', app: 'Lido wstETH', label: 'Unwrap wstETH', + budget: { kind: 'fixed-token', token: wstETH, amountArg: 0 }, }, { target: lidoQueue, @@ -283,18 +370,21 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ pins: [{ arg: 1, expected: wallet }], app: 'Lido withdrawal queue', label: 'Request withdrawal', + budget: { kind: 'unsupported', reason: 'DYNAMIC_AMOUNT' }, }, { target: lidoQueue, selector: 'claimWithdrawals(uint256[],uint256[])', app: 'Lido withdrawal queue', label: 'Claim finalized withdrawals', + budget: { kind: 'unsupported', reason: 'POSITION_AMOUNT' }, }, { target: lidoQueue, selector: 'claimWithdrawal(uint256)', app: 'Lido withdrawal queue', label: 'Claim finalized withdrawal', + budget: { kind: 'unsupported', reason: 'POSITION_AMOUNT' }, }, { @@ -302,11 +392,11 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ selector: 'depositIntoStrategy(address,address,uint256)', app: 'EigenLayer', label: 'Deposit into strategy (withdrawals not included)', + budget: { kind: 'token-argument', tokenArg: 1, amountArg: 2 }, }, ], approvals: [ { spender: wstETH, token: stETH, app: 'Lido wstETH' }, - { spender: lidoQueue, token: stETH, app: 'Lido withdrawal queue' }, { spender: address('0x858646372CC42E1A627fcE94aa7A7033e7CF075A'), @@ -323,6 +413,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ selector: 'depositIntoStrategy(address,address,uint256)', app: 'EigenLayer', label: 'Deposit into strategy (withdrawals not included)', + budget: { kind: 'token-argument', tokenArg: 1, amountArg: 2 }, }, ], approvals: [{ spender: eigen, app: 'EigenLayer' }], diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index 61c5ce4bf..9ba56b051 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -9,9 +9,12 @@ import type { AgenticPresets, PresetOptions, PresetRuleTemplate, + PresetChainReadiness, + PresetConfigurationIssue, } from './types'; import { parseSelector } from '../codec/selectors'; import { PRESET_CATALOG, type PresetDefinition } from './catalog'; +import { applyOperationLimits, assetAddress } from './operation-limits'; const MAX_UINT256 = (BigInt(1) << BigInt(256)) - BigInt(1); const invalid = (message: string) => @@ -59,8 +62,23 @@ export function buildPresetApi( ...p, chains: p.chains.map((c) => ({ ...c, - allowedCalls: c.allowedCalls.map(copyCall), - approvals: c.approvals?.map((a) => ({ ...a })), + allowedCalls: c.allowedCalls.map((call) => ({ + ...copyCall(call), + ...(call.budget + ? { + budget: { + ...call.budget, + ...(call.budget.kind === 'token-argument' && call.budget.tokens + ? { tokens: [...call.budget.tokens] } + : {}), + }, + } + : {}), + })), + approvals: c.approvals?.map((a) => ({ + ...a, + ...(a.tokens ? { tokens: [...a.tokens] } : {}), + })), swapRouters: c.swapRouters?.map((r) => ({ ...r })), })), })); @@ -161,8 +179,17 @@ export function buildPresetApi( }); assets.set(chain, group); } + const readiness: PresetChainReadiness[] = []; const rules: PresetRuleTemplate[] = chains.map((chain) => { - const calls = chain.allowedCalls.map(copyCall); + const selectedAssets = assets.get(chain.chainNamespace) ?? []; + const limits = applyOperationLimits( + chain.allowedCalls.map((c) => ({ + ...copyCall(c), + ...(c.budget ? { budget: c.budget } : {}), + })), + selectedAssets + ); + const calls = limits.calls; if (chain.swapRouters?.length) { const tokens = assets.get(chain.chainNamespace) ?? []; if ( @@ -236,6 +263,23 @@ export function buildPresetApi( }); } } + if (chain.swapRouters?.length) { + const swaps = calls.filter((c) => c.selector.startsWith('exact')); + for (const c of swaps) { + const positive = c.pins?.some( + (p) => 'max' in p && p.max !== undefined && p.max > BigInt(0) + ); + if (positive) limits.enabled = true; + else + limits.disabledCalls.push({ + target: c.target, + selector: c.selector, + app: c.app, + reason: + selectedAssets.length < 2 ? 'TOKEN_REQUIRED' : 'ZERO_CAP', + }); + } + } const approvals = new Set(); for (const asset of assets.get(chain.chainNamespace) ?? []) { const token = tokenAddress(asset.token); @@ -256,6 +300,11 @@ export function buildPresetApi( getAddress(token) !== getAddress(approval.token) ) continue; + if ( + approval.tokens && + !approval.tokens.some((t) => getAddress(t) === getAddress(token)) + ) + continue; const spender = getAddress(approval.spender); const target = getAddress(token); const approvalKey = `${target}:${spender}`; @@ -307,6 +356,54 @@ export function buildPresetApi( ); pairs.set(pair, !!call.pins?.length); } + const issues: PresetConfigurationIssue[] = []; + if (!options.agent) + issues.push({ + code: 'AGENT_REQUIRED', + message: 'Choose the agent Push address.', + }); + if (!options.validUntil) + issues.push({ + code: 'EXPIRY_REQUIRED', + message: 'Choose an expiry.', + }); + if ( + (options.maxGasPerCall ?? chain.maxGasPerCall ?? BigInt(0)) === + BigInt(0) + ) + issues.push({ + code: 'GAS_BUDGET_REQUIRED', + message: 'Set a positive PC gas budget per outbound.', + }); + if (!selectedAssets.length) + issues.push({ + code: 'ASSETS_REQUIRED', + message: + 'Choose tokens and their limits, then regenerate this preset.', + }); + if (chain.swapRouters?.length && selectedAssets.length < 2) + issues.push({ + code: 'TOKEN_PAIR_REQUIRED', + message: 'Choose both swap tokens, then regenerate this preset.', + }); + if (!limits.enabled) + issues.push({ + code: 'NO_ENABLED_OPERATION', + message: 'No asset operation has a positive per-call limit.', + }); + readiness.push({ + chainNamespace: chain.chainNamespace, + ready: issues.length === 0, + issues, + disabledCalls: limits.disabledCalls, + excludedCalls: limits.excludedCalls, + knownTokens: limits.knownTokens, + zeroBridgeLimits: selectedAssets + .filter( + (a) => a.maxPerCall === BigInt(0) || a.maxTotal === BigInt(0) + ) + .map(assetAddress), + }); return { chainNamespace: chain.chainNamespace, allowedCalls: calls, @@ -329,7 +426,13 @@ export function buildPresetApi( : {}), }; }); - return { rules }; + return { + rules, + readiness: { + ready: readiness.length > 0 && readiness.every((c) => c.ready), + chains: readiness, + }, + }; }, }; // The overload narrows templates only when both required fields were supplied and validated. diff --git a/packages/core/src/lib/agentic/presets/operation-limits.ts b/packages/core/src/lib/agentic/presets/operation-limits.ts new file mode 100644 index 000000000..4466eb932 --- /dev/null +++ b/packages/core/src/lib/agentic/presets/operation-limits.ts @@ -0,0 +1,116 @@ +import { getAddress, zeroAddress } from 'viem'; +import type { RuleAsset, RuleCall } from '../agentic.types'; +import type { PresetCallDefinition } from './catalog'; +import type { PresetCallIssue } from './types'; + +export const assetAddress = (asset: RuleAsset): string => + typeof asset.token === 'string' ? asset.token : asset.token.address; +export const nativeAsset = (asset: RuleAsset): boolean => + assetAddress(asset) === zeroAddress || + (typeof asset.token !== 'string' && asset.token.mechanism === 'native'); + +/** Compile reviewed protocol argument positions. No exchange-rate or share guesses. */ +export function applyOperationLimits( + definitions: readonly PresetCallDefinition[], + assets: RuleAsset[] +) { + const matches = (definition: PresetCallDefinition): RuleAsset[] => { + const b = definition.budget; + if (!b) return []; + if (b.kind === 'native-value') return assets.filter(nativeAsset); + if (b.kind === 'fixed-token') + return assets.filter( + (a) => + !nativeAsset(a) && getAddress(assetAddress(a)) === getAddress(b.token) + ); + if (b.kind === 'token-argument') + return assets.filter( + (a) => + !nativeAsset(a) && + (!b.tokens || + b.tokens.some((t) => getAddress(t) === getAddress(assetAddress(a)))) + ); + return []; + }; + const previewAll = !definitions.some((d) => matches(d).length > 0); + const calls: RuleCall[] = []; + const disabledCalls: PresetCallIssue[] = [], + excludedCalls: PresetCallIssue[] = []; + const knownTokens = new Set(); + let enabled = false; + for (const definition of definitions) { + const { budget, ...call } = definition; + if (!budget || budget.kind === 'setup') { + calls.push(call); + if (!budget) enabled = true; // Non-budgeted internal fixture entries only. + continue; + } + const info = { + target: call.target, + selector: call.selector, + ...(call.app ? { app: call.app } : {}), + }; + if (budget.kind === 'unsupported') { + excludedCalls.push({ + ...info, + reason: budget.reason, + ...(budget.alternative ? { alternative: budget.alternative } : {}), + }); + continue; + } + if (budget.kind === 'fixed-token') + knownTokens.add(getAddress(budget.token)); + if (budget.kind === 'native-value') knownTokens.add(zeroAddress); + if (budget.kind === 'token-argument') + budget.tokens?.forEach((t) => knownTokens.add(getAddress(t))); + const selected = matches(definition); + if (!selected.length && !previewAll) { + excludedCalls.push({ + ...info, + reason: 'UNSELECTED_TOKEN', + ...(budget.kind === 'fixed-token' + ? { token: getAddress(budget.token) } + : {}), + }); + continue; + } + for (const asset of selected.length ? selected : [undefined]) { + const maximum = asset?.maxPerCall ?? BigInt(0); + const pins = [...(call.pins ?? [])]; + if (budget.kind === 'token-argument' && asset) + pins.push({ + arg: budget.tokenArg, + expected: getAddress(assetAddress(asset)), + }); + if (budget.kind !== 'native-value') + pins.push({ arg: budget.amountArg, max: maximum }); + calls.push({ + ...call, + ...(pins.length ? { pins } : {}), + ...(budget.kind === 'native-value' ? { maxValue: maximum } : {}), + }); + if (maximum > BigInt(0)) enabled = true; + else + disabledCalls.push({ + ...info, + reason: asset + ? 'ZERO_CAP' + : assets.length + ? 'UNSELECTED_TOKEN' + : 'TOKEN_REQUIRED', + ...(asset + ? { token: assetAddress(asset) } + : budget.kind === 'fixed-token' + ? { token: getAddress(budget.token) } + : {}), + }); + } + } + return { + calls, + enabled, + disabledCalls, + excludedCalls, + knownTokens: [...knownTokens], + }; +} diff --git a/packages/core/src/lib/agentic/presets/types.ts b/packages/core/src/lib/agentic/presets/types.ts index d7c886422..5f89ffbbb 100644 --- a/packages/core/src/lib/agentic/presets/types.ts +++ b/packages/core/src/lib/agentic/presets/types.ts @@ -1,5 +1,10 @@ import type { Address } from 'viem'; -import type { Rule, RuleAsset, ForeignChainNamespace } from '../agentic.types'; +import type { + Rule, + RuleAsset, + RuleCall, + ForeignChainNamespace, +} from '../agentic.types'; export type PresetCategory = 'DEXES' | 'LENDING' | 'STAKING' | 'PREDICTION'; export interface Preset { @@ -16,8 +21,10 @@ export type PresetRuleTemplate = Omit & { export interface PresetAsset { chainNamespace?: ForeignChainNamespace; token: RuleAsset['token']; - /** Omitted caps are zero, by the user's October 12 decision. */ + /** Per allowed operation and per Push outbound, in raw token units. Omitted = 0. + * Does not aggregate multiple destination calls in one transaction. */ maxPerCall?: bigint; + /** Cumulative Push-outbound amount only; not lifetime activity on destination-held assets. Omitted = 0. */ maxTotal?: bigint; } export interface PresetOptions { @@ -34,11 +41,52 @@ export interface ReadyPresetOptions extends PresetOptions { agent: Address; validUntil: number; } +/** Offline configuration only: does not check deployment, reserves, balances or liquidity. */ +export interface PresetConfigurationIssue { + code: + | 'AGENT_REQUIRED' + | 'EXPIRY_REQUIRED' + | 'GAS_BUDGET_REQUIRED' + | 'ASSETS_REQUIRED' + | 'TOKEN_PAIR_REQUIRED' + | 'NO_ENABLED_OPERATION'; + message: string; +} +export interface PresetCallIssue { + target: string; + selector: RuleCall['selector']; + app?: string; + reason: + | 'TOKEN_REQUIRED' + | 'ZERO_CAP' + | 'UNSELECTED_TOKEN' + | 'SHARE_AMOUNT' + | 'DYNAMIC_AMOUNT' + | 'POSITION_AMOUNT'; + token?: string; + alternative?: RuleCall['selector']; +} +export interface PresetChainReadiness { + chainNamespace: ForeignChainNamespace; + ready: boolean; + issues: PresetConfigurationIssue[]; + /** Calls present in the rule but disabled by a zero amount/value maximum. */ + disabledCalls: PresetCallIssue[]; + /** Calls deliberately omitted, including operations with incompatible amount units. */ + excludedCalls: PresetCallIssue[]; + /** Known fixed-token/reserve addresses in this catalog, not selected defaults. */ + knownTokens: string[]; + /** Selected tokens with a zero Push-outbound budget; destination-held assets are separate. */ + zeroBridgeLimits: string[]; +} +export interface PresetResult< + T extends PresetRuleTemplate = PresetRuleTemplate +> { + rules: T[]; + readiness: { ready: boolean; chains: PresetChainReadiness[] }; +} export interface AgenticPresets { list(options?: { type?: 'CATEGORY' }): { presets: Preset[] }; - get(preset: PresetCategory, options: ReadyPresetOptions): { rules: Rule[] }; - get( - preset: PresetCategory, - options?: PresetOptions - ): { rules: PresetRuleTemplate[] }; + get(preset: PresetCategory, options: ReadyPresetOptions): PresetResult; + get(preset: PresetCategory, options?: PresetOptions): PresetResult; } diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 25edbc944..1783b4396 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -241,5 +241,9 @@ export type { PresetOptions, ReadyPresetOptions, PresetRuleTemplate, + PresetResult, + PresetChainReadiness, + PresetConfigurationIssue, + PresetCallIssue, AgenticPresets, } from './agentic'; From 012dd66ec2c9669b6ba768c7121840f3776b993b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 14:38:22 +0000 Subject: [PATCH 13/27] release(core): bump to 6.0.27-alpha.2 [skip ci] --- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 012da9dde..42d5ee908 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,9 @@ +@pushchain/core@6.0.27-alpha.2 (2026-10-16) + +- feat: add bounded preset operations and configuration readiness + +--- + @pushchain/core@6.0.27-alpha.1 (2026-10-15) - fix: resolve AGW tokens by destination and expand live coverage diff --git a/packages/core/package.json b/packages/core/package.json index a27b13ca6..90f7fe7d4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.27-alpha.1", + "version": "6.0.27-alpha.2", "homepage": "https://push.org", "keywords": [ "pushchain", From 83f9a713dbd6dcd2175bb000319e805ab6cff27a Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Sun, 18 Oct 2026 18:50:43 +0530 Subject: [PATCH 14/27] feat(core): ship bounded preset defaults with funding checks --- packages/core/AGW.md | 69 +++++- .../core/__agw-local__/v5-multiasset.spec.ts | 61 +++++- .../core/__e2e__/agw/presets-live.spec.ts | 63 ++++-- packages/core/scripts/check-agw-package.js | 20 ++ .../core/scripts/check-agw-preset-routes.js | 202 ++++++++++++++++++ .../__tests__/prc20-source-encoding.spec.ts | 63 ++++++ .../agentic/__tests__/preset-catalog.spec.ts | 42 ++-- .../__tests__/preset-default-policy.spec.ts | 168 +++++++++++++++ .../__tests__/preset-default-tokens.spec.ts | 154 +++++++++++++ .../__tests__/preset-operation-limits.spec.ts | 43 ++-- .../agentic/__tests__/preset-routes.spec.ts | 103 +++++++++ .../src/lib/agentic/__tests__/presets.spec.ts | 6 +- .../lib/agentic/contracts/prc20-metadata.ts | 27 ++- .../core/src/lib/agentic/presets/catalog.ts | 15 ++ .../src/lib/agentic/presets/default-policy.ts | 98 +++++++++ .../src/lib/agentic/presets/default-tokens.ts | 80 +++++++ .../core/src/lib/agentic/presets/index.ts | 86 +++++--- .../core/src/lib/agentic/presets/routes.ts | 56 +++++ .../core/src/lib/agentic/presets/types.ts | 40 +++- .../src/lib/universal/prc20-address.spec.ts | 65 ++++++ .../core/src/lib/universal/prc20-address.ts | 32 ++- 21 files changed, 1383 insertions(+), 110 deletions(-) create mode 100644 packages/core/scripts/check-agw-preset-routes.js create mode 100644 packages/core/src/lib/agentic/__tests__/prc20-source-encoding.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts create mode 100644 packages/core/src/lib/agentic/presets/default-policy.ts create mode 100644 packages/core/src/lib/agentic/presets/default-tokens.ts create mode 100644 packages/core/src/lib/agentic/presets/routes.ts create mode 100644 packages/core/src/lib/universal/prc20-address.spec.ts diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 7d5b71be6..0a8a7c60f 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -91,7 +91,7 @@ An equality pin fixes an argument; a maximum pin bounds it. For EVM signatures, `PushChain.CONSTANTS.AGENTIC.WALLET` resolves to the AGW on Push, its CEA on an EVM destination, or its CEA/selected token ATA on Solana. Deployment addresses and encoding helpers are internal. -Omitted `maxValue`, `assets[].maxTotal`, and `maxGasPerCall` default to **zero**. Asset totals meter tokens sent from Push; they do not meter trading or lending using capital already in the destination CEA. Specify positive caps for sends that need value, tokens or destination gas. Native value is limited per call; there is no public native lifetime-total cap. +For directly authored rules, omitted `maxValue`, `assets[].maxTotal`, and `maxGasPerCall` default to **zero**. Presets supply explicit policy values as described below. Asset totals meter tokens sent from Push; they do not meter trading or lending using capital already in the destination CEA. Specify positive caps for sends that need value, tokens or destination gas. Native value is limited per call; there is no public native lifetime-total cap. ## EVM destinations @@ -171,7 +171,7 @@ The wire encoder uses uint32 maximum for unlimited native calls and 255 for unsp - DEXES: Uniswap V3 on Ethereum, Base and Arbitrum (mainnet and their Sepolia testnets); PancakeSwap V3 on BNB mainnet and BSC testnet. - LENDING: Aave on Ethereum, Base, Arbitrum and BNB mainnets, plus Sepolia/Base Sepolia/Arbitrum Sepolia; Moonwell on Base mainnet/testnet and Venus on BNB mainnet/testnet. -- STAKING: EigenLayer deposits on Ethereum mainnet and Sepolia; Lido stake and wrap/unwrap on Ethereum mainnet. Withdrawal requests with dynamic amounts, claims with position-derived amounts and EigenLayer withdrawals are excluded from these bounded presets. A selected native asset supplies the Lido `submit` value cap; omitted caps remain zero. +- STAKING: EigenLayer deposits on Ethereum mainnet and Sepolia; Lido stake and wrap/unwrap on Ethereum mainnet. Withdrawal requests with dynamic amounts, claims with position-derived amounts and EigenLayer withdrawals are excluded from these bounded presets. A selected native asset supplies the Lido `submit` value cap; omitted caps in explicitly supplied assets remain zero. - PREDICTION: no entries yet. Hide categories with empty `chains` lists. ```ts @@ -180,13 +180,24 @@ const availablePresets = presets.filter(preset => preset.chains.length > 0); const { rules: reviewTemplates } = PushChain.utils.agentic.presets.get( PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, ); -// Choose tokens in the review UI, then call get again as below. +// Every populated chain includes curated destination tokens in rule.assets. +// The SDK supplies spending limits, a PC budget and a seven-day expiry. +// Supply the agent to get complete Rule[] configuration. +const configuredPreset = PushChain.utils.agentic.presets.get( + PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.LENDING, + { chainNamespace: 'eip155:11155111', agent: '0x0000000000000000000000000000000000001234', funding: 'destination' }, +); +// Destination assets must already be funded; no Push token exports are granted. +// Show configuredPreset.readiness.chains[0].selectedAssets in the review UI. +// Pass configuredPreset.rules directly to create after checking readiness. +// The helper below illustrates explicit overrides, rather than required UI input. function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { return PushChain.utils.agentic.presets.get( PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, { chainNamespace: 'eip155:11155111', + funding: 'destination', agent, validUntil: Math.floor(Date.now() / 1000) + 3600, maxGasPerCall: BigInt(10) ** BigInt(18), @@ -199,9 +210,9 @@ function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { } ``` -The example caps are illustrative raw units; choose caps for the actual token decimals and gas requirements. `get` groups calls into one rule per chain. The result is `{ rules, readiness }`. With agent and expiry supplied, `rules` is typed as `Rule[]`; otherwise it is `PresetRuleTemplate[]`. This type distinction alone does not establish readiness. Assets must name their chain unless a single chain filter is supplied. Omitted asset caps and `maxGasPerCall` default to zero. +The example caps are illustrative raw units; choose caps for the actual token decimals and gas requirements. `get` groups calls into one rule per chain. The result is `{ rules, readiness }`. With an agent supplied, `rules` is typed as `Rule[]`; otherwise it is `PresetRuleTemplate[]` with a generated expiry and missing agent. This type distinction alone does not establish readiness. When `assets` is omitted, each chain includes its curated destination tokens with the explicit spending limits below. An explicit `assets` array replaces the defaults for every returned chain; `[]` selects no tokens. Assets in an explicit list must name their chain unless a single chain filter is supplied. For an explicit asset list, omitted caps still default to zero. An omitted gas budget uses the preset policy; an explicit zero remains zero. -DEX discovery works without options, with an empty asset list, or with assets for only some chains. A chain with fewer than two tokens returns full-signature review calls with wallet-recipient pins and an explicit zero input-amount maximum. A single supplied token is also pinned as the input. These calls cannot spend swap input tokens, even if asset budgets are filled later. Call `get` again after selecting both tokens to replace the review calls with the complete token-pinned rules; merely assigning `rule.assets` is insufficient. +DEX discovery without `assets` includes a curated wrapped-native/stablecoin pair per chain, with both swap directions, exact-input/output functions and the token-specific input caps below. It does not require the UI to select tokens separately for every network. DEX discovery also works with an explicit empty asset list or with assets for only some chains. A chain with fewer than two explicitly supplied tokens returns full-signature review calls with wallet-recipient pins and an explicit zero input-amount maximum. A single supplied token is also pinned as the input. These calls cannot spend swap input tokens, even if asset budgets are filled later. Call `get` again after selecting both tokens to replace the review calls with the complete token-pinned rules; merely assigning `rule.assets` is insufficient. With two ERC20 tokens, `get` expands ordered pairs into exact-input and exact-output calls with token, wallet-recipient and input-amount pins. Native markers and expansions exceeding the policy's 32-pin limit are rejected. Two tokens fit the shipped router template; three do not. Static tuple fields use paths such as `[0, 3]`. @@ -209,11 +220,55 @@ Reviewed EVM spenders generate approvals pinned to that spender and bounded by t Templates describe permissions, not available liquidity or guaranteed execution. Verify destination token balances, supported reserves and source PRC20 routes before use: protocol testnet mock tokens may differ from MOVEABLE tokens. Presets do not sign, fetch app ABIs or modify wallets. Dynamic-path swaps, Solana protocol templates and unconfirmed deployments are excluded. Mainnet addresses were checked read-only; no mainnet transactions were executed. Other mainnet protocols in the proposal remain excluded until their deployment and permission constraints are verified. Local policy tests do not execute the destination protocols. +### Curated token defaults + +These are small, protocol-specific subsets chosen to fit the 32-call/32-pin ceilings. The full reserve catalog is not automatically granted. + +| Network | DEX pair | Lending tokens | +| --- | --- | --- | +| Ethereum | WETH / USDC | WETH, USDC | +| Base | WETH / USDC | USDC (Aave and Moonwell) | +| Arbitrum | WETH / native USDC | WETH, native USDC | +| BNB | WBNB / USDT | USDT (Aave and Venus) | +| Sepolia | DEX WETH / Circle USDC | Aave faucet LINK | +| Base Sepolia | WETH / Circle USDC | Aave faucet LINK, Moonwell mock USDbC and WETH | +| Arbitrum Sepolia | WETH / Circle USDC | Aave USDC | +| BSC testnet | WBNB / Venus USDT | Venus USDT, USDC | + +Staking defaults are ETH/stETH/wstETH on Ethereum and the reviewed EigenLayer strategy's WETH on Sepolia. Testnet tokens with similar names can have different addresses; the returned addresses are authoritative. These tokens may lack Push PRC20 routes and can instead be used with destination-held balances. Defaults do not guarantee active reserves, liquidity, a Push PRC20 route or execution. In particular, an unmodified preset can fail rule preparation if its default assets lack PRC20 mappings. For destination-only use, pass `funding: 'destination'`: the SDK generates the operation constraints, returns empty rule bridge assets and retains selected token limits in readiness metadata. This grants no token export budget from Push and requires already-funded destination balances. Token addresses are returned as before; metadata was verified when defining the policy. No runtime metadata/price fetch is added to get. + +### Funding and release-network checks + +`funding` defaults to `'push'`. Missing PRC20 registry entries produce `BRIDGE_ASSET_UNAVAILABLE`, and readiness is false while the returned rule retains all requested assets. No address or cap is silently replaced. `network` defaults to `TESTNET_DONUT` (`TESTNET` is accepted as an alias); use the connected client's Push network when supplying it. Networks without a verified AGW deployment report `AGW_NETWORK_UNAVAILABLE`. + +Use `funding: 'destination'` for the prefunded CEA workflow. Selected tokens still generate the same target/function/amount/recipient/spender restrictions, exposed alongside their caps in `readiness.chains[].selectedAssets`. Returned `rule.assets` is intentionally empty, which grants zero Push token-export authority. It is safe to pass these returned rules directly to create/update. The mode does not fund the destination, wrap ETH, mint testnet tokens or enable an unsupported network. Do not relabel it as bridge funding in the UI. + +Existing mappings distinguish token identity by chain and address, including address casing for EVM. WETH/stETH Sepolia mappings and string/address-word source metadata are supported; mainnet or other-chain WETH is never aliased to them. Protocol mock LINK, Moonwell/Venus mocks and Circle testnet USDC can differ from the supported source tokens; they remain unavailable for bridge funding until real matching routes exist. + +### Default permission policy + +Only preset-generated assets receive these positive values. Direct rules, unknown tokens and explicit asset lists do not acquire guessed limits. + +| Selected token | Per allowed call / Push outbound | Cumulative Push-outbound total | +| --- | --- | --- | +| USDC / USDT / mock USDbC | 10 tokens | 100 tokens | +| ETH / WETH / stETH / wstETH | 0.001 token | 0.01 token | +| WBNB | 0.01 token | 0.1 token | +| LINK | 1 token | 10 tokens | + +Amounts use verified token decimals, keyed by chain and address, rather than symbol or live prices. For example, the USDT per-call limit is `10 * 10^18` on BNB mainnet and `10 * 10^6` on BSC testnet. These are fixed token quantities, not USD budgets. + +The default expiry is seven days from each get call, computed once for all returned rules. The default gas ceiling is **20 PC per outbound**, including the value checked by URP; it is not a flat fee or a reservation. Actual sends obtain a fresh quote and remain subject to the rule cap. This ceiling has testnet protocol-flow coverage; mainnet templates do not have funded execution acceptance. Quotes may exceed the cap, requiring an explicit higher budget. + +Explicit `validUntil` and `maxGasPerCall` override policy values; zero gas is preserved and reports incomplete readiness. An explicit `assets` array replaces all default tokens and limits, and omitted caps inside it remain zero. To change a preset cap while retaining its token set, take the returned assets, replace the caps and pass them back to get. Do not just edit the returned rule assets: the allowed-call pins and approvals also need regeneration. + +`maxPerCall` applies to each allowed destination call; multiple calls may each consume that amount. `maxTotal` meters assets sent from Push, not aggregate destination activity. No destination lifetime limit is implied by these defaults. The general rules API continues to treat omitted caps as zero. + ### Configure, inspect and regenerate -`readiness.ready` means the returned chains have locally complete inputs and at least one enabled asset operation. It does not check PRC20 availability, deployments, balances, live reserve flags or liquidity. `readiness.chains` gives per-chain `issues`, `disabledCalls`, `excludedCalls`, catalog `knownTokens` and `zeroBridgeLimits`. These are additive result fields; `rules` remains accepted by the existing create/update methods. +`readiness.ready` requires complete configuration plus offline SDK deployment/destination/bridge-registry support for the selected funding mode. Per-chain `configurationReady` reports input completeness separately. These checks do not verify live balances, reserve flags, liquidity, current deployed bytecode or source identity; grant preparation still verifies wiring and token metadata on-chain before signing. `readiness.chains` gives per-chain `issues`, `disabledCalls`, `excludedCalls`, catalog `knownTokens`, `selectedAssets`, `bridgeAssets`, `funding` and `zeroBridgeLimits`. Display `selectedAssets` as the reviewed token/amount permissions in either mode; `rule.assets` describes Push bridge permissions only. `bridgeAssets` reports registry presence, not verified live deployment. Mainnet templates remain discoverable but have `DESTINATION_UNAVAILABLE` on Donut. These are additive result fields; `rules` remains accepted by the existing create/update methods. -The UI should collect chain, agent, token selections, raw-unit limits, gas budget and expiry. Show zero-cap calls as disabled. Present excluded calls explicitly. There is no SDK-selected 0.1 PC gas budget or 30-day expiry. A zero bridge limit can be intentional when assets are already at the destination; it is reported separately from operation readiness. +Tokens and permission defaults come directly from the SDK: omitted `assets` populates the curated subset and its caps in every returned `rule.assets`. Omitted gas and expiry use the policy below. The caller supplies the agent and can filter chains; no per-network token selection is required. Show the generated permissions for owner review and allow explicit overrides. Regenerate via get after an edit so operation pins/approvals match the new caps. Show explicitly zero-cap calls as disabled and present excluded calls clearly. A zero bridge limit can be intentional when assets are already at the destination; it is reported separately from operation readiness. ```ts async function createConfiguredDex( diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index ba06384a2..859d1247f 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -308,17 +308,19 @@ describe('v6 multi-asset SDK management and execution', () => { }); it.each(['exactInputSingle', 'exactOutputSingle'] as const)( - 'a review-only DEX %s call cannot spend tokens even after asset budgets are filled', + 'a default-token DEX %s call rejects spending above its policy cap despite edited bridge budgets', async (functionName) => { - const preview = agenticPresets.get('DEXES', { chainNamespace: CHAIN_NS }) - .rules[0]; - const { agent } = await setup({ - ...preview, + const preview = agenticPresets.get('DEXES', { + chainNamespace: CHAIN_NS, agent: h.wallets[1].account!.address, - validUntil: Math.floor(Date.now() / 1000) + 3600, - maxGasPerCall: BigInt(10) ** BigInt(16), + }).rules[0]; + const { wallet, id, agent } = await setup({ + ...preview, assets: [{ token: a, maxPerCall: BigInt(5), maxTotal: BigInt(20) }], }); + const stored = await owner.agentic.wallet(wallet).rules.get(id); + expect(stored.rule.validUntil).toBe(preview.validUntil); + expect(stored.rule.maxGasPerCall).toBe(preview.maxGasPerCall); const router = preview.allowedCalls[0].target as Address; const abi = parseAbi([ `function ${functionName}((address,address,uint24,address,uint256,uint256,uint160))`, @@ -333,7 +335,17 @@ describe('v6 multi-asset SDK management and execution', () => { data: encodeFunctionData({ abi, functionName, - args: [[a, b, 3000, CEA, BigInt(1), BigInt(1), BigInt(0)]], + args: [ + [ + preview.assets![0].token as Address, + preview.assets![1].token as Address, + 3000, + CEA, + preview.assets![0].maxPerCall + BigInt(1), + preview.assets![0].maxPerCall + BigInt(1), + BigInt(0), + ], + ], }), }, ], @@ -344,6 +356,35 @@ describe('v6 multi-asset SDK management and execution', () => { } ); + it.each(['DEXES', 'LENDING', 'STAKING'] as const)( + 'public %s destination-funded defaults create directly without rewriting returned rules', + async (category) => { + const generated = agenticPresets.get(category, { + chainNamespace: CHAIN_NS, + agent: h.wallets[1].account!.address, + funding: 'destination', + }); + expect(generated.readiness.ready).toBe(true); + expect( + generated.readiness.chains[0].selectedAssets.length + ).toBeGreaterThan(0); + expect(generated.rules[0].assets).toEqual([]); + const { wallet, id } = await setup(generated.rules[0]); + const actual = await stored(wallet, id); + expect(actual.assets).toEqual([ + { + token: a, + maxPerCall: BigInt(0), + maxTotal: BigInt(0), + spent: BigInt(0), + }, + ]); + const record = await owner.agentic.wallet(wallet).rules.get(id); + expect(record.rule.maxGasPerCall).toBe(generated.rules[0].maxGasPerCall); + expect(record.rule.validUntil).toBe(generated.rules[0].validUntil); + } + ); + it.each(['LENDING', 'STAKING'] as const)( 'regenerated %s presets enforce new token/amount pins through atomic rules.update', async (category) => { @@ -359,7 +400,7 @@ describe('v6 multi-asset SDK management and execution', () => { validUntil: Math.floor(Date.now() / 1000) + 3600, maxGasPerCall: BigInt(10) ** BigInt(16), }; - const preview = agenticPresets.get(category, opts); + const preview = agenticPresets.get(category, { ...opts, assets: [] }); expect(preview.readiness.ready).toBe(false); const { wallet, id, agent } = await setup({ ...preview.rules[0], @@ -400,7 +441,7 @@ describe('v6 multi-asset SDK management and execution', () => { { token: firstToken, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, ], }); - expect(first.readiness.ready).toBe(true); + expect(first.readiness.chains[0].configurationReady).toBe(true); const before = await h.publicClient.readContract({ address: wallet, abi: h.generation.contracts.abis.wallet, diff --git a/packages/core/__e2e__/agw/presets-live.spec.ts b/packages/core/__e2e__/agw/presets-live.spec.ts index dfcfe6113..48fab87ed 100644 --- a/packages/core/__e2e__/agw/presets-live.spec.ts +++ b/packages/core/__e2e__/agw/presets-live.spec.ts @@ -73,7 +73,26 @@ d('agw presets live', () => { 'PRESET_PREREQUISITE: Sepolia pool has no active liquidity' ); f = await setupAgw(); + const defaults = PushChain.utils.agentic.presets.get('DEXES', { + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + agent: f.agentAddress, + funding: 'destination', + }); + expect(defaults.readiness.ready).toBe(true); + expect(defaults.rules[0].maxGasPerCall).toBe(MAX_PC); + expect(defaults.rules[0].validUntil).toBeGreaterThan(inSeconds(6 * 86400)); + expect( + defaults.readiness.chains[0].selectedAssets.map((a) => + a.token.toString().toLowerCase() + ) + ).toEqual([WETH.toLowerCase(), USDC.toLowerCase()]); + expect( + defaults.readiness.chains[0].selectedAssets.every( + (a) => a.maxPerCall > BigInt(0) + ) + ).toBe(true); const { rules } = PushChain.utils.agentic.presets.get('DEXES', { + funding: 'destination', chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: f.agentAddress, validUntil: inSeconds(3600), @@ -91,7 +110,7 @@ d('agw presets live', () => { // tokens, so omit bridge assets without changing any generated call or pin. const { wallet, rulesIds } = await f.owner.agentic.create( 'e2e-preset-uniswap', - { rules: [{ ...rules[0], assets: [] }] } + { rules } ); const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) .cea as Address; @@ -268,16 +287,36 @@ d('agw presets live', () => { ); f?.teardown(); f = await setupAgw(); + const defaults = PushChain.utils.agentic.presets.get('LENDING', { + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + agent: f.agentAddress, + funding: 'destination', + }); + expect(defaults.readiness.ready).toBe(true); + expect(defaults.readiness.chains[0].selectedAssets[0].maxPerCall).toBe( + parseEther('1') + ); + expect(defaults.rules[0].maxGasPerCall).toBe(MAX_PC); + expect( + defaults.readiness.chains[0].selectedAssets.map((a) => + a.token.toString().toLowerCase() + ) + ).toEqual([mockLINK.toLowerCase()]); const { rules } = PushChain.utils.agentic.presets.get('LENDING', { + funding: 'destination', chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: f.agentAddress, validUntil: inSeconds(3600), maxGasPerCall: MAX_PC, - assets: [{ token: mockLINK, maxPerCall: amount, maxTotal: amount }], + assets: defaults.readiness.chains[0].selectedAssets.map((a) => ({ + ...a, + maxPerCall: amount, + maxTotal: amount, + })), }); const { wallet, rulesIds } = await f.owner.agentic.create( 'e2e-preset-aave', - { rules: [{ ...rules[0], assets: [] }] } + { rules } ); const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) .cea as Address; @@ -417,16 +456,14 @@ d('agw presets live', () => { assets: [{ token: mockLINK, maxPerCall: half, maxTotal: amount }], }); expect(regenerated.readiness.ready).toBe(true); - const changed = await f.owner.agentic - .wallet(wallet) - .rules.update({ - rules: [ - { - rulesId: rulesIds[0], - rule: { ...regenerated.rules[0], assets: [] }, - }, - ], - }); + const changed = await f.owner.agentic.wallet(wallet).rules.update({ + rules: [ + { + rulesId: rulesIds[0], + rule: { ...regenerated.rules[0], assets: [] }, + }, + ], + }); await expect( agent.universal.sendTransaction({ to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, diff --git a/packages/core/scripts/check-agw-package.js b/packages/core/scripts/check-agw-package.js index 85e87be47..7384461d0 100644 --- a/packages/core/scripts/check-agw-package.js +++ b/packages/core/scripts/check-agw-package.js @@ -23,6 +23,26 @@ async function main() { ); assert(pack.files.some((f) => f.path === 'AGW.md')); assert(!pack.files.some((f) => /(^|\/)\.env($|\.)/.test(f.path))); + assert( + !pack.files.some((f) => + /(^|\/)(plan|__e2e__|__agw-local__|scripts)(\/|$)/.test(f.path) + ) + ); + const preset = cjs.PushChain.utils.agentic.presets.get('LENDING', { + agent: '0x0000000000000000000000000000000000005678', + chainNamespace: 'eip155:11155111', + funding: 'destination', + }); + assert.equal(preset.readiness.ready, true); + assert.deepEqual(preset.rules[0].assets, []); + assert(preset.readiness.chains[0].selectedAssets.length > 0); + assert.equal( + cjs.PushChain.utils.agentic.presets.get('LENDING', { + agent: '0x0000000000000000000000000000000000005678', + chainNamespace: 'eip155:11155111', + }).readiness.ready, + false + ); const guide = fs.readFileSync(path.join(built, 'AGW.md'), 'utf8'); const source = [...guide.matchAll(/```ts\n([\s\S]*?)```/g)] .map((m) => m[1]) diff --git a/packages/core/scripts/check-agw-preset-routes.js b/packages/core/scripts/check-agw-preset-routes.js new file mode 100644 index 000000000..ff524ea3c --- /dev/null +++ b/packages/core/scripts/check-agw-preset-routes.js @@ -0,0 +1,202 @@ +/** Read-only Donut acceptance of built preset configuration and factory calldata. + * Build core first. No keys, signing, funding, deployments or broadcasts. + * Usage: node packages/core/scripts/check-agw-preset-routes.js /tmp/report.json + */ +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { createPublicClient, http, getAddress } = require('viem'); +const built = path.resolve(__dirname, '../../..', 'dist/packages/core/src'); +const { PushChain } = require(built); +const { CHAIN, PUSH_NETWORK } = PushChain.CONSTANTS; +const { currentGeneration, verifyFactoryWiring } = require(path.join( + built, + 'lib/agentic/deployments' +)); +const { Snapshot } = require(path.join(built, 'lib/agentic/reads/snapshot')); +const { publicRules } = require(path.join(built, 'lib/agentic/public-rule')); +const { prepareWalletRules } = require(path.join( + built, + 'lib/agentic/management/prepare' +)); +const { getCEAAddress } = require(path.join( + built, + 'lib/orchestrator/cea-utils' +)); +const { readOriginToken, PRC20_SOURCE_ABI } = require(path.join( + built, + 'lib/agentic/contracts/prc20-metadata' +)); +const { getPRC20Address } = require(path.join( + built, + 'lib/universal/prc20-address' +)); + +const endpoints = { + [CHAIN.ETHEREUM_SEPOLIA]: 'https://ethereum-sepolia-rpc.publicnode.com', + [CHAIN.BASE_SEPOLIA]: 'https://base-sepolia-rpc.publicnode.com', + [CHAIN.ARBITRUM_SEPOLIA]: 'https://arbitrum-sepolia-rpc.publicnode.com', + [CHAIN.BNB_TESTNET]: 'https://bsc-testnet-rpc.publicnode.com', +}; + +async function main() { + const report = { + at: new Date().toISOString(), + readOnly: true, + network: PUSH_NETWORK.TESTNET_DONUT, + cases: [], + }; + const rpc = createPublicClient({ + transport: http('https://evm.donut.rpc.push.org/', { + timeout: 15000, + retryCount: 1, + }), + }); + assert.equal(await rpc.getChainId(), 42101); + const owner = getAddress('0x0A16CBa65FfCAa4C2282b27b027Ab4A2fE46E0Bf'); + const agent = getAddress('0x0000000000000000000000000000000000005678'); + // A UniversalAccount creates a read-mode client: there is no signer here. + const client = await PushChain.initialize( + { address: owner, chain: CHAIN.PUSH_TESTNET_DONUT }, + { network: PUSH_NETWORK.TESTNET_DONUT } + ); + const runtime = client.agenticRuntime; + assert.equal(runtime.isReadOnly, true); + // Same CEA factory read, with explicit public endpoints for reproducibility. + runtime.resolveCEA = (wallet, chain) => + getCEAAddress(wallet, chain, endpoints[chain]); + const gen = currentGeneration(runtime.network); + await verifyFactoryWiring(runtime.reader, gen); + const snap = await Snapshot.at(runtime.reader); + report.blockNumber = String(snap.blockNumber); + report.factory = gen.addresses.factory; + report.verifiedERC20Mappings = []; + for (const source of [ + '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + '0x3e3FE7dBc6B4C189E7128855dD526361c49b40Af', + ]) { + const mapped = getPRC20Address({ + chain: CHAIN.ETHEREUM_SEPOLIA, + address: getAddress(source), + }); + assert.equal( + await snap.read( + mapped.address, + PRC20_SOURCE_ABI, + 'SOURCE_CHAIN_NAMESPACE' + ), + CHAIN.ETHEREUM_SEPOLIA + ); + assert.equal( + await readOriginToken(snap, mapped.address, CHAIN.ETHEREUM_SEPOLIA), + getAddress(source) + ); + report.verifiedERC20Mappings.push({ + source: getAddress(source), + prc20: mapped.address, + status: 'source-identity-verified', + }); + } + const index = await snap.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'walletCount', + [owner] + ); + const [wallet, deployed] = await snap.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'predictWallet', + [owner, index] + ); + assert.equal(deployed, false); + report.counterfactualWallet = wallet; + for (const category of ['DEXES', 'LENDING', 'STAKING']) { + const chains = PushChain.utils.agentic.presets + .list() + .presets.find((p) => p.constant === category) + .chains.filter((c) => endpoints[c]); + for (const chainNamespace of chains) { + const result = { category, chainNamespace }; + try { + const push = PushChain.utils.agentic.presets.get(category, { + agent, + chainNamespace, + }); + result.pushReadiness = push.readiness; + const destination = PushChain.utils.agentic.presets.get(category, { + agent, + chainNamespace, + funding: 'destination', + }); + assert.equal(destination.readiness.ready, true); + assert.deepEqual(destination.rules[0].assets, []); + const prepared = await prepareWalletRules( + runtime, + gen, + owner, + wallet, + publicRules(destination.rules), + snap + ); + const data = gen.contracts.encodeCreateWalletAt( + owner, + index, + wallet, + 'preset-readonly-check', + prepared.map((p) => p.session) + ); + const simulated = await rpc.call({ + account: owner, + to: gen.addresses.factory, + data, + blockNumber: snap.blockNumber, + gas: BigInt(20000000), + }); + assert(simulated.data && simulated.data !== '0x'); + result.destinationFactorySimulation = 'passed'; + result.selectedAssets = destination.readiness.chains[0].selectedAssets; + // If static registry support is advertised, verify preparation against real metadata too. + if (push.readiness.ready) { + await prepareWalletRules( + runtime, + gen, + owner, + wallet, + publicRules(push.rules), + snap + ); + result.pushPreparation = 'passed'; + } else { + result.pushPreparation = 'unavailable-in-registry'; + } + console.log( + `${category} ${chainNamespace}: destination factory eth_call passed; Push ${result.pushPreparation}` + ); + } catch (error) { + result.error = error.shortMessage || error.message; + process.exitCode = 1; + console.error(`${category} ${chainNamespace}: ${result.error}`); + } + report.cases.push(result); + } + } + const output = process.argv[2] || '/tmp/agw-preset-route-acceptance.json'; + fs.writeFileSync( + output, + JSON.stringify( + report, + (_, value) => (typeof value === 'bigint' ? value.toString() : value), + 2 + ) + ); + console.log( + `Read-only report: ${output}; ${ + report.cases.filter((c) => !c.error).length + }/${report.cases.length} passed.` + ); +} +main().catch((error) => { + console.error(error.shortMessage || error.message); + process.exitCode = 1; +}); diff --git a/packages/core/src/lib/agentic/__tests__/prc20-source-encoding.spec.ts b/packages/core/src/lib/agentic/__tests__/prc20-source-encoding.spec.ts new file mode 100644 index 000000000..61e1ba95e --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/prc20-source-encoding.spec.ts @@ -0,0 +1,63 @@ +import { getAddress, zeroAddress } from 'viem'; +import { readOriginToken, CORE_ADDRESS } from '../contracts/prc20-metadata'; +import { Snapshot } from '../reads/snapshot'; +import type { ChainReader } from '../contracts/reader'; +import { ADDR } from './fake-chain'; + +it.each(['string', 'address'] as const)( + 'reads exact non-native source identity from the %s ABI', + async (encoding) => { + const readContract = jest.fn(async (request) => { + const output = request.abi.find( + (f: { name: string }) => f.name === request.functionName + ).outputs[0].type; + if (output !== encoding) throw new Error('different source ABI'); + return ADDR.target; + }); + const snap = new Snapshot( + { readContract } as unknown as ChainReader, + BigInt(123) + ); + expect(await readOriginToken(snap, ADDR.other, 'eip155:11155111')).toBe( + getAddress(ADDR.target) + ); + expect( + readContract.mock.calls.every(([r]) => r.blockNumber === BigInt(123)) + ).toBe(true); + } +); +it('accepts an address-word zero only for the registered native gas token', async () => { + const readContract = jest.fn(async (request) => { + if (request.address === CORE_ADDRESS) return ADDR.other; + const output = request.abi.find( + (f: { name: string }) => f.name === request.functionName + ).outputs[0].type; + if (output === 'string') throw new Error('address ABI'); + return zeroAddress; + }); + const snap = new Snapshot( + { readContract } as unknown as ChainReader, + BigInt(123) + ); + expect(await readOriginToken(snap, ADDR.other, 'eip155:11155111')).toBe( + zeroAddress + ); + await expect( + readOriginToken(snap, ADDR.target, 'eip155:11155111') + ).rejects.toThrow(/non-native.*empty source/); +}); +it('preserves a failed read when neither source encoding can be read', async () => { + const snap = new Snapshot( + { + readContract: async () => { + throw new Error('rpc offline'); + }, + } as unknown as ChainReader, + BigInt(123) + ); + await expect( + readOriginToken(snap, ADDR.other, 'eip155:11155111') + ).rejects.toMatchObject({ + details: { blockNumber: BigInt(123), functionName: 'SOURCE_TOKEN_ADDRESS' }, + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts index 1ce41fdae..0adcce47c 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts @@ -192,24 +192,42 @@ it('mutating a returned tuple path cannot change a later preset', () => { ).toEqual([0, 0]); }); -it('returns one zero-input-cap review template per chain without options', () => { - const { rules } = agenticPresets.get('DEXES'); +it('includes a curated capped token pair and exact pair pins on every DEX chain', () => { + const { rules, readiness } = agenticPresets.get('DEXES'); expect(rules.map((r) => r.chainNamespace)).toEqual( agenticPresets.list().presets.find((p) => p.constant === 'DEXES')!.chains ); for (const rule of rules) { expect(rule).not.toHaveProperty('agent'); - expect(rule).not.toHaveProperty('validUntil'); - expect(rule).not.toHaveProperty('assets'); - expect(rule.allowedCalls).toHaveLength(2); - rule.allowedCalls.forEach((call, i) => { - expect(call.pins).toEqual([ - { arg: [0, 3], expected: AGENTIC.WALLET }, - { arg: [0, i === 0 ? 4 : 5], max: BigInt(0) }, - ]); - expect(call.label).toContain('select two tokens'); - }); + expect(rule.validUntil).toBeGreaterThan(Math.floor(Date.now() / 1000)); + expect(rule.assets).toHaveLength(2); + expect( + rule.assets!.every( + (a) => + a.maxPerCall > BigInt(0) && (a.maxTotal ?? BigInt(0)) >= a.maxPerCall + ) + ).toBe(true); + expect(rule.allowedCalls).toHaveLength(6); + const swaps = rule.allowedCalls.filter((c) => + c.selector.startsWith('exact') + ); + expect(swaps).toHaveLength(4); + for (const call of swaps) { + expect(call.pins).toHaveLength(4); + expect(call.pins).toContainEqual({ + arg: [0, 3], + expected: AGENTIC.WALLET, + }); + expect(call.pins?.at(-1)?.max).toBeGreaterThan(BigInt(0)); + expect(call.label).not.toContain('select two tokens'); + } } + expect(readiness.chains.every((c) => c.knownTokens.length >= 2)).toBe(true); + expect( + readiness.chains.every( + (c) => !c.issues.some((i) => i.code === 'TOKEN_PAIR_REQUIRED') + ) + ).toBe(true); }); it('keeps single-token swaps disabled and generates approvals only for the supplied asset', () => { const rule = agenticPresets.get('DEXES', { diff --git a/packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts new file mode 100644 index 000000000..5e72d2bd4 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts @@ -0,0 +1,168 @@ +import { getAddress, parseEther, zeroAddress } from 'viem'; +import { agenticPresets } from '../presets'; +import { defaultPresetAsset } from '../presets/default-policy'; +import type { Rule } from '../agentic.types'; +import { ADDR } from './fake-chain'; + +const chainNamespace = 'eip155:11155111' as const; +const agent = ADDR.agent; + +it.each(['DEXES', 'LENDING', 'STAKING'] as const)( + 'an agent alone produces typed, locally complete %s rules on all catalog chains', + (category) => { + const { rules, readiness } = agenticPresets.get(category, { agent }); + const typed: Rule[] = rules; + expect(typed.length).toBeGreaterThan(0); + expect(readiness.chains.every((c) => c.configurationReady)).toBe(true); + for (const rule of typed) { + expect(rule.agent).toBe(agent); + expect(rule.maxGasPerCall).toBe(parseEther('20')); + expect(rule.validUntil).toBeGreaterThan(Math.floor(Date.now() / 1000)); + expect(rule.assets?.every((a) => a.maxPerCall > BigInt(0))).toBe(true); + } + } +); + +it('generates one expiry seven days from this get call, refreshing on the next call', () => { + const clock = jest.spyOn(Date, 'now'); + try { + const now = 1_800_000_000; + clock.mockReturnValue(now * 1000); + const first = agenticPresets.get('DEXES', { agent }); + expect(new Set(first.rules.map((r) => r.validUntil))).toEqual( + new Set([now + 604800]) + ); + clock.mockReturnValue((now + 60) * 1000); + expect(agenticPresets.get('DEXES', { agent }).rules[0].validUntil).toBe( + now + 60 + 604800 + ); + expect(first.rules[0].validUntil).toBe(now + 604800); + } finally { + clock.mockRestore(); + } +}); + +it('uses verified raw-unit limits for stablecoins with different decimals', () => { + const mainnet = agenticPresets.get('DEXES', { + agent, + chainNamespace: 'eip155:56', + }).rules[0]; + const testnet = agenticPresets.get('DEXES', { + agent, + chainNamespace: 'eip155:97', + }).rules[0]; + expect(mainnet.assets?.[1]).toMatchObject({ + maxPerCall: BigInt('10000000000000000000'), + maxTotal: BigInt('100000000000000000000'), + }); + expect(testnet.assets?.[1]).toMatchObject({ + maxPerCall: BigInt('10000000'), + maxTotal: BigInt('100000000'), + }); + const aave = agenticPresets.get('LENDING', { + agent, + chainNamespace: 'eip155:1', + }).rules[0]; + expect(aave.assets?.[1]).toMatchObject({ + token: getAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'), + maxPerCall: BigInt('10000000'), + maxTotal: BigInt('100000000'), + }); +}); + +it('uses token quantities for native, wrapped-native and LINK defaults', () => { + const eth = agenticPresets.get('STAKING', { + agent, + chainNamespace: 'eip155:1', + }).rules[0]; + expect(eth.assets?.[0]).toEqual({ + token: zeroAddress, + maxPerCall: parseEther('0.001'), + maxTotal: parseEther('0.01'), + }); + expect( + eth.allowedCalls.find((c) => c.selector === 'submit(address)')?.maxValue + ).toBe(parseEther('0.001')); + const link = agenticPresets.get('LENDING', { agent, chainNamespace }) + .rules[0]; + expect(link.assets?.[0]).toMatchObject({ + maxPerCall: parseEther('1'), + maxTotal: parseEther('10'), + }); + const bnb = agenticPresets.get('DEXES', { + agent, + chainNamespace: 'eip155:97', + }).rules[0]; + expect(bnb.assets?.[0]).toMatchObject({ + maxPerCall: parseEther('0.01'), + maxTotal: parseEther('0.1'), + }); +}); + +it('preserves explicit expiry and gas, including a zero gas ceiling', () => { + const validUntil = Math.floor(Date.now() / 1000) + 600; + const disabled = agenticPresets.get('LENDING', { + agent, + chainNamespace, + validUntil, + maxGasPerCall: BigInt(0), + }); + expect(disabled.rules[0].validUntil).toBe(validUntil); + expect(disabled.rules[0].maxGasPerCall).toBe(BigInt(0)); + expect(disabled.readiness.ready).toBe(false); + expect(disabled.readiness.chains[0].issues.map((i) => i.code)).toEqual( + expect.arrayContaining(['GAS_BUDGET_REQUIRED', 'BRIDGE_ASSET_UNAVAILABLE']) + ); + expect( + agenticPresets.get('LENDING', { + agent, + chainNamespace, + maxGasPerCall: BigInt(123), + }).rules[0].maxGasPerCall + ).toBe(BigInt(123)); +}); + +it('never invents caps for explicit assets, even when their address is a default', () => { + const defaults = agenticPresets.get('LENDING', { agent, chainNamespace }) + .rules[0]; + const token = defaults.assets?.[0].token; + if (!token) throw new Error('missing default asset'); + const supplied = agenticPresets.get('LENDING', { + agent, + chainNamespace, + assets: [{ token }], + }); + expect(supplied.rules[0].assets).toEqual([ + { token, maxPerCall: BigInt(0), maxTotal: BigInt(0) }, + ]); + expect(supplied.readiness.ready).toBe(false); + const totalZero = agenticPresets.get('LENDING', { + agent, + chainNamespace, + assets: [{ token, maxPerCall: BigInt(5), maxTotal: BigInt(0) }], + }); + expect(totalZero.rules[0].assets?.[0].maxTotal).toBe(BigInt(0)); + expect(totalZero.readiness.chains[0].zeroBridgeLimits).toEqual([token]); +}); + +it('does not grant the default tokens on a different chain when assets is explicit', () => { + const res = agenticPresets.get('LENDING', { + agent, + assets: [{ chainNamespace, token: ADDR.other, maxPerCall: BigInt(5) }], + }); + expect( + res.rules + .filter((r) => r.chainNamespace !== chainNamespace) + .every((r) => r.assets?.length === 0) + ).toBe(true); + expect(res.readiness.ready).toBe(false); +}); + +it('refuses unreviewed default tokens instead of inferring permissions from symbols', () => { + expect(() => defaultPresetAsset('eip155:999', ADDR.other)).toThrow( + /No reviewed default spending policy/ + ); + expect(() => defaultPresetAsset(chainNamespace, ADDR.other)).toThrow( + /No reviewed default spending policy/ + ); +}); diff --git a/packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts new file mode 100644 index 000000000..d84126609 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts @@ -0,0 +1,154 @@ +import { getAddress } from 'viem'; +import { agenticPresets } from '../presets'; +import { buildPresetApi } from '../presets'; +import type { PresetDefinition } from '../presets/catalog'; +import { ADDR } from './fake-chain'; + +const options = { + agent: ADDR.agent, + validUntil: 2_000_000_000, + maxGasPerCall: BigInt(1), +}; + +it.each(['DEXES', 'LENDING', 'STAKING'] as const)( + '%s includes destination tokens independently on every supported network within policy limits', + (category) => { + const result = agenticPresets.get(category, options); + expect(result.rules.length).toBeGreaterThan(0); + for (const rule of result.rules) { + expect(rule.assets!.length).toBeGreaterThan(0); + expect(rule.assets!.length).toBeLessThanOrEqual(8); + expect(rule.allowedCalls.length).toBeLessThanOrEqual(32); + expect( + rule.allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) + ).toBeLessThanOrEqual(32); + expect( + rule.assets!.every( + (a) => + a.maxPerCall > BigInt(0) && + (a.maxTotal ?? BigInt(0)) >= a.maxPerCall + ) + ).toBe(true); + } + expect(result.readiness.chains.every((c) => c.configurationReady)).toBe( + true + ); + expect(result.readiness.chains.every((c) => c.configurationReady)).toBe( + true + ); + } +); + +it('selects protocol-specific testnet tokens rather than reusing the DEX pair for lending', () => { + const lending = agenticPresets.get('LENDING', { + chainNamespace: 'eip155:11155111', + }).rules[0]; + const dex = agenticPresets.get('DEXES', { chainNamespace: 'eip155:11155111' }) + .rules[0]; + expect(lending.assets!.map((a) => a.token)).toEqual([ + getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), + ]); + expect(dex.assets!.map((a) => a.token)).toEqual([ + getAddress('0xfff9976782d46cc05630d1f6ebab18b2324d6b14'), + getAddress('0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'), + ]); + expect( + agenticPresets.get('STAKING', { chainNamespace: 'eip155:11155111' }) + .rules[0].assets![0].token + ).toBe(getAddress('0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9')); +}); + +it('preserves an explicit empty list instead of silently repopulating tokens', () => { + const result = agenticPresets.get('DEXES', { ...options, assets: [] }); + expect( + result.rules.every( + (r) => r.assets?.length === 0 && r.allowedCalls.length === 2 + ) + ).toBe(true); + expect( + result.readiness.chains.every((c) => + c.issues.some((i) => i.code === 'ASSETS_REQUIRED') + ) + ).toBe(true); +}); + +it('an explicit selection replaces defaults on all chains without filling other networks', () => { + const assets = [ + { + chainNamespace: 'eip155:11155111' as const, + token: ADDR.target, + maxPerCall: BigInt(2), + }, + ]; + const result = agenticPresets.get('STAKING', { ...options, assets }); + expect( + result.rules.find((r) => r.chainNamespace === 'eip155:11155111')!.assets + ).toEqual([ + { token: ADDR.target, maxPerCall: BigInt(2), maxTotal: BigInt(0) }, + ]); + expect( + result.rules.find((r) => r.chainNamespace === 'eip155:1')!.assets + ).toEqual([]); + expect(assets).toHaveLength(1); +}); + +it('regenerates token constraints and approvals from returned default assets with supplied caps', () => { + const request = { ...options, chainNamespace: 'eip155:11155111' as const }; + const preview = agenticPresets.get('LENDING', request); + const result = agenticPresets.get('LENDING', { + ...request, + assets: preview.rules[0].assets!.map((a) => ({ + ...a, + maxPerCall: BigInt(5), + maxTotal: BigInt(10), + })), + }); + expect(result.readiness.chains.every((c) => c.configurationReady)).toBe(true); + expect( + result.rules[0].allowedCalls.every((c) => + c.pins?.some((p) => p.max === BigInt(5)) + ) + ).toBe(true); + expect(preview.rules[0].assets![0].maxPerCall).toBe(BigInt(10) ** BigInt(18)); +}); + +it('isolates defaults from returned assets, caller mutations and input catalog mutations', () => { + const catalog: PresetDefinition[] = [ + { + constant: 'STAKING', + name: 'Fixture', + chains: [ + { + chainNamespace: 'eip155:11155111', + defaultTokens: [ + getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), + ], + allowedCalls: [ + { + target: ADDR.other, + selector: 'deposit(uint256)', + budget: { + kind: 'fixed-token', + token: getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), + amountArg: 0, + }, + }, + ], + }, + ], + }, + ]; + const api = buildPresetApi(catalog); + const first = api.get('STAKING'); + first.rules[0].assets![0].token = ADDR.other; + first.rules[0].assets!.push({ token: ADDR.agent, maxPerCall: BigInt(1) }); + first.readiness.chains[0].knownTokens.length = 0; + catalog[0].chains[0].defaultTokens = [ADDR.other]; + expect(api.get('STAKING').rules[0].assets).toEqual([ + { + token: getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), + maxPerCall: BigInt(10) ** BigInt(18), + maxTotal: BigInt(10) ** BigInt(19), + }, + ]); +}); diff --git a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts index 42a2dcbe0..5d1a73f17 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts @@ -22,7 +22,7 @@ it('Aave pins the selected reserve, operation amount and wallet beneficiary for ...options, assets: [asset(), asset(WBTC, BigInt(7))], }); - expect(readiness.ready).toBe(true); + expect(readiness.chains[0].configurationReady).toBe(true); expect(rules).toHaveLength(1); for (const [selector, walletArg] of [ ['supply(address,uint256,address,uint16)', 2], @@ -44,27 +44,33 @@ it('Aave pins the selected reserve, operation amount and wallet beneficiary for } } }); -it('returns actionable readiness without silently choosing tokens, caps, gas or expiry', () => { +it('default lending policy needs only the agent to become locally ready', () => { const result = agenticPresets.get('LENDING', { chainNamespace }); expect(result.readiness.ready).toBe(false); expect(result.readiness.chains[0].issues.map((i) => i.code)).toEqual( - expect.arrayContaining([ - 'AGENT_REQUIRED', - 'EXPIRY_REQUIRED', - 'GAS_BUDGET_REQUIRED', - 'ASSETS_REQUIRED', - 'NO_ENABLED_OPERATION', - ]) + expect.arrayContaining(['AGENT_REQUIRED', 'BRIDGE_ASSET_UNAVAILABLE']) ); - expect(result.rules[0]).not.toHaveProperty('assets'); - expect(result.rules[0]).not.toHaveProperty('validUntil'); - expect(result.rules[0]).not.toHaveProperty('maxGasPerCall'); + expect(result.rules[0].assets).toEqual([ + { + token: LINK, + maxPerCall: BigInt(10) ** BigInt(18), + maxTotal: BigInt(10) ** BigInt(19), + }, + ]); + expect(result.rules[0].validUntil).toBeGreaterThan( + Math.floor(Date.now() / 1000) + ); + expect(result.rules[0].maxGasPerCall).toBe( + BigInt(20) * BigInt(10) ** BigInt(18) + ); + expect(result.readiness.chains[0].knownTokens).toContain(LINK); expect( - result.rules[0].allowedCalls.every((c) => - c.pins?.some((p) => p.max === BigInt(0)) - ) + agenticPresets.get('LENDING', { + chainNamespace, + agent: ADDR.agent, + funding: 'destination', + }).readiness.ready ).toBe(true); - expect(result.readiness.chains[0].knownTokens).toContain(LINK); }); it('zero operation caps remain valid disabled configurations, while zero bridge totals are separately reported', () => { const disabled = agenticPresets.get('LENDING', { @@ -81,7 +87,7 @@ it('zero operation caps remain valid disabled configurations, while zero bridge ...options, assets: [{ token: LINK, maxPerCall: BigInt(5) }], }); - expect(enabled.readiness.ready).toBe(true); + expect(enabled.readiness.chains[0].configurationReady).toBe(true); expect(enabled.readiness.chains[0].zeroBridgeLimits).toEqual([LINK]); expect(enabled.rules[0].assets?.[0].maxTotal).toBe(BigInt(0)); }); @@ -195,7 +201,8 @@ it('readiness is per chain and cannot be mutated to change later results', () => }); expect(res.readiness.ready).toBe(false); expect( - res.readiness.chains.find((c) => c.chainNamespace === chainNamespace)?.ready + res.readiness.chains.find((c) => c.chainNamespace === chainNamespace) + ?.configurationReady ).toBe(true); const expected = agenticPresets.get('LENDING', { chainNamespace }).readiness; res.readiness.chains.forEach((c) => { diff --git a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts new file mode 100644 index 000000000..077b23511 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts @@ -0,0 +1,103 @@ +import { agenticPresets } from '../presets'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../constants/tokens'; +import { ADDR } from './fake-chain'; + +const options = { chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: ADDR.agent }; +it('reports unmapped source tokens without silently changing the default Push grant', () => { + const res = agenticPresets.get('LENDING', options); + expect(res.readiness.ready).toBe(false); + const state = res.readiness.chains[0]; + expect(state.configurationReady).toBe(true); + expect(state.funding).toBe('push'); + expect(state.issues).toContainEqual( + expect.objectContaining({ + code: 'BRIDGE_ASSET_UNAVAILABLE', + token: '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5', + }) + ); + expect(res.rules[0].assets).toEqual(state.selectedAssets); + expect(res.rules[0].assets).toHaveLength(1); +}); +it('supports the explicit destination-funded grant with preserved token/amount pins and review metadata', () => { + const push = agenticPresets.get('DEXES', options); + const dest = agenticPresets.get('DEXES', { + ...options, + funding: 'destination', + }); + expect(push.readiness.ready).toBe(false); + expect(dest.readiness.ready).toBe(true); + expect(dest.rules[0].assets).toEqual([]); + expect(dest.readiness.chains[0].selectedAssets).toEqual(push.rules[0].assets); + expect(dest.rules[0].allowedCalls).toEqual(push.rules[0].allowedCalls); + expect(dest.readiness.chains[0].bridgeAssets.map((a) => a.status)).toEqual([ + 'registered', + 'unavailable', + ]); + const returned = dest.readiness.chains[0].selectedAssets[0]; + returned.maxPerCall = BigInt(0); + expect( + agenticPresets.get('DEXES', options).rules[0].assets?.[0].maxPerCall + ).toBeGreaterThan(BigInt(0)); +}); +it('an explicitly selected mapped source token can be ready for Push funding', () => { + const token = MOVEABLE_TOKEN_CONSTANTS.BNB_TESTNET.USDT; + const res = agenticPresets.get('DEXES', { + chainNamespace: CHAIN.BNB_TESTNET, + agent: ADDR.agent, + assets: [ + { + token: MOVEABLE_TOKEN_CONSTANTS.BNB_TESTNET.USDC, + maxPerCall: BigInt(5), + maxTotal: BigInt(10), + }, + { token, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, + ], + }); + expect(res.readiness.ready).toBe(true); + expect( + res.readiness.chains[0].bridgeAssets.every((a) => a.status === 'registered') + ).toBe(true); +}); +it('never treats a mainnet template or undeployed Push generation as executable', () => { + const mainnet = agenticPresets.get('DEXES', { + agent: ADDR.agent, + chainNamespace: CHAIN.ETHEREUM_MAINNET, + funding: 'destination', + }); + expect(mainnet.readiness.ready).toBe(false); + expect(mainnet.readiness.chains[0].issues).toContainEqual( + expect.objectContaining({ code: 'DESTINATION_UNAVAILABLE' }) + ); + const missing = agenticPresets.get('LENDING', { + ...options, + funding: 'destination', + network: PUSH_NETWORK.MAINNET, + }); + expect(missing.readiness.ready).toBe(false); + expect(missing.readiness.chains[0].issues).toContainEqual( + expect.objectContaining({ code: 'AGW_NETWORK_UNAVAILABLE' }) + ); +}); +it('supports the TESTNET alias, rejects invalid modes and preserves empty selections', () => { + expect( + agenticPresets.get('LENDING', { + ...options, + funding: 'destination', + network: PUSH_NETWORK.TESTNET, + }).readiness.ready + ).toBe(true); + expect(() => + agenticPresets.get('LENDING', { ...options, funding: 'auto' as never }) + ).toThrow(/funding/); + expect(() => + agenticPresets.get('LENDING', { ...options, network: 'OTHER' as never }) + ).toThrow(/network/); + const empty = agenticPresets.get('LENDING', { + ...options, + funding: 'destination', + assets: [], + }); + expect(empty.readiness.chains[0].selectedAssets).toEqual([]); + expect(empty.readiness.ready).toBe(false); +}); diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts index ffb417092..9ea62badd 100644 --- a/packages/core/src/lib/agentic/__tests__/presets.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -84,7 +84,7 @@ it('ships verified mainnet and testnet templates and keeps prediction empty', () agenticPresets.get('LENDING', { chainNamespace: 'eip155:999999' }) ).toThrow(/no approved entry/); }); -it('groups all apps on a chain into one editable template and omits unspecified fields', () => { +it('groups apps on a chain into one editable template with default expiry and omitted identity', () => { const data = catalog(); data[0].chains[0].allowedCalls = [ ...data[0].chains[0].allowedCalls, @@ -101,7 +101,7 @@ it('groups all apps on a chain into one editable template and omits unspecified for (const rule of res.rules) { expect(rule).not.toHaveProperty('agent'); expect(rule).not.toHaveProperty('assets'); - expect(rule).not.toHaveProperty('validUntil'); + expect(rule.validUntil).toBeGreaterThan(Math.floor(Date.now() / 1000)); } expect(api.list({ type: 'CATEGORY' }).presets[0].chains).toEqual([ chain, @@ -134,7 +134,7 @@ it('returns ready Rule types with identity and expiry; routes assets and default }); it('does not type an incomplete template as a ready rule', () => { const { rules } = buildPresetApi(catalog()).get('DEXES'); - // @ts-expect-error callers must fill agent and expiry before create + // @ts-expect-error callers must fill agent before create const ready: Rule[] = rules; expect(ready[0].agent).toBeUndefined(); }); diff --git a/packages/core/src/lib/agentic/contracts/prc20-metadata.ts b/packages/core/src/lib/agentic/contracts/prc20-metadata.ts index 332bb0b62..e5eff6b33 100644 --- a/packages/core/src/lib/agentic/contracts/prc20-metadata.ts +++ b/packages/core/src/lib/agentic/contracts/prc20-metadata.ts @@ -95,7 +95,7 @@ export async function readGasPrc20( ) ); } -/** Donut native precompile returns a zero word; ERC20 metadata is a dynamic string. */ +/** Solidity PRC20s return a string; Donut precompiles can return an address word. */ export async function readOriginToken( snap: Snapshot, token: Address, @@ -109,14 +109,25 @@ export async function readOriginToken( 'SOURCE_TOKEN_ADDRESS' ); } catch (cause) { - if ((await readGasPrc20(snap, chain)) !== getAddress(token)) throw cause; - const native = await snap.read
( - token, - NATIVE_SOURCE_ABI, - 'SOURCE_TOKEN_ADDRESS' + let sourceWord: Address; + try { + sourceWord = getAddress( + await snap.read
( + token, + NATIVE_SOURCE_ABI, + 'SOURCE_TOKEN_ADDRESS' + ) + ); + } catch { + throw cause; + } + if (sourceWord !== zeroAddress) return sourceWord; + if ((await readGasPrc20(snap, chain)) === getAddress(token)) + return zeroAddress; + throw new AgenticError( + AGENTIC_ERROR_CODE.RULE_READ_FAILED, + `non-native PRC20 ${token} has an empty source token address` ); - if (getAddress(native) === zeroAddress) return zeroAddress; - throw cause; } if (isAddress(source, { strict: false })) return getAddress(source); if (source === '' && (await readGasPrc20(snap, chain)) === getAddress(token)) diff --git a/packages/core/src/lib/agentic/presets/catalog.ts b/packages/core/src/lib/agentic/presets/catalog.ts index c7d7b2e2a..263ddd924 100644 --- a/packages/core/src/lib/agentic/presets/catalog.ts +++ b/packages/core/src/lib/agentic/presets/catalog.ts @@ -2,6 +2,11 @@ import { getAddress, type Address } from 'viem'; import type { RuleCall, ForeignChainNamespace } from '../agentic.types'; import type { PresetCategory } from './types'; import { AAVE_RESERVES } from './aave-reserves'; +import { + DEX_DEFAULT_TOKENS, + LENDING_DEFAULT_TOKENS, + STAKING_DEFAULT_TOKENS, +} from './default-tokens'; export type PresetBudget = | { @@ -25,6 +30,8 @@ export interface PresetCallDefinition extends RuleCall { /** Internal catalog: reviewed addresses and explicit calls only; never fetched at runtime. */ export interface PresetChainDefinition { chainNamespace: ForeignChainNamespace; + /** Used with the bundled permission policy only when assets is omitted. */ + defaultTokens?: readonly Address[]; allowedCalls: readonly PresetCallDefinition[]; /** Static single-hop routers; incomplete pairs return zero-input-cap review calls. */ swapRouters?: readonly { address: Address; app: string }[]; @@ -205,6 +212,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ ], ].map(([chain, router, app]) => ({ chainNamespace: chain as ForeignChainNamespace, + defaultTokens: DEX_DEFAULT_TOKENS[chain], allowedCalls: [], swapRouters: [{ address: address(router), app }], approvals: [{ spender: address(router), app }], @@ -223,6 +231,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ ] as const ).map(([chainNamespace, pool]) => ({ chainNamespace, + defaultTokens: LENDING_DEFAULT_TOKENS[chainNamespace], allowedCalls: [ ...aave(address(pool), chainNamespace), ...mainnetMarkets(chainNamespace).allowedCalls, @@ -239,6 +248,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ { chainNamespace: 'eip155:11155111', + defaultTokens: LENDING_DEFAULT_TOKENS['eip155:11155111'], allowedCalls: aave(sepoliaPool, 'eip155:11155111'), approvals: [ { @@ -250,6 +260,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ }, { chainNamespace: 'eip155:421614', + defaultTokens: LENDING_DEFAULT_TOKENS['eip155:421614'], allowedCalls: aave(arbitrumPool, 'eip155:421614'), approvals: [ { @@ -261,6 +272,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ }, { chainNamespace: 'eip155:84532', + defaultTokens: LENDING_DEFAULT_TOKENS['eip155:84532'], allowedCalls: [ ...aave(basePool, 'eip155:84532'), ...market( @@ -301,6 +313,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ }, { chainNamespace: 'eip155:97', + defaultTokens: LENDING_DEFAULT_TOKENS['eip155:97'], allowedCalls: [ ...market( venusUSDT, @@ -341,6 +354,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ chains: [ { chainNamespace: 'eip155:1', + defaultTokens: STAKING_DEFAULT_TOKENS['eip155:1'], allowedCalls: [ { target: stETH, @@ -407,6 +421,7 @@ export const PRESET_CATALOG: readonly PresetDefinition[] = [ { chainNamespace: 'eip155:11155111', + defaultTokens: STAKING_DEFAULT_TOKENS['eip155:11155111'], allowedCalls: [ { target: eigen, diff --git a/packages/core/src/lib/agentic/presets/default-policy.ts b/packages/core/src/lib/agentic/presets/default-policy.ts new file mode 100644 index 000000000..dc1cf5dd5 --- /dev/null +++ b/packages/core/src/lib/agentic/presets/default-policy.ts @@ -0,0 +1,98 @@ +import { getAddress, parseUnits } from 'viem'; +import type { Address } from 'viem'; +import type { RuleAsset } from '../agentic.types'; +import { capabilityUnavailable } from '../errors'; + +/** Preset-only permission policy. Ordinary rule authoring retains zero defaults. + * Amounts are token quantities, not USD valuations or destination lifetime caps. + * Metadata verified read-only on October 18; evidence is retained under plan/agw/. + */ +export const PRESET_VALIDITY_SECONDS = 7 * 24 * 60 * 60; + +// Explicit profiles keep future chains from inheriting an unreviewed budget. +// 20 PC is a ceiling, not the amount charged: execution obtains a fresh quote. +// This budget has testnet protocol-flow coverage; mainnet remains template-only. +export const PRESET_GAS_BUDGETS: Readonly> = + Object.fromEntries( + [ + 'eip155:1', + 'eip155:8453', + 'eip155:42161', + 'eip155:56', + 'eip155:11155111', + 'eip155:84532', + 'eip155:421614', + 'eip155:97', + ].map((chain) => [chain, parseUnits('20', 18)]) + ); + +type TokenBudget = { decimals: number; perCall: string; total: string }; +const stable6: TokenBudget = { decimals: 6, perCall: '10', total: '100' }; +const stable18: TokenBudget = { ...stable6, decimals: 18 }; +const eth: TokenBudget = { decimals: 18, perCall: '0.001', total: '0.01' }; +const bnb: TokenBudget = { decimals: 18, perCall: '0.01', total: '0.1' }; +const link: TokenBudget = { decimals: 18, perCall: '1', total: '10' }; + +// Match by chain AND address. Never infer decimals or limits from a symbol, +// caller metadata, or the position of a token in an arbitrary selection. +const TOKEN_BUDGETS: Readonly< + Record +> = { + 'eip155:1': [ + ['0x0000000000000000000000000000000000000000', eth], + ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', eth], + ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', stable6], + ['0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', eth], + ['0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', eth], + ], + 'eip155:8453': [ + ['0x4200000000000000000000000000000000000006', eth], + ['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', stable6], + ], + 'eip155:42161': [ + ['0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', eth], + ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831', stable6], + ], + 'eip155:56': [ + ['0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', bnb], + ['0x55d398326f99059fF775485246999027B3197955', stable18], + ], + 'eip155:11155111': [ + ['0xfff9976782d46cc05630d1f6ebab18b2324d6b14', eth], + ['0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', stable6], + ['0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5', link], + ['0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9', eth], + ], + 'eip155:84532': [ + ['0x4200000000000000000000000000000000000006', eth], + ['0x036CbD53842c5426634e7929541eC2318f3dCF7e', stable6], + ['0x810D46F9a9027E28F9B01F75E2bdde839dA61115', link], + ['0x4616975f7E6045FD9737e95bC527e9e14c727BD2', stable6], + ['0x663A6F02d547dFd639BF689FED298712f2FB69Fb', eth], + ], + 'eip155:421614': [ + ['0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', eth], + ['0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', stable6], + ], + 'eip155:97': [ + ['0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', bnb], + ['0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c', stable6], + ['0x16227D60f7a0e586C66B005219dfc887D13C9531', stable6], + ], +}; + +export function defaultPresetAsset(chain: string, token: Address): RuleAsset { + const budget = TOKEN_BUDGETS[chain]?.find( + ([address]) => getAddress(address) === getAddress(token) + )?.[1]; + if (!budget) + throw capabilityUnavailable( + 'presetPolicy', + `No reviewed default spending policy for ${chain}:${token}` + ); + return { + token, + maxPerCall: parseUnits(budget.perCall, budget.decimals), + maxTotal: parseUnits(budget.total, budget.decimals), + }; +} diff --git a/packages/core/src/lib/agentic/presets/default-tokens.ts b/packages/core/src/lib/agentic/presets/default-tokens.ts new file mode 100644 index 000000000..09f38ecee --- /dev/null +++ b/packages/core/src/lib/agentic/presets/default-tokens.ts @@ -0,0 +1,80 @@ +import { getAddress, zeroAddress, type Address } from 'viem'; + +/** Curated subsets of the reviewed catalog. Addresses are destination tokens, + * not a promise that a Push PRC20 route or protocol liquidity is available. + * Keep these sets within the policy's 32-call/32-pin limits after expansion. + * Testnet DEX pairs and lending/staking assets match the live preset fixtures. + * Mainnet addresses share the catalog's October 15 read-only source baseline. + * Spending caps are intentionally separate from token selection. + */ +const addresses = (...values: string[]): readonly Address[] => + values.map((value) => getAddress(value.toLowerCase())); + +export const DEX_DEFAULT_TOKENS: Readonly> = + { + 'eip155:1': addresses( + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ), + 'eip155:8453': addresses( + '0x4200000000000000000000000000000000000006', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' + ), + 'eip155:42161': addresses( + '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' + ), + 'eip155:56': addresses( + '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', + '0x55d398326f99059fF775485246999027B3197955' + ), + 'eip155:11155111': addresses( + '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' + ), + 'eip155:84532': addresses( + '0x4200000000000000000000000000000000000006', + '0x036CbD53842c5426634e7929541eC2318f3dCF7e' + ), + 'eip155:421614': addresses( + '0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', + '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' + ), + 'eip155:97': addresses( + '0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', + '0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c' + ), + }; + +export const LENDING_DEFAULT_TOKENS: Readonly< + Record +> = { + 'eip155:1': DEX_DEFAULT_TOKENS['eip155:1'], + // One shared reserve/market token keeps Aave + Moonwell/Venus within 32 pins. + 'eip155:8453': addresses('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'), + 'eip155:42161': DEX_DEFAULT_TOKENS['eip155:42161'], + 'eip155:56': addresses('0x55d398326f99059fF775485246999027B3197955'), + // Protocol testnet reserves differ from Circle USDC and DEX wrapped ETH. + 'eip155:11155111': addresses('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), + 'eip155:84532': addresses( + '0x810D46F9a9027E28F9B01F75E2bdde839dA61115', + '0x4616975f7E6045FD9737e95bC527e9e14c727BD2', + '0x663A6F02d547dFd639BF689FED298712f2FB69Fb' + ), + 'eip155:421614': addresses('0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d'), + 'eip155:97': addresses( + '0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c', + '0x16227D60f7a0e586C66B005219dfc887D13C9531' + ), +}; + +export const STAKING_DEFAULT_TOKENS: Readonly< + Record +> = { + 'eip155:1': addresses( + zeroAddress, + '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0' + ), + 'eip155:11155111': addresses('0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9'), +}; diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index 9ba56b051..e80145513 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -15,6 +15,13 @@ import type { import { parseSelector } from '../codec/selectors'; import { PRESET_CATALOG, type PresetDefinition } from './catalog'; import { applyOperationLimits, assetAddress } from './operation-limits'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { presetRoutes } from './routes'; +import { + defaultPresetAsset, + PRESET_GAS_BUDGETS, + PRESET_VALIDITY_SECONDS, +} from './default-policy'; const MAX_UINT256 = (BigInt(1) << BigInt(256)) - BigInt(1); const invalid = (message: string) => @@ -62,6 +69,7 @@ export function buildPresetApi( ...p, chains: p.chains.map((c) => ({ ...c, + ...(c.defaultTokens ? { defaultTokens: [...c.defaultTokens] } : {}), allowedCalls: c.allowedCalls.map((call) => ({ ...copyCall(call), ...(call.budget @@ -108,6 +116,13 @@ export function buildPresetApi( if (!entry) throw invalid(`unknown preset ${String(preset)}`); if (!options || typeof options !== 'object' || Array.isArray(options)) throw invalid('preset options must be an object'); + const now = Math.floor(Date.now() / 1000); + const network = options.network ?? PUSH_NETWORK.TESTNET_DONUT; + const funding = options.funding ?? 'push'; + if (!Object.values(PUSH_NETWORK).includes(network)) + throw invalid('unknown preset Push network'); + if (funding !== 'push' && funding !== 'destination') + throw invalid('preset funding must be push or destination'); if ( options.agent !== undefined && (!isAddress(options.agent, { strict: false }) || @@ -117,10 +132,11 @@ export function buildPresetApi( if ( options.validUntil !== undefined && (!Number.isSafeInteger(options.validUntil) || - options.validUntil <= Math.floor(Date.now() / 1000) || + options.validUntil <= now || options.validUntil >= 2 ** 48) ) throw invalid('preset validUntil must be a future uint48 timestamp'); + const validUntil = options.validUntil ?? now + PRESET_VALIDITY_SECONDS; if (options.maxGasPerCall !== undefined) cap(options.maxGasPerCall, 'maxGasPerCall'); if (options.assets !== undefined && !Array.isArray(options.assets)) @@ -142,6 +158,17 @@ export function buildPresetApi( `preset ${preset} has no approved app entries` ); const assets = new Map(); + // An explicit array (including []) replaces the defaults for all selected + // chains. Never add permissions on chains the caller left unconfigured. + if (options.assets === undefined) + for (const chain of chains) + if (chain.defaultTokens) + assets.set( + chain.chainNamespace, + chain.defaultTokens.map((token) => + defaultPresetAsset(chain.chainNamespace, token) + ) + ); for (const asset of options.assets ?? []) { if (!asset || typeof asset !== 'object') throw invalid('preset asset must be an object'); @@ -181,6 +208,12 @@ export function buildPresetApi( } const readiness: PresetChainReadiness[] = []; const rules: PresetRuleTemplate[] = chains.map((chain) => { + const maxGasPerCall = + options.maxGasPerCall ?? + chain.maxGasPerCall ?? + (chain.defaultTokens + ? PRESET_GAS_BUDGETS[chain.chainNamespace] + : undefined); const selectedAssets = assets.get(chain.chainNamespace) ?? []; const limits = applyOperationLimits( chain.allowedCalls.map((c) => ({ @@ -362,15 +395,7 @@ export function buildPresetApi( code: 'AGENT_REQUIRED', message: 'Choose the agent Push address.', }); - if (!options.validUntil) - issues.push({ - code: 'EXPIRY_REQUIRED', - message: 'Choose an expiry.', - }); - if ( - (options.maxGasPerCall ?? chain.maxGasPerCall ?? BigInt(0)) === - BigInt(0) - ) + if ((maxGasPerCall ?? BigInt(0)) === BigInt(0)) issues.push({ code: 'GAS_BUDGET_REQUIRED', message: 'Set a positive PC gas budget per outbound.', @@ -391,14 +416,32 @@ export function buildPresetApi( code: 'NO_ENABLED_OPERATION', message: 'No asset operation has a positive per-call limit.', }); + const configurationReady = issues.length === 0; + const routes = presetRoutes( + chain.chainNamespace, + selectedAssets, + network, + funding + ); + issues.push(...routes.issues); + const bridgeAssets = funding === 'destination' ? [] : selectedAssets; readiness.push({ chainNamespace: chain.chainNamespace, ready: issues.length === 0, + configurationReady, + funding, + selectedAssets: selectedAssets.map((a) => ({ + ...a, + token: typeof a.token === 'string' ? a.token : { ...a.token }, + })), + bridgeAssets: routes.bridgeAssets, issues, disabledCalls: limits.disabledCalls, excludedCalls: limits.excludedCalls, - knownTokens: limits.knownTokens, - zeroBridgeLimits: selectedAssets + knownTokens: [ + ...new Set([...limits.knownTokens, ...(chain.defaultTokens ?? [])]), + ], + zeroBridgeLimits: bridgeAssets .filter( (a) => a.maxPerCall === BigInt(0) || a.maxTotal === BigInt(0) ) @@ -410,19 +453,12 @@ export function buildPresetApi( ...(options.agent !== undefined ? { agent: getAddress(options.agent) } : {}), - ...(options.validUntil !== undefined - ? { validUntil: options.validUntil } + validUntil, + ...(options.assets !== undefined || chain.defaultTokens + ? { assets: bridgeAssets } : {}), - ...(options.assets !== undefined - ? { assets: assets.get(chain.chainNamespace) ?? [] } - : {}), - ...((options.maxGasPerCall ?? chain.maxGasPerCall) !== undefined - ? { - maxGasPerCall: cap( - options.maxGasPerCall ?? chain.maxGasPerCall, - 'maxGasPerCall' - ), - } + ...(maxGasPerCall !== undefined + ? { maxGasPerCall: cap(maxGasPerCall, 'maxGasPerCall') } : {}), }; }); @@ -435,7 +471,7 @@ export function buildPresetApi( }; }, }; - // The overload narrows templates only when both required fields were supplied and validated. + // Expiry is generated; the overload narrows templates when the agent is supplied. return Object.freeze(api) as AgenticPresets; } export const agenticPresets = buildPresetApi(PRESET_CATALOG); diff --git a/packages/core/src/lib/agentic/presets/routes.ts b/packages/core/src/lib/agentic/presets/routes.ts new file mode 100644 index 000000000..fc0babf40 --- /dev/null +++ b/packages/core/src/lib/agentic/presets/routes.ts @@ -0,0 +1,56 @@ +import { getAddress } from 'viem'; +import { PUSH_NETWORK, type CHAIN } from '../../constants/enums'; +import { SUPPORTED_GATEWAY_CHAINS } from '../../orchestrator/internals/helpers'; +import { getPRC20Address } from '../../universal/prc20-address'; +import { generationsFor } from '../deployments'; +import type { RuleAsset } from '../agentic.types'; +import type { PresetChainReadiness, PresetConfigurationIssue } from './types'; +import { assetAddress } from './operation-limits'; + +/** Offline registry checks only. Grant preparation still verifies source metadata + * and wiring against chain state before any signature or transaction. */ +export function presetRoutes( + chain: string, + assets: RuleAsset[], + network: PUSH_NETWORK, + funding: 'push' | 'destination' +): { + bridgeAssets: PresetChainReadiness['bridgeAssets']; + issues: PresetConfigurationIssue[]; +} { + const issues: PresetConfigurationIssue[] = []; + network = + network === PUSH_NETWORK.TESTNET ? PUSH_NETWORK.TESTNET_DONUT : network; + if (!generationsFor(network).some((g) => g.advertised)) + issues.push({ + code: 'AGW_NETWORK_UNAVAILABLE', + message: `No verified AGW deployment is registered for ${network}.`, + }); + const supported = SUPPORTED_GATEWAY_CHAINS.includes(chain as CHAIN); + if (!supported) + issues.push({ + code: 'DESTINATION_UNAVAILABLE', + message: `The SDK has no supported gateway route to ${chain}; this is a catalog template.`, + }); + const bridgeAssets = assets.map((asset) => { + const token = assetAddress(asset); + try { + if (!supported) throw new Error(`No supported gateway route to ${chain}`); + const prc20 = getPRC20Address({ chain, address: token }, { network }); + return { + token, + prc20: getAddress(prc20.address), + status: 'registered' as const, + }; + } catch { + if (funding === 'push') + issues.push({ + code: 'BRIDGE_ASSET_UNAVAILABLE', + token, + message: `No SDK PRC20 mapping for ${chain}:${token} on ${network}. Use a supported source asset or explicitly choose destination funding for assets already at the CEA.`, + }); + return { token, status: 'unavailable' as const }; + } + }); + return { bridgeAssets, issues }; +} diff --git a/packages/core/src/lib/agentic/presets/types.ts b/packages/core/src/lib/agentic/presets/types.ts index 5f89ffbbb..f014991cf 100644 --- a/packages/core/src/lib/agentic/presets/types.ts +++ b/packages/core/src/lib/agentic/presets/types.ts @@ -1,4 +1,5 @@ import type { Address } from 'viem'; +import type { PUSH_NETWORK } from '../../constants/enums'; import type { Rule, RuleAsset, @@ -13,7 +14,7 @@ export interface Preset { name: string; chains: ForeignChainNamespace[]; } -/** An editable review-screen template; both identity and expiry are required by create. */ +/** Presets generate expiry; optional fields retain editable-template compatibility. */ export type PresetRuleTemplate = Omit & { agent?: Address; validUntil?: number; @@ -28,20 +29,29 @@ export interface PresetAsset { maxTotal?: bigint; } export interface PresetOptions { - /** Explicit Push-PC gas budget per outbound; omitted = zero. */ + /** Offline route/deployment checks; defaults to TESTNET_DONUT. */ + network?: PUSH_NETWORK; + /** Push funding requires PRC20 routes. Destination funding explicitly emits + * empty bridge assets while preserving selected token/call limits in metadata. + * The CEA must already hold the required destination assets. Default: push. */ + funding?: 'push' | 'destination'; + /** Push-PC budget ceiling per outbound; omitted uses the catalog policy (20 PC). + * Explicit zero is preserved. This is not the fee charged. */ maxGasPerCall?: bigint; chainNamespace?: ForeignChainNamespace; agent?: Address; + /** Omitted: seven days from generation time. Explicit expiry is preserved. */ validUntil?: number; - /** DEX swaps with fewer than two tokens are zero-input-cap review templates. - * Call get again with a complete pair to generate token pins and amount limits. */ + /** Omitted: include the catalog's curated tokens and explicit spending policy. + * An explicit array replaces all defaults (including [] to select no tokens). + * Supplied assets retain zero for omitted caps; no policy is guessed for them. + * DEX swaps with fewer than two tokens remain zero-input-cap review templates. */ assets?: PresetAsset[]; } export interface ReadyPresetOptions extends PresetOptions { agent: Address; - validUntil: number; } -/** Offline configuration only: does not check deployment, reserves, balances or liquidity. */ +/** Offline configuration/registry checks; chain state and balances are not checked. */ export interface PresetConfigurationIssue { code: | 'AGENT_REQUIRED' @@ -49,8 +59,12 @@ export interface PresetConfigurationIssue { | 'GAS_BUDGET_REQUIRED' | 'ASSETS_REQUIRED' | 'TOKEN_PAIR_REQUIRED' - | 'NO_ENABLED_OPERATION'; + | 'NO_ENABLED_OPERATION' + | 'AGW_NETWORK_UNAVAILABLE' + | 'DESTINATION_UNAVAILABLE' + | 'BRIDGE_ASSET_UNAVAILABLE'; message: string; + token?: string; } export interface PresetCallIssue { target: string; @@ -69,12 +83,22 @@ export interface PresetCallIssue { export interface PresetChainReadiness { chainNamespace: ForeignChainNamespace; ready: boolean; + configurationReady: boolean; + funding: 'push' | 'destination'; + /** Selected destination permissions, retained even when bridge assets is empty. */ + selectedAssets: RuleAsset[]; + /** Registry presence only; grant preparation verifies exact on-chain identity. */ + bridgeAssets: { + token: string; + status: 'registered' | 'unavailable'; + prc20?: Address; + }[]; issues: PresetConfigurationIssue[]; /** Calls present in the rule but disabled by a zero amount/value maximum. */ disabledCalls: PresetCallIssue[]; /** Calls deliberately omitted, including operations with incompatible amount units. */ excludedCalls: PresetCallIssue[]; - /** Known fixed-token/reserve addresses in this catalog, not selected defaults. */ + /** Known catalog token addresses, including the curated default subset. */ knownTokens: string[]; /** Selected tokens with a zero Push-outbound budget; destination-held assets are separate. */ zeroBridgeLimits: string[]; diff --git a/packages/core/src/lib/universal/prc20-address.spec.ts b/packages/core/src/lib/universal/prc20-address.spec.ts new file mode 100644 index 000000000..42aa2f81d --- /dev/null +++ b/packages/core/src/lib/universal/prc20-address.spec.ts @@ -0,0 +1,65 @@ +import { getAddress } from 'viem'; +import { getPRC20Address } from './prc20-address'; +import { CHAIN, PUSH_NETWORK } from '../constants/enums'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../constants/tokens'; +import { SYNTHETIC_PUSH_ERC20 } from '../constants/chain'; + +const addresses = SYNTHETIC_PUSH_ERC20[PUSH_NETWORK.TESTNET_DONUT]; +it.each([ + ['WETH', '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', addresses.WETH_ETH], + ['stETH', '0x3e3FE7dBc6B4C189E7128855dD526361c49b40Af', addresses.stETH_ETH], +] as const)( + 'resolves verified Sepolia %s for lowercase/checksummed address input', + (symbol, address, prc20) => { + for (const input of [address.toLowerCase(), getAddress(address)]) { + expect( + getPRC20Address({ chain: CHAIN.ETHEREUM_SEPOLIA, address: input }) + ).toMatchObject({ + chain: CHAIN.ETHEREUM_SEPOLIA, + symbol, + address: prc20, + decimals: 18, + }); + } + } +); +it('preserves case-sensitive Solana identities while accepting EVM casing variations', () => { + const token = MOVEABLE_TOKEN_CONSTANTS.BASE_SEPOLIA.USDC; + expect( + getPRC20Address({ + chain: CHAIN.BASE_SEPOLIA, + address: token.address.toLowerCase(), + }).address + ).toBe(addresses.USDC_BASE); + const mint = MOVEABLE_TOKEN_CONSTANTS.SOLANA_DEVNET.USDC; + expect(() => + getPRC20Address({ + chain: CHAIN.SOLANA_DEVNET, + address: mint.address.toLowerCase(), + }) + ).toThrow(/infer/); + expect( + getPRC20Address({ chain: CHAIN.SOLANA_DEVNET, address: mint.address }) + .address + ).toBe(addresses.USDC_SOL); +}); +it('never aliases mainnet ETH/WETH or a different chain WETH to Sepolia', () => { + expect(() => + getPRC20Address({ + chain: CHAIN.ETHEREUM_MAINNET, + address: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_MAINNET.ETH.address, + }) + ).toThrow(/Unsupported ETH origin/); + expect(() => + getPRC20Address({ + chain: CHAIN.ETHEREUM_MAINNET, + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }) + ).toThrow(/Unsupported WETH origin/); + expect(() => + getPRC20Address({ + chain: CHAIN.ARBITRUM_SEPOLIA, + address: '0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', + }) + ).toThrow(/Unsupported WETH origin/); +}); diff --git a/packages/core/src/lib/universal/prc20-address.ts b/packages/core/src/lib/universal/prc20-address.ts index 693aa28e7..db40f9123 100644 --- a/packages/core/src/lib/universal/prc20-address.ts +++ b/packages/core/src/lib/universal/prc20-address.ts @@ -1,6 +1,12 @@ import { CHAIN, PUSH_NETWORK } from '../constants/enums'; import { MOVEABLE_TOKENS, type MoveableToken } from '../constants/tokens'; import { SYNTHETIC_PUSH_ERC20 } from '../constants/chain'; +import { isAddress } from 'viem'; + +const sameTokenAddress = (a: string, b: string) => + isAddress(a, { strict: false }) && isAddress(b, { strict: false }) + ? a.toLowerCase() === b.toLowerCase() + : a === b; /** Shared pure origin-to-PRC20 mapping; no signer or network reads. */ export function getPRC20Address( @@ -58,7 +64,9 @@ export function getPRC20Address( for (const [key, list] of Object.entries(MOVEABLE_TOKENS)) { const k = key as CHAIN; const match = (list ?? []).find( - (t) => t.symbol === token.symbol && t.address === token.address + (t) => + t.symbol === token.symbol && + sameTokenAddress(t.address, token.address) ); if (match) { originChain = k; @@ -71,7 +79,7 @@ export function getPRC20Address( // { chain, address } path: trust the provided chain and resolve symbol via registry originChain = token.chain as CHAIN; const list = MOVEABLE_TOKENS[originChain] ?? []; - const match = list.find((t) => t.address === token.address); + const match = list.find((t) => sameTokenAddress(t.address, token.address)); if (match) { tokenSymbol = match.symbol; tokenDecimals = match.decimals; @@ -88,9 +96,9 @@ export function getPRC20Address( } // Map token → synthetic key by origin chain family - const isEthFamily = - originChain === CHAIN.ETHEREUM_MAINNET || - originChain === CHAIN.ETHEREUM_SEPOLIA; + // Current synthetic keys represent testnet sources. Mainnet assets must never + // resolve to a same-symbol Sepolia token on Donut. + const isEthFamily = originChain === CHAIN.ETHEREUM_SEPOLIA; const isArbFamily = originChain === CHAIN.ARBITRUM_SEPOLIA; const isBaseFamily = originChain === CHAIN.BASE_SEPOLIA; const isBnbFamily = originChain === CHAIN.BNB_TESTNET; @@ -111,9 +119,21 @@ export function getPRC20Address( | 'USDC_ARB' | 'USDC_SOL' | 'USDC_BSC' - | 'USDC_BASE'; + | 'USDC_BASE' + | 'WETH_ETH' + | 'stETH_ETH'; switch (tokenSymbol) { + case 'WETH': + case 'stETH': { + // These deployments represent Sepolia tokens, not other chains' assets. + if (originChain !== CHAIN.ETHEREUM_SEPOLIA) + throw new Error( + `Unsupported ${tokenSymbol} origin chain for synthetic mapping` + ); + key = tokenSymbol === 'WETH' ? 'WETH_ETH' : 'stETH_ETH'; + break; + } case 'ETH': { if (isEthFamily) key = 'pETH'; else if (isArbFamily) key = 'pETH_ARB'; From 21857417c7ae72d15c844b7828f5783be2783da7 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Sun, 18 Oct 2026 19:03:50 +0530 Subject: [PATCH 15/27] fix(ci): repair AGW workflow context and pin v6 contracts --- .github/workflows/agw-sdk-validation.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agw-sdk-validation.yml b/.github/workflows/agw-sdk-validation.yml index af1d14ef7..9ff23dcaf 100644 --- a/.github/workflows/agw-sdk-validation.yml +++ b/.github/workflows/agw-sdk-validation.yml @@ -15,7 +15,6 @@ jobs: env: NX_DAEMON: 'false' AGW_REPO: ${{ github.workspace }}/.ci-contracts - AGW_LOCAL_DIR: ${{ runner.temp }}/agw-v5-build steps: - uses: actions/checkout@v5 with: @@ -23,7 +22,7 @@ jobs: - uses: actions/checkout@v5 with: repository: pushchain/push-agentic-wallets - ref: 2e61e133e641b4e0e1ddbdc9306b0903b60e4dbb + ref: ffd567d3e49b2d27c6f50ad36093274dbac3090f path: .ci-contracts persist-credentials: false - uses: actions/setup-node@v6 @@ -36,6 +35,8 @@ jobs: - run: corepack enable - run: yarn install --immutable - name: Unit, contracts, types, docs and package checks + env: + AGW_LOCAL_DIR: ${{ runner.temp }}/agw-v6-build run: node packages/core/scripts/agw-verify.js - uses: actions/upload-artifact@v4 if: always() From 66dc81f8ca1a193cf8b43f9bca0dc1b32e597dde Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Sun, 18 Oct 2026 19:20:55 +0530 Subject: [PATCH 16/27] fix(ci): repair invalid dependency ranges for hardened installs --- .github/workflows/agw-sdk-validation.yml | 1 + yarn.lock | 95 +++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agw-sdk-validation.yml b/.github/workflows/agw-sdk-validation.yml index 9ff23dcaf..8db877909 100644 --- a/.github/workflows/agw-sdk-validation.yml +++ b/.github/workflows/agw-sdk-validation.yml @@ -14,6 +14,7 @@ jobs: timeout-minutes: 30 env: NX_DAEMON: 'false' + YARN_ENABLE_HARDENED_MODE: '1' AGW_REPO: ${{ github.workspace }}/.ci-contracts steps: - uses: actions/checkout@v5 diff --git a/yarn.lock b/yarn.lock index aa597ef3d..73efc74f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1464,6 +1464,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.5": + version: 7.29.10 + resolution: "@babel/runtime@npm:7.29.10" + checksum: 10/883bb39a09f4773cf46cdd7ba0080ef6b30ff996a4ed21f8d34511ec0836122e697716533da5efc9b5016f763110b19aa07e81cec6e2036fe72cb26aad556af2 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.5.5": version: 7.27.6 resolution: "@babel/runtime@npm:7.27.6" @@ -8891,7 +8898,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-macros@npm:^2.8.0, babel-plugin-macros@npm:^3.1.0": +"babel-plugin-macros@npm:^2.8.0": version: 2.8.0 resolution: "babel-plugin-macros@npm:2.8.0" dependencies: @@ -8902,6 +8909,17 @@ __metadata: languageName: node linkType: hard +"babel-plugin-macros@npm:^3.1.0": + version: 3.1.0 + resolution: "babel-plugin-macros@npm:3.1.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + cosmiconfig: "npm:^7.0.0" + resolve: "npm:^1.19.0" + checksum: 10/30be6ca45e9a124c58ca00af9a0753e5410ec0b79a737714fc4722bbbeb693e55d9258f05c437145ef4a867c2d1603e06a1c292d66c243ce1227458c8ea2ca8c + languageName: node + linkType: hard + "babel-plugin-polyfill-corejs2@npm:^0.4.10": version: 0.4.13 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.13" @@ -9662,7 +9680,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^13.1.0, commander@npm:^4.0.0": +"commander@npm:^13.1.0": version: 13.1.0 resolution: "commander@npm:13.1.0" checksum: 10/d3b4b79e6be8471ddadacbb8cd441fe82154d7da7393b50e76165a9e29ccdb74fa911a186437b9a211d0fc071db6051915c94fb8ef16d77511d898e9dbabc6af @@ -9676,6 +9694,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^4.0.0": + version: 4.1.1 + resolution: "commander@npm:4.1.1" + checksum: 10/3b2dc4125f387dab73b3294dbcb0ab2a862f9c0ad748ee2b27e3544d25325b7a8cdfbcc228d103a98a716960b14478114a5206b5415bd48cdafa38797891562c + languageName: node + linkType: hard + "compare-func@npm:^2.0.0": version: 2.0.0 resolution: "compare-func@npm:2.0.0" @@ -9901,6 +9926,19 @@ __metadata: languageName: node linkType: hard +"cosmiconfig@npm:^7.0.0": + version: 7.1.0 + resolution: "cosmiconfig@npm:7.1.0" + dependencies: + "@types/parse-json": "npm:^4.0.0" + import-fresh: "npm:^3.2.1" + parse-json: "npm:^5.0.0" + path-type: "npm:^4.0.0" + yaml: "npm:^1.10.0" + checksum: 10/03600bb3870c80ed151b7b706b99a1f6d78df8f4bdad9c95485072ea13358ef294b13dd99f9e7bf4cc0b43bcd3599d40df7e648750d21c2f6817ca2cd687e071 + languageName: node + linkType: hard + "cosmiconfig@npm:^9.0.0": version: 9.0.0 resolution: "cosmiconfig@npm:9.0.0" @@ -12209,6 +12247,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10/13823863ae48161068b4c51606a3128451c66f14545a5169d667fe9fca168dcd38c27570c7a299e32ef844b8da3d55def7fe88602f8970d4311fb543ee88001a + languageName: node + linkType: hard + "he@npm:^1.2.0": version: 1.2.0 resolution: "he@npm:1.2.0" @@ -12572,6 +12619,15 @@ __metadata: languageName: node linkType: hard +"is-core-module@npm:^2.17.0": + version: 2.17.0 + resolution: "is-core-module@npm:2.17.0" + dependencies: + hasown: "npm:^2.0.4" + checksum: 10/0020f71b7eb525ccd684012cd1cfba235e7596e0addbc993732f7d8faff7ccd3a399e16dac7eb317b63459ea23f97efd661d6bf32584f6bf1d0a607fd8e21a6b + languageName: node + linkType: hard + "is-deflate@npm:^1.0.0": version: 1.0.0 resolution: "is-deflate@npm:1.0.0" @@ -16276,6 +16332,20 @@ __metadata: languageName: node linkType: hard +"resolve@npm:^1.19.0": + version: 1.22.13 + resolution: "resolve@npm:1.22.13" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.17.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/4fc4242f448c5a679921bbac6e74268ca76893d7379cb9cfaa49f40ce6b1cea884c2df901d0617c92d927375fe66a05ccbbf17bbb0262cdcd6cb196e33fa1baa + languageName: node + linkType: hard + "resolve@patch:resolve@npm%3A^1.1.7#optional!builtin, resolve@patch:resolve@npm%3A^1.12.0#optional!builtin, resolve@patch:resolve@npm%3A^1.14.2#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin, resolve@patch:resolve@npm%3A~1.22.1#optional!builtin, resolve@patch:resolve@npm%3A~1.22.2#optional!builtin": version: 1.22.10 resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" @@ -16289,6 +16359,20 @@ __metadata: languageName: node linkType: hard +"resolve@patch:resolve@npm%3A^1.19.0#optional!builtin": + version: 1.22.13 + resolution: "resolve@patch:resolve@npm%3A1.22.13#optional!builtin::version=1.22.13&hash=c3c19d" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.17.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10/f0b8e2c897327b5edc4b277c90d19c7bd06975817183ef51f5c79367b8b8e6d637f6ca20ba6eb3ef964944157cc2eb6970fef1c2202edbe65e6e7be4f268fc7f + languageName: node + linkType: hard + "restore-cursor@npm:^3.1.0": version: 3.1.0 resolution: "restore-cursor@npm:3.1.0" @@ -18802,6 +18886,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^1.10.0": + version: 1.10.3 + resolution: "yaml@npm:1.10.3" + checksum: 10/e2ef2feb92c708138f016c69777a0f1e45f6d3c5e7cbcda30807a98a37eda2e008bd4fa57352b043c65245a4c799d0c99d1f9b3425de40e70929e26d2ea38215 + languageName: node + linkType: hard + "yaml@npm:^1.7.2": version: 1.10.2 resolution: "yaml@npm:1.10.2" From b291b873e072a3272331422e7558f82986331814 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Sun, 18 Oct 2026 19:46:31 +0530 Subject: [PATCH 17/27] perf(ci): keep automatic AGW validation SDK-only --- .github/workflows/agw-sdk-validation.yml | 28 ++++++++- packages/core/scripts/agw-local/prepare.sh | 8 ++- packages/core/scripts/agw-verify.js | 70 ++++++++++++---------- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/.github/workflows/agw-sdk-validation.yml b/.github/workflows/agw-sdk-validation.yml index 8db877909..67a23a412 100644 --- a/.github/workflows/agw-sdk-validation.yml +++ b/.github/workflows/agw-sdk-validation.yml @@ -1,8 +1,19 @@ name: AGW SDK validation (unfunded) on: pull_request: - paths: ['packages/core/**', 'tsconfig.base.json', 'yarn.lock', '.github/workflows/agw-sdk-validation.yml'] + paths: + [ + 'packages/core/**', + 'tsconfig.base.json', + 'yarn.lock', + '.github/workflows/agw-sdk-validation.yml', + ] workflow_dispatch: + inputs: + contracts: + description: 'Run contract suites and full API checks (slower)' + type: boolean + default: false permissions: contents: read concurrency: @@ -21,6 +32,7 @@ jobs: with: persist-credentials: false - uses: actions/checkout@v5 + if: github.event_name == 'workflow_dispatch' && inputs.contracts with: repository: pushchain/push-agentic-wallets ref: ffd567d3e49b2d27c6f50ad36093274dbac3090f @@ -31,17 +43,29 @@ jobs: node-version: '24.18.0' package-manager-cache: false - uses: foundry-rs/foundry-toolchain@v1 + if: github.event_name == 'workflow_dispatch' && inputs.contracts with: version: v1.5.1 - run: corepack enable + - uses: actions/cache@v4 + with: + path: .yarn/cache + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn- - run: yarn install --immutable - - name: Unit, contracts, types, docs and package checks + - name: SDK tests, types, build and package checks + if: github.event_name != 'workflow_dispatch' || !inputs.contracts + run: node packages/core/scripts/agw-verify.js --sdk-only + - name: Full unfunded contract and SDK checks + if: github.event_name == 'workflow_dispatch' && inputs.contracts env: AGW_LOCAL_DIR: ${{ runner.temp }}/agw-v6-build + AGW_ALLOW_COMPILER_DOWNLOAD: '1' run: node packages/core/scripts/agw-verify.js - uses: actions/upload-artifact@v4 if: always() with: name: agw-unfunded-validation path: .agw-validation/ + include-hidden-files: true if-no-files-found: ignore diff --git a/packages/core/scripts/agw-local/prepare.sh b/packages/core/scripts/agw-local/prepare.sh index 6e3d487e8..2796fe8a3 100755 --- a/packages/core/scripts/agw-local/prepare.sh +++ b/packages/core/scripts/agw-local/prepare.sh @@ -7,6 +7,8 @@ # so the clone's working tree, branches and index are never touched. # AGW_PIN commit to extract (default: the reviewed v6). # OUT destination directory (default: $TMPDIR/push-agw-local-). +# AGW_ALLOW_COMPILER_DOWNLOAD=1 permits Foundry to install the pinned Solidity +# compiler on a fresh CI runner. Default keeps compilation offline. # # The script extracts the pinned tree, fetches each git submodule at the exact # commit recorded in that tree, copies the harness-only contracts from @@ -44,5 +46,9 @@ fi mkdir -p "$OUT/sdk-harness" cp "$HERE"/contracts/*.sol "$OUT/sdk-harness/" -(cd "$OUT" && forge build --offline src sdk-harness lib/smartsessions/contracts/SmartSession.sol lib/openzeppelin-contracts/contracts/proxy >/dev/null) +AGW_BUILD_ARGS=(src sdk-harness lib/smartsessions/contracts/SmartSession.sol lib/openzeppelin-contracts/contracts/proxy) +if [ "${AGW_ALLOW_COMPILER_DOWNLOAD:-0}" != "1" ]; then + AGW_BUILD_ARGS=(--offline "${AGW_BUILD_ARGS[@]}") +fi +(cd "$OUT" && forge build "${AGW_BUILD_ARGS[@]}" >/dev/null) echo "$OUT" diff --git a/packages/core/scripts/agw-verify.js b/packages/core/scripts/agw-verify.js index 3ac74f72e..6767f8ded 100644 --- a/packages/core/scripts/agw-verify.js +++ b/packages/core/scripts/agw-verify.js @@ -3,6 +3,7 @@ const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const root = path.resolve(__dirname, '../../..'); +const sdkOnly = process.argv.includes('--sdk-only'); const dir = path.join(root, '.agw-validation'); fs.mkdirSync(dir, { recursive: true }); function run(name, file, args, options = {}) { @@ -16,6 +17,8 @@ function run(name, file, args, options = {}) { const out = String(result.stdout || '') + String(result.stderr || ''); fs.writeFileSync(path.join(dir, `${name}.log`), out); if (result.error || result.status !== 0) { + process.stderr.write(out); + if (result.error) process.stderr.write(String(result.error) + '\n'); throw new Error( `AGW validation failed: ${name}; see .agw-validation/${name}.log` ); @@ -28,37 +31,39 @@ run('units', node, [ '--config', 'packages/core/jest.config.ts', '--runInBand', - '--testPathPattern=agentic|outbound-(confirmation-gate|svm-confirmation)|svm-idl|evm-client.batch-gas', + '--testPathPattern=agentic|prc20-address|outbound-(confirmation-gate|svm-confirmation)|svm-idl|evm-client.batch-gas', ]); -for (const kind of ['lib', 'spec', 'agw-local']) +for (const kind of sdkOnly ? ['spec'] : ['lib', 'spec', 'agw-local']) run(`types-${kind}`, node, [ 'node_modules/typescript/bin/tsc', '-p', `packages/core/tsconfig.${kind}.json`, '--noEmit', ]); -run('docs', node, ['packages/core/scripts/check-agw-docs.js']); -const env = { - ...process.env, - OUT: process.env.AGW_LOCAL_DIR || path.join(dir, 'contracts'), -}; -run( - 'prepare-contracts', - 'bash', - ['packages/core/scripts/agw-local/prepare.sh'], - { env } -); -run( - 'local-contracts', - node, - [ - 'node_modules/jest/bin/jest.js', - '--config', - 'packages/core/jest.agw-local.config.ts', - '--runInBand', - ], - { env: { ...env, AGW_LOCAL_DIR: env.OUT } } -); +if (!sdkOnly) { + run('docs', node, ['packages/core/scripts/check-agw-docs.js']); + const env = { + ...process.env, + OUT: process.env.AGW_LOCAL_DIR || path.join(dir, 'contracts'), + }; + run( + 'prepare-contracts', + 'bash', + ['packages/core/scripts/agw-local/prepare.sh'], + { env } + ); + run( + 'local-contracts', + node, + [ + 'node_modules/jest/bin/jest.js', + '--config', + 'packages/core/jest.agw-local.config.ts', + '--runInBand', + ], + { env: { ...env, AGW_LOCAL_DIR: env.OUT } } + ); +} run( 'build', node, @@ -66,9 +71,14 @@ run( { env: { ...process.env, NX_DAEMON: 'false' } } ); run('package', node, ['packages/core/scripts/check-agw-package.js']); -run('public-api', node, [ - 'packages/core/scripts/check-agw-api.js', - '--output', - path.join(dir, 'api-comparison.json'), -]); -process.stdout.write('AGW unfunded validation completed.\n'); +if (!sdkOnly) + run('public-api', node, [ + 'packages/core/scripts/check-agw-api.js', + '--output', + path.join(dir, 'api-comparison.json'), + ]); +process.stdout.write( + sdkOnly + ? 'AGW SDK-only validation completed.\n' + : 'AGW unfunded validation completed.\n' +); From 2cd35f07e1b0bf627e2775050b44e7ee13569e6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 18 Oct 2026 14:21:24 +0000 Subject: [PATCH 18/27] release(core): bump to 6.0.27-alpha.3 [skip ci] --- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 42d5ee908..24c9100d9 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,9 @@ +@pushchain/core@6.0.27-alpha.3 (2026-10-18) + +- feat: ship bounded preset defaults with funding checks + +--- + @pushchain/core@6.0.27-alpha.2 (2026-10-16) - feat: add bounded preset operations and configuration readiness diff --git a/packages/core/package.json b/packages/core/package.json index 90f7fe7d4..279ded7a0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.27-alpha.2", + "version": "6.0.27-alpha.3", "homepage": "https://push.org", "keywords": [ "pushchain", From 2e7e05cae9ab5099e8fa046e5040d671bbe1c30a Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Tue, 20 Oct 2026 19:14:52 +0530 Subject: [PATCH 19/27] feat(core): sync v4 SDK changes without plan files Sync non-plan source through v4 aed67991, including AGW error improvements and marketplace SDK work. Preserve v5 release version, changelog, lockfile and existing plan tree. --- packages/core/AGW.md | 22 + packages/core/MARKETPLACE.md | 206 ++ .../core/__agw-local__/v5-multiasset.spec.ts | 1 + packages/core/__e2e__/ci/ci-reporter.js | 5 +- packages/core/__e2e__/ci/suite.ts | 12 +- .../core/__e2e__/marketplace/funding.spec.ts | 419 +++ .../__e2e__/marketplace/lifecycle.spec.ts | 276 ++ .../__e2e__/marketplace/readiness.spec.ts | 55 + .../core/__market-local__/marketplace.spec.ts | 846 ++++++ packages/core/__market-local__/setup.ts | 42 + .../core/jest.marketplace-local.config.ts | 15 + packages/core/package.json | 1 + .../contracts/MarketplaceOwnerFixtures.sol | 46 + packages/core/scripts/check-agw-api.js | 3 + packages/core/scripts/check-agw-docs.js | 7 +- packages/core/scripts/check-agw-package.js | 10 +- .../core/scripts/marketplace-local/prepare.sh | 17 + .../agentic/__tests__/creation-errors.spec.ts | 206 ++ .../agentic/__tests__/preset-catalog.spec.ts | 2 +- .../__tests__/preset-operation-limits.spec.ts | 2 +- .../__tests__/response-surface.spec.ts | 2 + packages/core/src/lib/agentic/codec/rules.ts | 206 +- .../core/src/lib/agentic/codec/universal.ts | 37 +- packages/core/src/lib/agentic/constants.ts | 15 +- .../core/src/lib/agentic/contracts/reader.ts | 7 + .../core/src/lib/agentic/error-messages.ts | 167 ++ packages/core/src/lib/agentic/errors.ts | 99 +- .../core/src/lib/agentic/management/common.ts | 12 + .../src/lib/agentic/management/destination.ts | 38 + .../src/lib/agentic/management/prepare.ts | 196 +- .../src/lib/agentic/management/svm-public.ts | 15 +- .../core/src/lib/agentic/presets/index.ts | 14 +- packages/core/src/lib/agentic/public-rule.ts | 26 +- packages/core/src/lib/index.ts | 49 + .../src/lib/job-spec/__tests__/codec.spec.ts | 190 ++ .../job-spec/__tests__/description.spec.ts | 62 + .../lib/job-spec/__tests__/preview.spec.ts | 320 +++ packages/core/src/lib/job-spec/codec.ts | 453 ++++ packages/core/src/lib/job-spec/constants.ts | 32 + packages/core/src/lib/job-spec/helpers.ts | 205 ++ packages/core/src/lib/job-spec/index.ts | 19 + packages/core/src/lib/job-spec/preview.ts | 396 +++ packages/core/src/lib/job-spec/read.ts | 341 +++ packages/core/src/lib/job-spec/schema.ts | 66 + packages/core/src/lib/job-spec/types.ts | 148 ++ packages/core/src/lib/job/job.ts | 600 +++++ packages/core/src/lib/job/types.ts | 110 + packages/core/src/lib/job/watch.spec.ts | 140 + packages/core/src/lib/job/watch.ts | 209 ++ .../lib/market/__tests__/ci-reporter.spec.ts | 38 + .../src/lib/market/__tests__/deposit.spec.ts | 384 +++ .../src/lib/market/__tests__/history.spec.ts | 143 + .../lib/market/__tests__/kernel-wire.spec.ts | 55 + .../lib/market/__tests__/namespace.spec.ts | 182 ++ .../src/lib/market/__tests__/rules.spec.ts | 115 + .../lib/market/contracts/AgenticCommerce.ts | 1313 ++++++++++ .../lib/market/contracts/RulesBindingHook.ts | 310 +++ .../market/contracts/UniversalMarketplace.ts | 2291 +++++++++++++++++ .../contracts/UniversalMarketplaceErrors.ts | 412 +++ .../contracts/UniversalMarketplaceRules.ts | 319 +++ packages/core/src/lib/market/data.ts | 68 + packages/core/src/lib/market/deployment.ts | 26 + packages/core/src/lib/market/deposit.ts | 455 ++++ packages/core/src/lib/market/errors.ts | 103 + packages/core/src/lib/market/history.ts | 123 + packages/core/src/lib/market/index.ts | 8 + packages/core/src/lib/market/kernel-wire.ts | 73 + packages/core/src/lib/market/market.ts | 979 +++++++ packages/core/src/lib/market/rules.ts | 417 +++ packages/core/src/lib/market/runtime.ts | 236 ++ packages/core/src/lib/market/types.ts | 177 ++ .../push-chain-native-multicall.spec.ts | 15 + .../src/lib/orchestrator/internals/errors.ts | 15 +- .../orchestrator/internals/push-chain-tx.ts | 13 +- .../core/src/lib/push-chain/push-chain.ts | 36 +- .../read-state/__tests__/read-params.spec.ts | 6 + .../read-state/__tests__/read-types.spec.ts | 9 + .../core/src/lib/read-state/read-params.ts | 13 +- packages/core/src/lib/utils.ts | 4 + packages/core/tsconfig.marketplace-local.json | 5 + 80 files changed, 14483 insertions(+), 227 deletions(-) create mode 100644 packages/core/MARKETPLACE.md create mode 100644 packages/core/__e2e__/marketplace/funding.spec.ts create mode 100644 packages/core/__e2e__/marketplace/lifecycle.spec.ts create mode 100644 packages/core/__e2e__/marketplace/readiness.spec.ts create mode 100644 packages/core/__market-local__/marketplace.spec.ts create mode 100644 packages/core/__market-local__/setup.ts create mode 100644 packages/core/jest.marketplace-local.config.ts create mode 100644 packages/core/scripts/agw-local/contracts/MarketplaceOwnerFixtures.sol create mode 100644 packages/core/scripts/marketplace-local/prepare.sh create mode 100644 packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts create mode 100644 packages/core/src/lib/agentic/error-messages.ts create mode 100644 packages/core/src/lib/agentic/management/destination.ts create mode 100644 packages/core/src/lib/job-spec/__tests__/codec.spec.ts create mode 100644 packages/core/src/lib/job-spec/__tests__/description.spec.ts create mode 100644 packages/core/src/lib/job-spec/__tests__/preview.spec.ts create mode 100644 packages/core/src/lib/job-spec/codec.ts create mode 100644 packages/core/src/lib/job-spec/constants.ts create mode 100644 packages/core/src/lib/job-spec/helpers.ts create mode 100644 packages/core/src/lib/job-spec/index.ts create mode 100644 packages/core/src/lib/job-spec/preview.ts create mode 100644 packages/core/src/lib/job-spec/read.ts create mode 100644 packages/core/src/lib/job-spec/schema.ts create mode 100644 packages/core/src/lib/job-spec/types.ts create mode 100644 packages/core/src/lib/job/job.ts create mode 100644 packages/core/src/lib/job/types.ts create mode 100644 packages/core/src/lib/job/watch.spec.ts create mode 100644 packages/core/src/lib/job/watch.ts create mode 100644 packages/core/src/lib/market/__tests__/ci-reporter.spec.ts create mode 100644 packages/core/src/lib/market/__tests__/deposit.spec.ts create mode 100644 packages/core/src/lib/market/__tests__/history.spec.ts create mode 100644 packages/core/src/lib/market/__tests__/kernel-wire.spec.ts create mode 100644 packages/core/src/lib/market/__tests__/namespace.spec.ts create mode 100644 packages/core/src/lib/market/__tests__/rules.spec.ts create mode 100644 packages/core/src/lib/market/contracts/AgenticCommerce.ts create mode 100644 packages/core/src/lib/market/contracts/RulesBindingHook.ts create mode 100644 packages/core/src/lib/market/contracts/UniversalMarketplace.ts create mode 100644 packages/core/src/lib/market/contracts/UniversalMarketplaceErrors.ts create mode 100644 packages/core/src/lib/market/contracts/UniversalMarketplaceRules.ts create mode 100644 packages/core/src/lib/market/data.ts create mode 100644 packages/core/src/lib/market/deployment.ts create mode 100644 packages/core/src/lib/market/deposit.ts create mode 100644 packages/core/src/lib/market/errors.ts create mode 100644 packages/core/src/lib/market/history.ts create mode 100644 packages/core/src/lib/market/index.ts create mode 100644 packages/core/src/lib/market/kernel-wire.ts create mode 100644 packages/core/src/lib/market/market.ts create mode 100644 packages/core/src/lib/market/rules.ts create mode 100644 packages/core/src/lib/market/runtime.ts create mode 100644 packages/core/src/lib/market/types.ts create mode 100644 packages/core/tsconfig.marketplace-local.json diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 0a8a7c60f..e5a0e18bc 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -10,6 +10,7 @@ Use an application-managed key; examples do not load secrets or execute automati import { PushChain, AgenticError, + AgenticRevertError, type Rule, } from '@pushchain/core'; import { @@ -116,6 +117,27 @@ The deployed v6 policy supports up to eight EQ/MAX pins per EVM destination call When the caller omits destination `gasLimit`, a fresh EVM CEA gets a 2,000,000 gas default to cover first-use deployment. An existing CEA uses the normal quote default. An explicit caller value is preserved. The rule's gas/value caps still apply. +## Displaying errors in the UI + +Use `userMessage` for `AgenticError` and `AgenticRevertError`. Keep `code`, `message`, `hint`, `details`, `decodedError` and `cause` for diagnostics or application-specific translations. Unknown exceptions should get a neutral fallback instead of raw RPC/ABI text. + +```ts +function walletErrorText(error: unknown): string { + if (error instanceof AgenticError || error instanceof AgenticRevertError) { + return error.userMessage; + } + return 'The request could not be completed. Check wallet activity before trying again.'; +} +``` + +Creation and rule-preparation errors include a zero-based `details.ruleIndex` when a rule can be identified, with `assetIndex`, `callIndex` and `field` where available. The UI message uses a one-based rule number. Use these details to show the error on the relevant rule instead of only in a footer. Read/receipt failures preserve their causes and transaction hashes; do not automatically retry a submitted transaction whose outcome is unknown. + +`TOKEN_ROUTE_UNAVAILABLE` means an asset cannot be resolved for transfers from Push. It is not a token-symbol formatting request. Choose a supported source token, or regenerate the preset with `funding: 'destination'` when using assets already held by the destination account. The wallet can be created before funding; destination balances are needed before execution. Pass returned `rules` unchanged. **Do not copy `readiness.chains[].selectedAssets` back into `rules[].assets` in destination-funded mode**: selectedAssets is review metadata and doing so reintroduces unsupported bridge requirements. + +A DEX condition-limit failure reports `details.reason = 'RULE_CONSTRAINT_LIMIT'`, `actual`, `limit` and `callCount`. Conditions here are generated input checks (for example token, recipient and amount checks), rather than the number of visible function names. A stock two-token, single-router preset contains six allowed-call entries and twenty conditions. Appending the same call list again makes forty conditions. The SDK rejects this; it does not deduplicate, remove conditions or split authority automatically. Preserve per-chain rules and regenerate after edits rather than appending generated calls. + +For the review screen, show the actual network (including testnet name) and funding mode. In destination-funded mode, selectedAssets.maxTotal is not an enforced destination lifetime limit; the grant has no Push token-export budget. Label maxGasPerCall as a maximum PC gas budget, not a guaranteed fee or execution gas-unit count. + ## Solana destinations Rules use fixed-width signatures and numeric positions, without an IDL: diff --git a/packages/core/MARKETPLACE.md b/packages/core/MARKETPLACE.md new file mode 100644 index 000000000..3a5511e8a --- /dev/null +++ b/packages/core/MARKETPLACE.md @@ -0,0 +1,206 @@ +# Agentic marketplace and jobs + +This SDK checkpoint targets UniversalMarketplace v2 on Push Donut, its pUSDC kernel and AGW v6. It implements card management, JobSpec authoring and paid previews, preview/open, historical deposits and the kernel lifecycle. The evaluator is currently an EOA: there is no automatic verification, baseline or rating claim. + +`PushChain.initialize` now exposes `client.market` and `client.job`. Helpers are `PushChain.utils.market` and `PushChain.utils.job`; vocabulary is under `PushChain.CONSTANTS.AGENTIC`. There is no separate `client.evaluation` namespace. Read-only clients can discover cards and jobs. + +## Author a card + +Use verified application/token addresses and raw token units. The following helper returns permissions and a criteria template; it does not sign or send anything. + +```ts +import { PushChain, MarketError, type AgentCardInput, type AgentCardRules, type JobPlan, type JobWatchCursor, type MarketDepositResult, type PreviewJobSpecOptions } from '@pushchain/core'; +import { parseAbi, type Address } from 'viem'; + +function authorCard(app: Address, token: Address, receiptToken: Address) { + const { WALLET, PRINCIPAL, JOB } = PushChain.CONSTANTS.AGENTIC; + const read = PushChain.utils.job.prepareRead({ + chain: PushChain.CONSTANTS.CHAIN.BNB_TESTNET, + target: receiptToken, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: [WALLET], + }); + const jobSpecTemplate = PushChain.utils.job.buildJobSpecTemplate({ + reads: [read], + checks: [{ read: 0, evaluation: JOB.EVALUATION.COMPARE, operation: JOB.OPERATION.GREATER_OR_EQUAL, target: { principalBps: 9990n } }], + logic: JOB.LOGIC.ALL, + allowedMutability: JOB.ALLOWED_MUTABILITY.NONE, + }); + const rules: AgentCardRules = { + assets: [{ token, maxPerCall: PRINCIPAL, maxTotal: PRINCIPAL }], + principalAsset: 0, + maxGasPerCall: 10n ** 18n, + allowedCalls: [ + { target: token, selector: 'approve(address,uint256)', pins: [{ arg: 0, expected: app }, { arg: 1, max: PRINCIPAL }] }, + { target: app, selector: 'supply(address,uint256,address,uint16)', pins: [{ arg: 0, expected: token }, { arg: 1, max: PRINCIPAL }, { arg: 2, expected: WALLET }] }, + ], + }; + PushChain.utils.market.validateAgentCardRules(rules); + return { rules, jobSpecTemplate }; +} +``` + +`client.universal.read.prepare(options)` is the same pure authoring helper. `client.universal.read(prepared, options?)` sends a resolved prepared call and returns raw consensus bytes. WALLET/PARAM holes must first be resolved; the sender rejects them before a paid request. Existing async `universal.prepareRead(subject, options)` remains the paid-request preparation API. They produce different representations. A literal zero is never guessed to be a placeholder. Dynamic-layout placeholders are refused. Descriptions retain ABI function names in memory when available and apply explicit token decimals while retaining raw targets; chain-decoded specs fall back to selectors and addresses. + +Cards name one execution chain and one provider. Destination tokens resolve to verified Push PRC20 routes on that chain. An explicit `{ prc20: address }` is available for already-known Push tokens and is returned by lossless rule decoding. No route or same-symbol alias is invented. Structural validation alone does not establish deployment or liquidity readiness. + +Approvals are **allowed calls with spender/amount pins** in v2. There is no separate `approvals[]` field or automatic owner-approval deposit step. Principal caps meter outbound tokens from Push; they are not a destination-lifetime trading budget. Spec comparisons use raw units; the receipt token must have the units expected by the criterion. + +## Register, preview and open + +```ts +async function registerOffer( + provider: PushChain, + listing: AgentCardInput, + app: Address, + token: Address, + receiptToken: Address, +) { + const authored = authorCard(app, token, receiptToken); + return provider.market.agent.register(listing, authored); +} + +async function openReviewedJob(owner: PushChain, cardId: bigint) { + const now = Math.floor(Date.now() / 1000); + const plan = await owner.market.preview(cardId, { + principal: 100_000_000n, + executeBy: now + 3600, + expiredAt: now + 86400, + }); + // Render every mayDo/mayMove/judgedBy entry and the logic before signing. + return owner.market.openJob(plan, { + progressHook: event => console.log(event.id, event.message), + }); +} +``` + +Opening deploys/reuses the AGW, grants the card's exact resolved permissions and creates an Open job. It moves no principal or fee. The plan is revalidated before signing; changed card versions, nonces, occupancy or plan content require a new preview. OwnerIntent authorization uses the factory EIP-712 domain. Client submission can require a second wallet interaction; no relay is supplied in this checkpoint. + +Opening supports EVM typed-data signers. An external owner's UEA must already be deployed on Push (`OWNER_NOT_DEPLOYED` otherwise). The SDK checks the factory domain before prompting, then simulates the complete signed request through the real owner verifier before broadcasting. UEA and ERC-1271 acceptance follows the deployed contract; an EIP-7702 delegate without a compatible verifier fails simulation. Signing capability checks run before prompting; a verifier can reject the resulting signature after prompting. Provider writes use the connected signer's ordinary Push identity, including when the instance also has an AGW execution context. + +```ts +async function recoverOpening(client: PushChain, reviewedPlan: JobPlan, txHash: string) { + const tx = await client.universal.trackTransaction(txHash); + return client.market.openJob(reviewedPlan, { resume: tx }); +} +``` + +If receipt lookup or post-state verification fails, keep the original plan and reported transaction hash. Resume verifies the mined input, event, permission ID, wallet ownership, kernel record and marketplace mappings without another signature or submission. A read-only client can recover an existing opening. Normal writes copy reviewed inputs before awaiting RPCs or wallet prompts. Provider registration/update also simulate the exact card write before submission. A reference ERC-1271 contract-owner adapter and compatible/incompatible EIP-7702 verifiers are exercised against real contracts in the local acceptance suite; custom wallets supply their own `UniversalSigner` transport. + +Native transport errors also retain `txHash` and, when confirmation is uncertain, `pendingTransactionHash` after broadcast. Atomic native sends never fall back to sequential calls after a hash exists. Track that hash before another attempt; a confirmed revert retains its hash without claiming that it is still pending. + +**Current destination readiness:** BSC Testnet has passing live open/cancel coverage. Sepolia, Base Sepolia and Arbitrum Sepolia have different CEA factories in the marketplace configuration and current SDK routing table; preview returns `CEA_MISMATCH`. The SDK does not substitute the marketplace's account for an independently derived account. Align the deployment/routing configuration before opening on those chains. + +Card IDs, versions, ratings and job IDs are bigint. `jobType` reads are the canonical bytes32 hash, not a guessed original name. `cards` walks IDs with bounded concurrency; summaries remain readable even if an unverified provider stored a malformed template. Full `card` decoding/preview rejects unsupported content. `agent.card`, `agent.update` and `agent.setActive` manage the provider's one card. Preserve raw `rulesBytes`/`jobSpecTemplateBytes` for archival rather than serializing away unknown wire metadata. + +## Fund and track the kernel lifecycle + +```ts +async function depositForJob(owner: PushChain, jobId: bigint): Promise { + return owner.market.deposit(jobId, { + // Target wallet PC balance, not a gas quote for the whole job. + pcAmount: 10n ** 18n, + funding: 'push', + progressHook: event => console.log(event.id, event.message), + }); +} +``` + +`deposit` recovers the original principal and card version from the opening event and historical card state. It never sizes an old job from today's updated card. It tops up wallet balances to the principal, original reference fee and PC target; a shared principal/fee token is combined before subtracting the balance. `providerFee` explicitly overrides the fee reserve and `pcAmount` overrides the PC target. The default PC target is one per-call card gas cap, not a promise that all job execution is covered. This operation leaves the kernel escrow Open and makes no destination approval calls: those are pinned permissions granted to the agent in v2. + +The default source is the connected signer's Push identity, even on an instance configured to execute through an AGW. `funding: 'origin'` explicitly uses normal inbound `funds` transfers for registered assets from the connected origin chain; required tokens without such a route fail before any leg is sent. PC still comes from the signer's Push holdings. The underlying gateway's normal route, liquidity, balance and fee checks apply. + +Steps are separate transactions. `DEPOSIT_INCOMPLETE` retains `details.txs`, any `details.pending` broadcast hash and chain, and the original cause. The same client remembers submitted steps; pass `resume: previousSteps` when continuing with another client. After restarting, rehydrate recorded hashes with `universal.trackTransaction(hash, { chain })` before passing their transaction responses. Pending receipts must settle before another transfer, and a confirmed transfer whose target balance has since fallen requires manual review. Concurrent deposits on the same client are rejected. Coordinate other clients and avoid running the agent during deposits. Deposits require an Open, unexpired job and an enabled permission with no recorded asset spend. When archive state is unavailable or updates share the opening block, the SDK recovers the version from registration/update events and canonical transaction input, checking emitted content hashes, fee and immutable card identity. If neither source can authenticate it, `HISTORY_UNAVAILABLE` stops the operation. + +```ts +async function priceAndFund(provider: PushChain, owner: PushChain, jobId: bigint) { + // First deposit the payment token, principal and PC appropriate to the job. + await (await provider.job.setBudget(jobId, 5_000_000n)).wait(); + // Explicitly pass the price the client reviewed; never silently accept a re-quote. + return owner.job.fund(jobId, { expectedBudget: 5_000_000n }); +} + +async function trackJob(client: PushChain, provider: PushChain, jobId: bigint) { + const stop = client.job.watch(jobId, event => console.log(event.kind)); + const job = await client.job.status(jobId); + await (await provider.job.submit(jobId, { deliverable: 'ipfs://deliverable' })).wait(); + stop(); + return job; +} +``` + +The kernel currently accepts pUSDC (`USDC.eth`, 6 decimals) only; explicit mismatching payment tokens are rejected. A card's providerFee is a reference price. For an unchanged marketplace card version, omitted expectedBudget uses that reference; plain jobs or changed card versions need an explicit reviewed amount. A zero-price job is valid. + +For AGW clients, fund verifies ownership, rule/provider identity and expiry, then batches any necessary ERC20 allowance change and escrow funding through the owner door. This allowance is distinct from the AGW's automatic gateway allowance. Plain-client allowance/fund batching requires a supported atomic signer transport; the SDK does not silently fall back to sequential acceptance. + +`status` reads the kernel and hook, retains binary description bytes, decodes supported v2 JobSpecs, and recovers deliverable from submission logs. `evaluation.available` is false for this manual/unverified evaluator profile; frozen/baseline/verdict data is not fabricated. Plain text descriptions may have no decoded JobSpec. `list` can filter client/provider/status; `watch` polls bounded event ranges, reports read errors, supports a starting block and returns a stop function. It is not an indexer or a reorg-finality service. + +```ts +function resumeJobEvents(client: PushChain, jobId: bigint, cursor?: JobWatchCursor) { + return client.job.watch(jobId, event => { + // Persist cursor only after your application handles the event. + if (event.kind !== 'Error') console.log(event.kind, event.cursor); + }, cursor ? { cursor } : { pollIntervalMs: 3000 }); +} +``` + +Each delivered event has an exact block/log cursor. Resuming excludes already delivered logs within the same block; duplicate RPC logs are delivered once. RPC failures retry the same range, consumer callback errors do not rewind delivery, and unsubscribe suppresses pending callbacks. A changed canonical cursor emits `WATCH_REORG` and stops; reload status and choose a reviewed restart block. + +```ts +async function stopOpenJob(owner: PushChain, jobId: bigint) { + // For marketplace AGWs, one owner batch rejects the Open job and revokes its recorded grant. + return owner.job.cancel(jobId); +} + +function marketplaceErrorText(error: unknown): string { + return error instanceof MarketError ? error.userMessage : 'Check transaction activity before retrying.'; +} +``` + +Cancellation is client-side Open-job rejection. It does not withdraw deposited wallet balances; those remain under the owner's existing transfer authority. A plain job has no marketplace grant to infer. Other terminal paths do not imply AGW permission revocation. `claimRefund` respects expiry/grace and remains available while the kernel is paused; a third party may trigger it, with funds returned to the recorded client. + +Plain jobs can use `job.create` with provider/evaluator/hook/expiry/paymentToken plus either binary jobSpec or text description. The kernel's Solidity string slot can carry binary JobSpec bytes; the SDK preserves them and retains the original createJob selector. + +## Paid criteria preview + +```ts +async function previewCriteria(client: PushChain, template: ReturnType['jobSpecTemplate'], wallet: Address) { + const options: PreviewJobSpecOptions = { + client, wallet, principal: 100_000_000n, + maxTotalFee: 10n ** 16n, + advanced: { timeout: 120_000 }, + }; + return PushChain.utils.job.previewJobSpec(template, options); +} +``` + +This prepares every raw read, checks the total callback/protocol fee cap, then executes through the existing universal-read registry (or an explicit `callback`). Native transaction gas is separate from `maxTotalFee`. A real SDK client derives destination CEAs for template wallet holes using its configured routes; `accounts` supplies explicit destination addresses when needed. An AGW's Push address is never substituted for its destination account. + +The result includes raw values, decoded fields, request responses and fees. `pass` and each check are `true`, `false`, or `null` (unknown). NUMBER and PERCENTAGE require an explicit per-read `baseline`; missing baselines, zero percentage denominators and unavailable reads stay unknown. A preview is an observation, not an automatic evaluator verdict or proof that a job is complete. Raw EVM calls are also available as `universal.read(target, { chain, callData })` and return consensus bytes. + +`PREVIEW_INCOMPLETE` retains all known request IDs and transaction hashes. Track uncertain submissions before retrying. Pass `resume: requestIds` (one per read, in order) to finish without new requests or payment. Resume checks each on-chain destination, call, pin and confirmation requirement before evaluating; unrelated reads are refused. `fees.newlyPaid` is zero on recovery, while `fees.paid` retains the original request deposits. A zero `maxTotalFee` is valid for recovery. + +## Explicit placeholders + +- `job.verify`, baseline/prefund/retry integration: no compatible automatic evaluator is configured. A different linked evaluator source has a different wire and enum values. +- Replacement JobSpecs, non-NONE marketplace criteria, recurring jobs and relay are unavailable. + +Unsupported paths throw before signing/sending. They do not return successful dummy results. Keep message, code, details and cause for diagnostics. + +## E2E validation + +From the repository root: + +```sh +# Real deployed stack on an isolated Anvil fork; no private keys or public-chain writes. +node node_modules/jest/bin/jest.js --config packages/core/jest.marketplace-local.config.ts --runInBand +# Read-only live Donut acceptance. +node node_modules/ts-node/dist/bin.js --compiler-options '{"module":"commonjs"}' packages/core/__e2e__/ci/run.ts --group marketplace +# Explicit funded smoke, using packages/core/.env: fresh provider/card/owner/wallet, then cancel/revoke/deactivate. +node node_modules/ts-node/dist/bin.js --compiler-options '{"module":"commonjs"}' packages/core/__e2e__/ci/run.ts --group marketplace-live +# Owned-token acceptance: actual deposits, native/AGW escrow and payouts, BSC-origin principal bridging. +node node_modules/ts-node/dist/bin.js --compiler-options '{"module":"commonjs"}' packages/core/__e2e__/ci/run.ts --group marketplace-funding +``` + +Marketplace groups stay out of the runner's default `all`. The basic live group spends bounded PC for gas/test wallets and read callbacks. The separate funding group uses owned test assets: 0.01 pUSDC per paid job, 0.01 USDT of bridged principal and up to 0.01 pBNB for source-gas setup. It cancels/revokes market jobs, completes plain jobs with a fresh controlled manual evaluator, returns principal/reserve/payout tokens and deactivates its card. The deployed marketplace evaluator remains unchanged. Pause/refund and signature variants use the local fork. The fork's UEA signature test uses a native submitter because Anvil has no Cosmos transport; live cases exercise actual external-owner transport. No automated verdict is implied by those results. Signature fixtures compile from repo-owned source at harness startup; Foundry and cached solc 0.8.26 are prerequisites. diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 859d1247f..394301edd 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -352,6 +352,7 @@ describe('v6 multi-asset SDK management and execution', () => { }) ).rejects.toMatchObject({ decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + userMessage: expect.stringContaining('exceeds a rule'), }); } ); diff --git a/packages/core/__e2e__/ci/ci-reporter.js b/packages/core/__e2e__/ci/ci-reporter.js index 3dcd4db6c..9f23ded8d 100644 --- a/packages/core/__e2e__/ci/ci-reporter.js +++ b/packages/core/__e2e__/ci/ci-reporter.js @@ -43,7 +43,10 @@ class CiReporter { numPassedTests: results.numPassedTests, numFailedTests: results.numFailedTests, numPendingTests: results.numPendingTests, - success: Boolean(results.success), + // Jest fills `success` after onRunComplete. Derive completion from final counters here. + success: results.numPassedTests > 0 && results.numFailedTests === 0 && + (results.numFailedTestSuites ?? 0) === 0 && (results.numRuntimeErrorTestSuites ?? 0) === 0 && !results.wasInterrupted, + jestReportedSuccess: Boolean(results.success), tests, }; diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index 56dd7c9a3..472727960 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -39,6 +39,9 @@ export const GROUPS = [ 'agw-svm-replay', 'agw-presets', 'agw-presets-expanded', + 'marketplace', + 'marketplace-live', + 'marketplace-funding', ] as const; export type Group = (typeof GROUPS)[number]; @@ -169,6 +172,13 @@ const F = { } as const; export const SCENARIOS: Scenario[] = [ + { id: 'marketplace-funding-agw', group: 'marketplace-funding', file: '__e2e__/marketplace/funding.spec.ts', grep: 'marketplace funding 1\\. deposits real principal', needs: { masterPC: '1' }, env: { MARKETPLACE_FUNDING_E2E: '1', AGW_E2E_AGENT_PC: '0.1' }, note: 'Owned test tokens only; 0.01 pUSDC escrow, controlled fresh evaluator, token cleanup.' }, + { id: 'marketplace-funding-native', group: 'marketplace-funding', file: '__e2e__/marketplace/funding.spec.ts', grep: 'marketplace funding 2\\. funds a native plain job', needs: { masterPC: '1' }, env: { MARKETPLACE_FUNDING_E2E: '1', AGW_E2E_AGENT_PC: '0.1' }, note: '0.01 pUSDC escrow using native atomic approval/funding and a controlled manual evaluator.' }, + { id: 'marketplace-funding-origin', group: 'marketplace-funding', file: '__e2e__/marketplace/funding.spec.ts', grep: 'marketplace funding 3\\. bridges principal', needs: { masterPC: '1' }, env: { MARKETPLACE_FUNDING_E2E: '1', AGW_E2E_AGENT_PC: '0.1' }, note: 'Fresh BSC owner, 0.01 USDT principal, up to 0.01 pBNB for source-gas setup; explicit token cleanup.' }, + { id: 'marketplace-native-lifecycle', group: 'marketplace-live', file: '__e2e__/marketplace/lifecycle.spec.ts', grep: 'marketplace live 1\\. native provider registration', needs: { masterPC: '0.5' }, env: { MARKETPLACE_LIVE_E2E: '1', AGW_E2E_AGENT_PC: '0.1' }, note: 'Fresh provider/card/wallet only; no principal export or fee escrow. Cancels and revokes its own permission.' }, + { id: 'marketplace-paid-preview', group: 'marketplace-live', file: '__e2e__/marketplace/lifecycle.spec.ts', grep: 'marketplace live 2\\. paid JobSpec preview', needs: { masterPC: '0.5' }, env: { MARKETPLACE_LIVE_E2E: '1', AGW_E2E_AGENT_PC: '0.1' }, note: 'One live validator read through the registry, with an explicit 0.01 PC callback/protocol fee cap.' }, + { id: 'marketplace-external-owner', group: 'marketplace-live', file: '__e2e__/marketplace/lifecycle.spec.ts', grep: 'marketplace live 3\\. external EVM owner', needs: { masterPC: '0.5' }, env: { MARKETPLACE_LIVE_E2E: '1', AGW_E2E_AGENT_PC: '0.1' }, note: 'Fresh Sepolia-origin UEA funded with 0.1 PC; open/cancel using the real UEA transport; reuses this run’s card.' }, + { id: 'marketplace-readonly-bindings', group: 'marketplace', file: '__e2e__/marketplace/readiness.spec.ts', grep: 'marketplace deployed v2 read-only', needs: {}, note: 'Unfunded public SDK card/job enumeration and capability gates; no signing keys.' }, { id: 'agw-svm-replay-positive', group: 'agw-svm-replay', @@ -1210,7 +1220,7 @@ export function scenariosFor(group: string | undefined): Scenario[] { if (!group || group === 'all') { // AGW stays opt-in: scenarios create/fund fresh wallets and consume testnet funds. return SCENARIOS.filter( - (s) => s.group !== 'known-fail' && !s.group.startsWith('agw') + (s) => s.group !== 'known-fail' && !s.group.startsWith('agw') && !s.group.startsWith('marketplace') ); } if (!(GROUPS as readonly string[]).includes(group)) { diff --git a/packages/core/__e2e__/marketplace/funding.spec.ts b/packages/core/__e2e__/marketplace/funding.spec.ts new file mode 100644 index 000000000..4d92181d8 --- /dev/null +++ b/packages/core/__e2e__/marketplace/funding.spec.ts @@ -0,0 +1,419 @@ +/** Opt-in acceptance with bounded tokens owned by the configured test master. No public impersonation. */ +import { + createPublicClient, + createWalletClient, + encodeFunctionData, + erc20Abi, + http, + parseAbi, + parseEther, + zeroAddress, + zeroHash, + type Address, + type Hex, +} from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { PushChain } from '../../src'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { CHAIN_INFO } from '../../src/lib/constants/chain'; +import { JOB_KERNEL_ABI } from '../../src/lib/market/contracts/AgenticCommerce'; +import { DONUT_MARKETPLACE as D } from '../../src/lib/market/deployment'; +import { setupAgw, evmClient, type AgwFixture } from '../agw/_fixture'; +const d = + process.env['MARKETPLACE_FUNDING_E2E'] === '1' ? describe : describe.skip; +const principalToken = '0x731aF1Da5365259d27528557EE4aFBA4baC90ef2' as Address; +const budget = BigInt(10000); // 0.01 pUSDC per paid job, returned to the master after payout. +d('marketplace funding', () => { + let f: AgwFixture; + let owner: PushChain; + let provider: PushChain; + let evaluator: PushChain; + let ownerKey: Hex; + let cardId: bigint; + const wallets: { key: Hex; origin: CHAIN; wallet: Address }[] = []; + const jobs: { jobId: bigint; market: boolean; client: PushChain }[] = []; + const sendToken = async ( + client: PushChain, + token: Address, + to: Address, + amount: bigint + ) => { + const tx = await client.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [to, amount], + }), + }); + await tx.wait(); + return tx; + }; + const balance = (token: Address, account: Address) => + f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [account], + }); + const complete = async (jobId: bigint) => { + const tx = await evaluator.universal.sendTransaction({ + to: D.kernel, + data: encodeFunctionData({ + abi: JOB_KERNEL_ABI, + functionName: 'complete', + args: [jobId, zeroHash, '0x'], + }), + }); + await tx.wait(); + return tx; + }; + beforeAll(async () => { + f = await setupAgw(); + if ( + (await balance(principalToken, f.ownerAddress)) < BigInt(1) || + (await balance(D.paymentToken, f.ownerAddress)) < budget * BigInt(3) + ) + throw new Error( + 'Configured Push master needs 1 pUSDT.bnb unit and 0.03 pUSDC for this bounded acceptance' + ); + ownerKey = generatePrivateKey(); + owner = await evmClient( + ownerKey, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network + ); + provider = await evmClient( + f.agentKey, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network + ); + evaluator = await evmClient( + generatePrivateKey(), + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network + ); + await f.fundPC(owner.universal.account as Address, parseEther('0.3')); + await f.fundPC(evaluator.universal.account as Address, parseEther('0.1')); + const token = PushChain.CONSTANTS.MOVEABLE.TOKEN.BNB_TESTNET.USDT + .address as Address; + const template = PushChain.utils.job.buildJobSpecTemplate({ + reads: [ + PushChain.utils.job.prepareRead({ + chain: CHAIN.BNB_TESTNET, + target: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: ['agentic:wallet'], + }), + ], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: BigInt(0), + }, + ], + logic: 'ALL', + }); + const registered = await provider.market.agent.register( + { + jobType: 'SDK_FUNDING_ACCEPTANCE', + metadataURI: 'https://example.invalid/sdk-funding-acceptance', + metadataHash: ('0x' + '56'.repeat(32)) as Hex, + chain: CHAIN.BNB_TESTNET, + providerFee: budget, + paymentToken: D.paymentToken, + principalMin: BigInt(1), + principalMax: BigInt(10000), + minDuration: 3600, + maxDuration: 86400, + minExecuteWindow: 0, + settleWindow: 60, + }, + { + rules: { + assets: [ + { + token: { prc20: principalToken }, + maxPerCall: 'agentic:principal', + maxTotal: 'agentic:principal', + }, + ], + principalAsset: 0, + maxGasPerCall: BigInt(0), + allowedCalls: [ + { + target: token, + selector: 'balanceOf(address)', + pins: [{ arg: 0, expected: 'agentic:wallet' }], + }, + ], + }, + jobSpecTemplate: template, + } + ); + cardId = registered.cardId; + f.evidence('marketplace-funding-register', { + cardId, + txHash: registered.tx.hash, + }); + }, 300000); + afterAll(async () => { + try { + for (const j of jobs) { + let status = (await j.client.job.status(j.jobId)).status; + if (status === 'Open') + await (await j.client.job.cancel(j.jobId)).wait(); + if (!j.market && status === 'Funded') { + await ( + await provider.job.submit(j.jobId, { + deliverable: 'SDK acceptance cleanup', + }) + ).wait(); + status = 'Submitted'; + } + if (!j.market && status === 'Submitted') await complete(j.jobId); + } + for (const w of wallets) { + const client = await evmClient( + w.key, + w.origin, + f.manifest.network, + w.wallet + ); + for (const token of [principalToken, D.paymentToken]) { + const held = await balance(token, w.wallet); + if (held) await sendToken(client, token, f.ownerAddress, held); + } + } + for (const client of [provider, evaluator, owner]) { + if (!client) continue; + const held = await balance( + D.paymentToken, + client.universal.account as Address + ); + if (held) await sendToken(client, D.paymentToken, f.ownerAddress, held); + } + if (cardId && (await provider.market.card(cardId)).active) + await (await provider.market.agent.setActive(cardId, false)).wait(); + f?.evidence('marketplace-funding-cleanup', { + cardId, + jobs: await Promise.all( + jobs.map(async (j) => ({ + jobId: j.jobId, + status: (await j.client.job.status(j.jobId)).status, + })) + ), + }); + } finally { + f?.teardown(); + } + }, 300000); + it('1. deposits real principal and fee, cancels the market job, then pays an AGW plain job through a controlled manual evaluator', async () => { + await sendToken( + f.owner, + principalToken, + owner.universal.account as Address, + BigInt(1) + ); + await sendToken( + f.owner, + D.paymentToken, + owner.universal.account as Address, + budget + ); + const now = Number((await f.push.getBlock()).timestamp); + const plan = await owner.market.preview(cardId, { + principal: BigInt(1), + executeBy: now + 600, + expiredAt: now + 7200, + }); + const opened = await owner.market.openJob(plan); + jobs.push({ jobId: opened.jobId, market: true, client: owner }); + wallets.push({ + key: ownerKey, + origin: CHAIN.PUSH_TESTNET_DONUT, + wallet: opened.wallet, + }); + const deposited = await owner.market.deposit(opened.jobId, { + pcAmount: parseEther('0.001'), + }); + expect(await balance(principalToken, opened.wallet)).toBe(BigInt(1)); + expect(await balance(D.paymentToken, opened.wallet)).toBe(budget); + const recovered = await owner.market.openJob(plan, { resume: opened.tx }); + expect(recovered.jobId).toBe(opened.jobId); + await (await owner.job.cancel(opened.jobId)).wait(); + const client = await evmClient( + ownerKey, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + opened.wallet + ); + const plain = await client.job.create({ + provider: provider.universal.account as Address, + evaluator: evaluator.universal.account as Address, + hook: zeroAddress, + expiredAt: now + 7200, + paymentToken: D.paymentToken, + description: 'SDK live escrow and manual evaluator acceptance', + }); + jobs.push({ jobId: plain.jobId, market: false, client }); + await (await provider.job.setBudget(plain.jobId, budget)).wait(); + const before = await balance( + D.paymentToken, + provider.universal.account as Address + ); + const funded = await client.job.fund(plain.jobId, { + expectedBudget: budget, + }); + await funded.wait(); + expect((await client.job.status(plain.jobId)).status).toBe('Funded'); + expect(await balance(D.paymentToken, opened.wallet)).toBe(BigInt(0)); + await ( + await provider.job.submit(plain.jobId, { + deliverable: 'SDK live manual evaluator proof', + }) + ).wait(); + const completed = await complete(plain.jobId); + expect((await client.job.status(plain.jobId)).status).toBe('Completed'); + expect( + await balance(D.paymentToken, provider.universal.account as Address) + ).toBeGreaterThan(before); + f.evidence('marketplace-funding-agw-paid', { + cardId, + marketJobId: opened.jobId, + plainJobId: plain.jobId, + wallet: opened.wallet, + depositHashes: deposited.txs.map((t) => t.tx.hash), + fundingHash: funded.hash, + completionHash: completed.hash, + budget, + }); + }, 600000); + it('2. funds a native plain job with an atomic token approval and pays out on live Donut', async () => { + await sendToken( + f.owner, + D.paymentToken, + owner.universal.account as Address, + budget + ); + const now = Number((await f.push.getBlock()).timestamp); + const created = await owner.job.create({ + provider: provider.universal.account as Address, + evaluator: evaluator.universal.account as Address, + hook: zeroAddress, + expiredAt: now + 7200, + paymentToken: D.paymentToken, + description: 'SDK live native escrow acceptance', + }); + jobs.push({ jobId: created.jobId, market: false, client: owner }); + await (await provider.job.setBudget(created.jobId, budget)).wait(); + const funded = await owner.job.fund(created.jobId, { + expectedBudget: budget, + }); + await funded.wait(); + expect(funded.atomic).toBe(true); + expect((await owner.job.status(created.jobId)).status).toBe('Funded'); + await ( + await provider.job.submit(created.jobId, { + deliverable: 'SDK native acceptance', + }) + ).wait(); + const completed = await complete(created.jobId); + expect((await owner.job.status(created.jobId)).status).toBe('Completed'); + f.evidence('marketplace-funding-native-paid', { + jobId: created.jobId, + fundingHash: funded.hash, + completionHash: completed.hash, + budget, + }); + }, 600000); + it('3. bridges principal from a fresh BSC origin into the opened wallet and recovers its funds after cancellation', async () => { + const sourceKey = process.env['EVM_PRIVATE_KEY'] as Hex; + if (!sourceKey) + throw new Error('EVM_PRIVATE_KEY is required for owned BSC test funding'); + const info = CHAIN_INFO[CHAIN.BNB_TESTNET]; + const bsc = createPublicClient({ transport: http(info.defaultRPC[0]) }); + const master = createWalletClient({ + account: privateKeyToAccount(sourceKey), + transport: http(info.defaultRPC[0]), + }); + if ( + (await bsc.getBalance({ address: master.account.address })) < + parseEther('0.01') + ) { + const gas = await f.owner.universal.sendTransaction({ + to: { address: master.account.address, chain: CHAIN.BNB_TESTNET }, + value: parseEther('0.01'), + }); + await gas.wait(); + f.evidence('marketplace-funding-bsc-gas', { + txHash: gas.hash, + amount: parseEther('0.01'), + }); + } + const key = generatePrivateKey(); + const address = privateKeyToAccount(key).address; + const gasHash = await master.sendTransaction({ + to: address, + value: parseEther('0.005'), + chain: null, + }); + await bsc.waitForTransactionReceipt({ hash: gasHash }); + const tokenHash = await master.writeContract({ + address: PushChain.CONSTANTS.MOVEABLE.TOKEN.BNB_TESTNET.USDT + .address as Address, + abi: erc20Abi, + functionName: 'transfer', + args: [address, BigInt(10000)], + chain: null, + }); + await bsc.waitForTransactionReceipt({ hash: tokenHash }); + const external = await evmClient( + key, + CHAIN.BNB_TESTNET, + f.manifest.network + ); + const uea = external.universal.account as Address; + const deploy = await f.owner.universal.sendTransaction({ + to: '0x00000000000000000000000000000000000000eA', + data: encodeFunctionData({ + abi: parseAbi([ + 'function deployUEA((string chainNamespace,string chainId,bytes owner) id) returns (address)', + ]), + functionName: 'deployUEA', + args: [{ chainNamespace: 'eip155', chainId: '97', owner: address }], + }), + }); + await deploy.wait(); + await f.fundPC(uea, parseEther('0.2')); + const now = Number((await f.push.getBlock()).timestamp); + const plan = await external.market.preview(cardId, { + principal: BigInt(10000), + executeBy: now + 600, + expiredAt: now + 7200, + }); + const opened = await external.market.openJob(plan); + jobs.push({ jobId: opened.jobId, market: true, client: external }); + wallets.push({ key, origin: CHAIN.BNB_TESTNET, wallet: opened.wallet }); + await sendToken(f.owner, D.paymentToken, opened.wallet, budget); + const deposited = await external.market.deposit(opened.jobId, { + funding: 'origin', + pcAmount: BigInt(0), + }); + expect(await balance(principalToken, opened.wallet)).toBe(BigInt(10000)); + expect(deposited.txs.map((t) => t.step)).toEqual(['PRINCIPAL']); + await (await external.job.cancel(opened.jobId)).wait(); + f.evidence('marketplace-funding-origin', { + cardId, + jobId: opened.jobId, + wallet: opened.wallet, + uea, + depositHashes: deposited.txs.map((t) => t.tx.hash), + principal: BigInt(10000), + origin: CHAIN.BNB_TESTNET, + }); + }, 900000); +}); diff --git a/packages/core/__e2e__/marketplace/lifecycle.spec.ts b/packages/core/__e2e__/marketplace/lifecycle.spec.ts new file mode 100644 index 000000000..e2144172c --- /dev/null +++ b/packages/core/__e2e__/marketplace/lifecycle.spec.ts @@ -0,0 +1,276 @@ +/** Opt-in funded Donut smoke. Only PC gas/fresh provider funding moves; principal and escrow stay zero. */ +import { encodeFunctionData, parseAbi, parseEther, type Address } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { PushChain } from '../../src'; +import { CHAIN } from '../../src/lib/constants/enums'; +import { setupAgw, evmClient, type AgwFixture } from '../agw/_fixture'; +import { DONUT_MARKETPLACE as D } from '../../src/lib/market/deployment'; +const d = + process.env['MARKETPLACE_LIVE_E2E'] === '1' ? describe : describe.skip; +d('marketplace live', () => { + let f: AgwFixture; + let provider: PushChain; + let owner: PushChain; + let externalOwner: PushChain | undefined; + let externalOpened: { jobId: bigint; wallet: Address } | undefined; + let cardId: bigint | undefined; + let opened: + | { jobId: bigint; wallet: Address; rulesId: `0x${string}` } + | undefined; + beforeAll(async () => { + f = await setupAgw(); + provider = await evmClient( + f.agentKey, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network + ); + const ownerKey = generatePrivateKey(); + owner = await evmClient( + ownerKey, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network + ); + await f.fundPC(owner.universal.account as Address, parseEther('0.1')); + }, 300000); + afterAll(async () => { + // Best-effort cleanup after failures, restricted to this run's own fresh card and wallet. + if (opened && (await owner.job.status(opened.jobId)).status === 'Open') + await (await owner.job.cancel(opened.jobId)).wait(); + if ( + externalOwner && + externalOpened && + (await externalOwner.job.status(externalOpened.jobId)).status === 'Open' + ) + await (await externalOwner.job.cancel(externalOpened.jobId)).wait(); + if (cardId && (await provider.market.card(cardId)).active) + await (await provider.market.agent.setActive(cardId, false)).wait(); + f?.teardown(); + }); + it('1. native provider registration, reviewed open and atomic cancel/revoke on BSC Testnet', async () => { + const target = PushChain.CONSTANTS.MOVEABLE.TOKEN.BNB_TESTNET.USDT + .address as Address; + const read = PushChain.utils.job.prepareRead({ + chain: CHAIN.BNB_TESTNET, + target, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: [PushChain.CONSTANTS.AGENTIC.WALLET], + }); + const template = PushChain.utils.job.buildJobSpecTemplate({ + reads: [read], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: BigInt(0), + }, + ], + logic: 'ALL', + }); + const registered = await provider.market.agent.register( + { + jobType: 'SDK_MARKETPLACE_SMOKE', + metadataURI: 'https://example.invalid/push-sdk-test', + metadataHash: ('0x' + '34'.repeat(32)) as `0x${string}`, + chain: CHAIN.BNB_TESTNET, + providerFee: BigInt(0), + paymentToken: D.paymentToken, + principalMin: BigInt(1), + principalMax: BigInt(1), + minDuration: 3600, + maxDuration: 86400, + minExecuteWindow: 0, + settleWindow: 60, + }, + { + rules: { + assets: [ + { + token: { prc20: '0x731aF1Da5365259d27528557EE4aFBA4baC90ef2' }, + maxPerCall: PushChain.CONSTANTS.AGENTIC.PRINCIPAL, + maxTotal: PushChain.CONSTANTS.AGENTIC.PRINCIPAL, + }, + ], + principalAsset: 0, + maxGasPerCall: BigInt(0), + allowedCalls: [ + { + target, + selector: 'balanceOf(address)', + pins: [{ arg: 0, expected: PushChain.CONSTANTS.AGENTIC.WALLET }], + }, + ], + }, + jobSpecTemplate: template, + } + ); + cardId = registered.cardId; + f.evidence('marketplace-register', { cardId, txHash: registered.tx.hash }); + const now = Number((await f.push.getBlock()).timestamp); + const plan = await owner.market.preview(cardId, { + principal: BigInt(1), + executeBy: now + 600, + expiredAt: now + 7200, + label: 'marketplace-e2e', + }); + const result = await owner.market.openJob(plan); + opened = result; + f.evidence('marketplace-open', { + jobId: result.jobId, + wallet: result.wallet, + rulesId: result.rulesId, + txHash: result.tx.hash, + }); + const status = await owner.job.status(opened.jobId); + expect(status.status).toBe('Open'); + expect(status.description).toBe(plan.jobSpec.bytes); + const cancelled = await owner.job.cancel(opened.jobId); + await cancelled.wait(); + expect((await owner.job.status(opened.jobId)).status).toBe('Rejected'); + await expect( + owner.agentic.wallet(opened.wallet).rules.get(opened.rulesId) + ).rejects.toMatchObject({ code: 'RULE_NOT_FOUND' }); + await (await provider.market.agent.setActive(cardId, false)).wait(); + expect((await provider.market.card(cardId)).active).toBe(false); + f.evidence('marketplace-cancel-cleanup', { + cardId, + wallet: opened.wallet, + jobId: opened.jobId, + txHash: cancelled.hash, + }); + }); + it('2. paid JobSpec preview reaches live validator consensus through the registry', async () => { + const spec = PushChain.utils.job.buildJobSpec({ + reads: [ + PushChain.utils.job.prepareRead({ + chain: CHAIN.BNB_TESTNET, + target: PushChain.CONSTANTS.MOVEABLE.TOKEN.BNB_TESTNET.USDT + .address as Address, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: [owner.universal.account], + }), + ], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: BigInt(0), + }, + ], + logic: 'ALL', + executeBy: Math.floor(Date.now() / 1000) + 600, + failFinalAt: Math.floor(Date.now() / 1000) + 660, + }); + const result = await PushChain.utils.job.previewJobSpec(spec, { + client: owner, + maxTotalFee: parseEther('0.01'), + advanced: { timeout: 120000, pollingIntervalMs: 1000 }, + }); + f.evidence('marketplace-paid-preview', { + pass: result.pass, + fees: result.fees, + reads: result.reads.map((r) => ({ + decoded: r.decoded, + error: r.error, + requestId: r.response.requestId, + txHash: r.response.txHash, + outcome: r.response.outcome, + })), + }); + expect(result.pass).toBe(true); + expect(result.reads[0].decoded).not.toBeNull(); + const resumed = await PushChain.utils.job.previewJobSpec(spec, { + client: owner, + resume: result.reads.map((r) => r.response.requestId), + maxTotalFee: BigInt(0), + advanced: { timeout: 120000 }, + }); + expect(resumed.pass).toBe(true); + expect(resumed.fees.newlyPaid).toBe(BigInt(0)); + const authored = PushChain.utils.job.prepareRead({ + chain: CHAIN.BNB_TESTNET, + target: spec.reads[0].target, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: [owner.universal.account], + }); + const direct = await owner.universal.read(authored, { + maxFee: parseEther('0.01'), + advanced: { timeout: 120000 }, + }); + const sameCall = await PushChain.utils.job.previewJobSpec(spec, { + client: owner, + resume: [direct.requestId], + maxTotalFee: BigInt(0), + }); + expect(sameCall.pass).toBe(true); + expect(sameCall.fees.newlyPaid).toBe(BigInt(0)); + f.evidence('marketplace-preview-recovery', { + originalRequestId: result.reads[0].response.requestId, + preparedReadRequestId: direct.requestId, + additionalPaymentOnResume: resumed.fees.newlyPaid, + }); + }); + it('3. external EVM owner opens and cancels through its real Push UEA transport', async () => { + if (!cardId) throw new Error('The fresh test card was not registered'); + const key = generatePrivateKey(); + externalOwner = await evmClient( + key, + CHAIN.ETHEREUM_SEPOLIA, + f.manifest.network + ); + const uea = externalOwner.universal.account as Address; + const deployed = await owner.universal.sendTransaction({ + to: '0x00000000000000000000000000000000000000eA', + data: encodeFunctionData({ + abi: parseAbi([ + 'function deployUEA((string chainNamespace,string chainId,bytes owner) id) returns (address)', + ]), + functionName: 'deployUEA', + args: [ + { + chainNamespace: 'eip155', + chainId: '11155111', + owner: privateKeyToAccount(key).address, + }, + ], + }), + }); + await deployed.wait(); + await f.fundPC(uea, parseEther('0.1')); + await (await provider.market.agent.setActive(cardId, true)).wait(); + const now = Number((await f.push.getBlock()).timestamp); + const plan = await externalOwner.market.preview(cardId, { + principal: BigInt(1), + executeBy: now + 600, + expiredAt: now + 7200, + }); + const openedResult = await externalOwner.market.openJob(plan); + externalOpened = openedResult; + const recovered = await externalOwner.market.openJob(plan, { + resume: openedResult.tx, + }); + expect(recovered.jobId).toBe(externalOpened.jobId); + f.evidence('marketplace-external-open', { + jobId: externalOpened.jobId, + wallet: externalOpened.wallet, + uea, + signerChainId: plan.intent.signerChainId, + txHash: openedResult.tx.hash, + }); + const cancelled = await externalOwner.job.cancel(externalOpened.jobId); + await cancelled.wait(); + expect((await externalOwner.job.status(externalOpened.jobId)).status).toBe( + 'Rejected' + ); + await (await provider.market.agent.setActive(cardId, false)).wait(); + f.evidence('marketplace-external-cleanup', { + jobId: externalOpened.jobId, + cardId, + txHash: cancelled.hash, + }); + }); +}); diff --git a/packages/core/__e2e__/marketplace/readiness.spec.ts b/packages/core/__e2e__/marketplace/readiness.spec.ts new file mode 100644 index 000000000..8876d34be --- /dev/null +++ b/packages/core/__e2e__/marketplace/readiness.spec.ts @@ -0,0 +1,55 @@ +/** Unfunded deployment acceptance. No keys, signatures or transaction broadcasts. */ +import { PushChain } from '../../src'; +import { CHAIN, PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { createPublicClient, http } from 'viem'; +import { DONUT_MARKETPLACE as D } from '../../src/lib/market/deployment'; +import { MARKETPLACE_ABI } from '../../src/lib/market/contracts/UniversalMarketplace'; +import { JOB_KERNEL_ABI } from '../../src/lib/market/contracts/AgenticCommerce'; +describe('marketplace deployed v2 read-only', () => { + let sdk: PushChain; + beforeAll(async () => { + sdk = await PushChain.initialize( + { + chain: CHAIN.PUSH_TESTNET_DONUT, + address: '0x1111111111111111111111111111111111111111', + }, + { network: PUSH_NETWORK.TESTNET_DONUT } + ); + }); + it('enumerates registry and jobs through public SDK bindings and confirms the deployment/token', async () => { + const rpc = createPublicClient({ + transport: http('https://evm.donut.rpc.push.org/'), + }); + const [cards, jobs, version, count, token] = await Promise.all([ + sdk.market.cards(), + sdk.job.list(), + rpc.readContract({ + address: D.marketplace, + abi: MARKETPLACE_ABI, + functionName: 'version', + }), + rpc.readContract({ + address: D.marketplace, + abi: MARKETPLACE_ABI, + functionName: 'agentCardCount', + }), + rpc.readContract({ + address: D.kernel, + abi: JOB_KERNEL_ABI, + functionName: 'paymentToken', + }), + ]); + expect(version).toBe('2.0.0'); + expect(token.toLowerCase()).toBe(D.paymentToken.toLowerCase()); + expect(BigInt(cards.cards.length)).toBe(count); + expect(Array.isArray(jobs.jobs)).toBe(true); + }); + it('returns explicit placeholders for unavailable verification and deposit orchestration', async () => { + await expect(sdk.job.verify(BigInt(1))).rejects.toMatchObject({ + code: 'CAPABILITY_UNAVAILABLE', + }); + await expect(sdk.market.deposit(BigInt(1))).rejects.toMatchObject({ + code: 'CAPABILITY_UNAVAILABLE', + }); + }); +}); diff --git a/packages/core/__market-local__/marketplace.spec.ts b/packages/core/__market-local__/marketplace.spec.ts new file mode 100644 index 000000000..faf01a67b --- /dev/null +++ b/packages/core/__market-local__/marketplace.spec.ts @@ -0,0 +1,846 @@ +/** Public SDK → real deployed v2/AGW v6 on an isolated Anvil Donut fork. No keys or writes to public RPC. */ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { createServer } from 'node:net'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + createPublicClient, + createWalletClient, + defineChain, + encodeFunctionData, + erc20Abi, + getAddress, + bytesToHex, + hexToBytes, + parseTransaction, + keccak256, + http, + parseAbi, + stringToHex, + zeroAddress, + zeroHash, + type Address, + type PublicClient, + type Hex, +} from 'viem'; +import { mnemonicToAccount } from 'viem/accounts'; +import { PushChain, type UniversalSigner } from '../src'; +import { CHAIN, PUSH_NETWORK, LIBRARY } from '../src/lib/constants/enums'; +import { DONUT_MARKETPLACE as D } from '../src/lib/market/deployment'; +import { JOB_KERNEL_ABI } from '../src/lib/market/contracts/AgenticCommerce'; +import { currentGeneration } from '../src/lib/agentic/deployments'; +import type { AgentCardRules } from '../src/lib/market/rules'; +import type { JobSpecTemplate } from '../src/lib/job-spec/types'; +import { MarketNamespaceImpl } from '../src/lib/market/market'; +import type { StackRuntime } from '../src/lib/market/runtime'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; + +let node: ChildProcess; +let rpc: string; +let push: PublicClient; +let owner: PushChain; +let provider: PushChain; +let cardId: bigint; +let opened: { jobId: bigint; wallet: Address; rulesId: `0x${string}` }; +let template: JobSpecTemplate; +let rules: AgentCardRules; +let stopWatch: (() => void) | undefined; +const accounts = [0, 1, 2].map((addressIndex) => + mnemonicToAccount( + 'test test test test test test test test test test test junk', + { addressIndex } + ) +); +const token = '0x731aF1Da5365259d27528557EE4aFBA4baC90ef2'; +const tokenOnSepolia = '0xE935d9c9C24D02E61186c640cc01d713C876d40F'; +beforeAll(async () => { + execFileSync( + 'bash', + [join(__dirname, '../scripts/marketplace-local/prepare.sh')], + { stdio: 'pipe' } + ); + const port = await new Promise((resolve) => { + const server = createServer(); + server.listen(0, '127.0.0.1', () => { + const p = (server.address() as { port: number }).port; + server.close(() => resolve(p)); + }); + }); + rpc = `http://127.0.0.1:${port}`; + node = spawn( + 'anvil', + [ + '--host', + '127.0.0.1', + '--port', + String(port), + '--chain-id', + '42101', + '--fork-url', + process.env['MARKET_FORK_RPC'] ?? 'https://evm.donut.rpc.push.org/', + '--fork-block-number', + '24447347', + '--timeout', + '120000', + '--retries', + '2', + '--silent', + '--order', + 'fifo', + ], + { stdio: ['ignore', 'ignore', 'pipe'] } + ); + let logs = ''; + node.stderr?.on('data', (c) => { + logs += String(c); + }); + const chain = defineChain({ + id: 42101, + name: 'Local marketplace fork', + nativeCurrency: { name: 'PC', symbol: 'PC', decimals: 18 }, + rpcUrls: { default: { http: [rpc] } }, + }); + push = createPublicClient({ + chain, + transport: http(rpc, { timeout: 120000, retryCount: 0 }), + pollingInterval: 25, + }) as PublicClient; + let ready = false; + for (let i = 0; i < 150; i++) { + if (node.exitCode !== null) throw new Error(`Anvil stopped: ${logs}`); + try { + await push.getBlockNumber(); + ready = true; + break; + } catch { + await new Promise((r) => setTimeout(r, 200)); + } + } + if (!ready) throw new Error(`Anvil did not start: ${logs}`); + await push.request({ + method: 'evm_setNextBlockTimestamp' as never, + params: [Math.floor(Date.now() / 1000)] as never, + }); + const clients = await Promise.all( + accounts.map(async (account) => { + const wallet = createWalletClient({ + account, + chain, + transport: http(rpc, { timeout: 120000 }), + pollingInterval: 25, + }); + const signer = await PushChain.utils.signer.toUniversalFromKeypair( + wallet, + { chain: CHAIN.PUSH_TESTNET_DONUT, library: LIBRARY.ETHEREUM_VIEM } + ); + return PushChain.initialize(signer, { + network: PUSH_NETWORK.TESTNET_DONUT, + rpcUrls: { [CHAIN.PUSH_TESTNET_DONUT]: [rpc] }, + }); + }) + ); + [owner, provider] = clients; + const read = PushChain.utils.job.prepareRead({ + chain: CHAIN.BNB_TESTNET, + target: tokenOnSepolia, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: ['agentic:wallet'], + }); + template = PushChain.utils.job.buildJobSpecTemplate({ + reads: [read], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: BigInt(0), + }, + ], + logic: 'ALL', + }); + rules = { + assets: [ + { + token: { prc20: token }, + maxPerCall: PushChain.CONSTANTS.AGENTIC.PRINCIPAL, + maxTotal: PushChain.CONSTANTS.AGENTIC.PRINCIPAL, + }, + ], + principalAsset: 0, + maxGasPerCall: BigInt(0), + allowedCalls: [ + { + target: tokenOnSepolia, + selector: 'balanceOf(address)', + pins: [{ arg: 0, expected: PushChain.CONSTANTS.AGENTIC.WALLET }], + }, + ], + }; +}, 180000); +afterAll(async () => { + stopWatch?.(); + node?.kill('SIGTERM'); +}); + +it('registers and discovers a real v2 provider card through the public namespace', async () => { + const response = await provider.market.agent.register( + { + jobType: 'SDK_FORK_TEST', + metadataURI: 'https://example.invalid/sdk-marketplace-test', + metadataHash: ('0x' + '12'.repeat(32)) as `0x${string}`, + chain: CHAIN.BNB_TESTNET, + providerFee: BigInt(5000000), + paymentToken: D.paymentToken, + principalMin: BigInt(1), + principalMax: BigInt(100), + minDuration: 3600, + maxDuration: 90 * 86400, + minExecuteWindow: 0, + settleWindow: 60, + }, + { rules, jobSpecTemplate: template } + ); + cardId = response.cardId; + expect(response.version).toBe(BigInt(1)); + expect(cardId).toBeGreaterThan(BigInt(0)); + const card = await owner.market.card(cardId); + expect(card.provider).toBe(accounts[1].address); + expect(card.jobSpecTemplate.allowedMutability).toBe('NONE'); + expect( + (await owner.market.cards({ provider: accounts[1].address })).cards[0] + .cardId + ).toBe(cardId); + expect((await provider.market.agent.card())?.cardId).toBe(cardId); +}); +it('previews and signs the exact on-chain JobSpec, opens on real AGW v6, and preserves binary bytes', async () => { + const now = Number((await push.getBlock()).timestamp); + const plan = await owner.market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + const response = await owner.market.openJob(plan); + opened = response; + expect((await owner.market.activeJob(opened.wallet)).jobId).toBe( + opened.jobId + ); + const status = await owner.job.status(opened.jobId); + expect(status.description).toBe(plan.jobSpec.bytes); + expect(status.jobSpec?.spec.origin).toBe(plan.jobSpec.spec.origin); + expect(status.evaluation.available).toBe(false); +}); +it('updates a decoded card without losing rule constraints or template wire bytes', async () => { + const before = await provider.market.card(cardId); + const updated = await provider.market.agent.update( + cardId, + { ...before, metadataURI: 'https://example.invalid/updated-sdk-test' }, + { rules: before.rules, jobSpecTemplate: before.jobSpecTemplate } + ); + expect(updated.version).toBe(BigInt(2)); + const after = await owner.market.card(cardId); + expect(after.rulesBytes).toBe(before.rulesBytes); + expect(after.jobSpecTemplateBytes).toBe(before.jobSpecTemplateBytes); + expect(after.verified).toBe(false); +}); +it('deposits original principal, fee and PC after a card update and recovers history in a fresh client', async () => { + await (await provider.job.setBudget(opened.jobId, BigInt(5000000))).wait(); + await expect( + owner.job.fund(opened.jobId, { expectedBudget: BigInt(5000000) }) + ).rejects.toMatchObject({ code: 'INSUFFICIENT_FUNDS' }); + // These are real existing assets borrowed on localhost, not public-chain transfers. + const source = '0x0A16CBa65FfCAa4C2282b27b027Ab4A2fE46E0Bf'; + await push.request({ + method: 'anvil_impersonateAccount' as never, + params: [source] as never, + }); + const funder = createWalletClient({ + account: source, + chain: push.chain, + transport: http(rpc), + }); + for (const [asset, amount] of [ + [token, BigInt(10)], + [D.paymentToken, BigInt(5000000)], + ] as const) { + const hash = await funder.writeContract({ + chain: push.chain, + address: asset, + abi: erc20Abi, + functionName: 'transfer', + args: [accounts[0].address, amount], + }); + await push.waitForTransactionReceipt({ hash }); + } + const deposited = await owner.market.deposit(opened.jobId, { + pcAmount: BigInt(10) ** BigInt(15), + }); + expect(deposited.cardVersion).toBe(BigInt(1)); + expect(deposited.txs.map((t) => t.step)).toEqual([ + 'PRINCIPAL', + 'PROVIDER_FEE', + 'PC', + ]); + expect((await owner.job.status(opened.jobId)).status).toBe('Open'); + expect( + await push.readContract({ + address: D.paymentToken, + abi: erc20Abi, + functionName: 'allowance', + args: [opened.wallet, D.kernel], + }) + ).toBe(BigInt(0)); + const wallet = createWalletClient({ + account: accounts[0], + chain: push.chain, + transport: http(rpc), + }); + const signer = await PushChain.utils.signer.toUniversalFromKeypair(wallet, { + chain: CHAIN.PUSH_TESTNET_DONUT, + library: LIBRARY.ETHEREUM_VIEM, + }); + const reconnected = await PushChain.initialize(signer, { + network: PUSH_NETWORK.TESTNET_DONUT, + rpcUrls: { [CHAIN.PUSH_TESTNET_DONUT]: [rpc] }, + }); + const recovered = await reconnected.market.deposit(opened.jobId, { + pcAmount: BigInt(10) ** BigInt(15), + }); + expect(recovered.cardVersion).toBe(BigInt(1)); + expect(recovered.txs).toEqual([]); +}); +it('funds through the real rules hook and submits as the provider; manual evaluator completes locally', async () => { + const events: string[] = []; + stopWatch = owner.job.watch(opened.jobId, (e) => events.push(e.kind), { + fromBlock: (await push.getBlockNumber({ cacheTime: 0 })) + BigInt(1), + pollIntervalMs: 25, + }); + await (await provider.job.setBudget(opened.jobId, BigInt(6000000))).wait(); + await expect(owner.job.fund(opened.jobId)).rejects.toMatchObject({ + code: 'EXPECTED_BUDGET_REQUIRED', + }); + await expect( + owner.job.fund(opened.jobId, { expectedBudget: BigInt(5000000) }) + ).rejects.toMatchObject({ code: 'BUDGET_MISMATCH' }); + await (await provider.job.setBudget(opened.jobId, BigInt(5000000))).wait(); + const before = await push.readContract({ + address: D.paymentToken, + abi: erc20Abi, + functionName: 'balanceOf', + args: [accounts[1].address], + }); + await ( + await owner.job.fund(opened.jobId, { expectedBudget: BigInt(5000000) }) + ).wait(); + expect((await owner.job.status(opened.jobId)).status).toBe('Funded'); + await ( + await provider.job.submit(opened.jobId, { deliverable: 'fork-only proof' }) + ).wait(); + const submitted = await owner.job.status(opened.jobId); + expect(submitted.status).toBe('Submitted'); + expect(submitted.deliverable).toBe(keccak256(stringToHex('fork-only proof'))); + await push.request({ + method: 'anvil_impersonateAccount' as never, + params: [D.evaluator] as never, + }); + await push.request({ + method: 'anvil_setBalance' as never, + params: [D.evaluator, '0x8ac7230489e80000'] as never, + }); + const evaluator = createWalletClient({ + account: D.evaluator, + chain: push.chain, + transport: http(rpc), + }); + const hash = await evaluator.sendTransaction({ + chain: push.chain, + to: D.kernel, + data: encodeFunctionData({ + abi: JOB_KERNEL_ABI, + functionName: 'complete', + args: [opened.jobId, zeroHash, '0x'], + }), + }); + await push.waitForTransactionReceipt({ hash }); + expect((await owner.job.status(opened.jobId)).status).toBe('Completed'); + const after = await push.readContract({ + address: D.paymentToken, + abi: erc20Abi, + functionName: 'balanceOf', + args: [accounts[1].address], + }); + expect(after - before).toBe(BigInt(5000000)); + await new Promise((resolve) => setTimeout(resolve, 300)); + stopWatch(); + stopWatch = undefined; + expect(events).toEqual( + expect.arrayContaining(['BudgetSet', 'Funded', 'Submitted', 'Completed']) + ); + expect(events).not.toContain('Error'); + await expect(owner.job.verify(opened.jobId)).rejects.toMatchObject({ + code: 'CAPABILITY_UNAVAILABLE', + }); +}); +it('creates a plain job and allows third-party refund while the kernel is paused', async () => { + const expiredAt = Number((await push.getBlock()).timestamp) + 600; + const plain = await owner.job.create({ + provider: accounts[1].address, + evaluator: D.evaluator, + hook: zeroAddress, + expiredAt, + paymentToken: D.paymentToken, + description: 'plain SDK fork job', + }); + await ( + await owner.job.fund(plain.jobId, { expectedBudget: BigInt(0) }) + ).wait(); + await push.request({ + method: 'anvil_impersonateAccount' as never, + params: [D.evaluator] as never, + }); + await push.request({ + method: 'anvil_setBalance' as never, + params: [D.evaluator, '0x8ac7230489e80000'] as never, + }); + const admin = createWalletClient({ + account: D.evaluator, + chain: push.chain, + transport: http(rpc), + }); + const pauseHash = await admin.writeContract({ + chain: push.chain, + address: D.kernel, + abi: JOB_KERNEL_ABI, + functionName: 'pause', + }); + await push.waitForTransactionReceipt({ hash: pauseHash }); + await push.request({ + method: 'evm_setNextBlockTimestamp' as never, + params: [expiredAt + 1] as never, + }); + await push.request({ method: 'evm_mine' as never, params: [] as never }); + await (await provider.job.claimRefund(plain.jobId)).wait(); + expect((await owner.job.status(plain.jobId)).status).toBe('Expired'); + const unpauseHash = await admin.writeContract({ + chain: push.chain, + address: D.kernel, + abi: JOB_KERNEL_ABI, + functionName: 'unpause', + }); + await push.waitForTransactionReceipt({ hash: unpauseHash }); + expect( + (await owner.job.list({ provider: accounts[1].address })).jobs.some( + (j) => j.jobId === plain.jobId + ) + ).toBe(true); +}); +it('rejects a tampered plan before signing and atomically cancels a second job with permission revocation', async () => { + const now = Number((await push.getBlock()).timestamp); + const plan = await owner.market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + await expect( + owner.market.openJob({ + ...plan, + preview: { ...plan.preview, mayDo: ['tampered'] }, + }) + ).rejects.toMatchObject({ code: 'STALE_PLAN' }); + const badSignature = jest + .spyOn(owner.universal, 'signTypedData') + .mockImplementationOnce(async (data) => accounts[1].signTypedData(data)); + await expect(owner.market.openJob(plan)).rejects.toMatchObject({ + code: 'OPEN_JOB_SIMULATION_FAILED', + }); + badSignature.mockRestore(); + const second = await owner.market.openJob(plan); + await (await owner.job.cancel(second.jobId)).wait(); + expect((await owner.job.status(second.jobId)).status).toBe('Rejected'); + expect((await owner.market.activeJob(second.wallet)).jobId).toBe(BigInt(0)); + const gen = currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + expect( + await push.readContract({ + address: gen.addresses.sessionEngine, + abi: gen.contracts.abis.engine, + functionName: 'isPermissionEnabled', + args: [second.rulesId, second.wallet], + }) + ).toBe(false); +}); +it('accepts a real deployed EVM UEA owner signature with its home-chain domain', async () => { + const external = await PushChain.initialize( + { chain: CHAIN.ETHEREUM_SEPOLIA, address: accounts[0].address }, + { + network: PUSH_NETWORK.TESTNET_DONUT, + rpcUrls: { [CHAIN.PUSH_TESTNET_DONUT]: [rpc] }, + } + ); + const uea = getAddress(external.universal.account); + const base = (owner as unknown as { agenticRuntime: AgenticRuntime }) + .agenticRuntime; + const rt: StackRuntime = { + agw: { + ...base, + signerPushAccount: () => uea, + signerOrigin: () => ({ + chain: CHAIN.ETHEREUM_SEPOLIA, + address: accounts[0].address, + }), + signerIsPushNative: () => false, + }, + account: () => uea, + wallet: () => undefined, + send: base.execute, + // Relaying is permitted on-chain. Anvil has no Cosmos MsgExecutePayload transport; + // only the submitting transport is native here. The UEA verifies the real origin signature. + signTypedData: (data) => accounts[0].signTypedData(data), + canSignTypedData: () => true, + jobBlocks: new Map(), + }; + const market = new MarketNamespaceImpl(rt); + const now = Number((await push.getBlock()).timestamp); + const plan = await market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + await expect(market.openJob(plan)).rejects.toMatchObject({ + code: 'OWNER_NOT_DEPLOYED', + }); + const deploy = await owner.universal.sendTransaction({ + to: '0x00000000000000000000000000000000000000eA', + data: encodeFunctionData({ + abi: parseAbi([ + 'function deployUEA((string chainNamespace,string chainId,bytes owner) id) returns (address)', + ]), + functionName: 'deployUEA', + args: [ + { + chainNamespace: 'eip155', + chainId: '11155111', + owner: accounts[0].address, + }, + ], + }), + }); + await deploy.wait(); + expect(await push.getCode({ address: uea })).not.toBe('0x'); + const reviewed = await market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + expect(reviewed.intent.owner).toBe(uea); + expect(reviewed.intent.signerChainId).toBe(BigInt(11155111)); + const result = await market.openJob(reviewed); + const gen = currentGeneration(PUSH_NETWORK.TESTNET_DONUT); + expect( + await push.readContract({ + address: result.wallet, + abi: gen.contracts.abis.wallet, + functionName: 'owner', + }) + ).toBe(uea); + expect((await owner.job.status(result.jobId)).status).toBe('Open'); +}); + +function ownerArtifact(name: string) { + const base = + process.env['MARKETPLACE_LOCAL_DIR'] ?? + join(tmpdir(), 'push-marketplace-local'); + return JSON.parse( + readFileSync( + join(base, 'out', 'MarketplaceOwnerFixtures.sol', `${name}.json`), + 'utf8' + ) + ); +} +it('uses a real ERC-1271 owner adapter, rejects malformed success values, and recovers without a second signature', async () => { + const artifact = ownerArtifact('Marketplace1271Owner'); + const controller = createWalletClient({ + account: accounts[0], + chain: push.chain, + transport: http(rpc), + }); + const hash = await controller.deployContract({ + chain: push.chain, + abi: artifact.abi, + bytecode: artifact.bytecode.object, + args: [accounts[0].address], + }); + const contract = (await push.waitForTransactionReceipt({ hash })) + .contractAddress as Address; + await ( + await owner.universal.sendTransaction({ + to: contract, + value: BigInt(10) ** BigInt(18), + }) + ).wait(); + const signTypedData = jest.fn( + async (data: Parameters<(typeof accounts)[0]['signTypedData']>[0]) => + hexToBytes(await accounts[0].signTypedData(data)) + ); + const send = jest.fn(async (unsigned: Uint8Array) => { + const tx = parseTransaction(bytesToHex(unsigned)); + if (!tx.to) + throw new Error( + 'The contract-owner adapter requires a transaction target' + ); + const txHash = await controller.writeContract({ + address: contract, + chain: push.chain, + abi: artifact.abi, + functionName: 'execute', + args: [tx.to, tx.value ?? BigInt(0), tx.data ?? '0x'], + gas: BigInt(3000000), + }); + await push.request({ method: 'anvil_mine' as never, params: ['0x4'] as never }); + return hexToBytes(txHash); + }); + const signer: UniversalSigner = { + account: { chain: CHAIN.PUSH_TESTNET_DONUT, address: contract }, + signMessage: async (data) => + hexToBytes(await accounts[0].signMessage({ message: { raw: data } })), + signTypedData, + signAndSendTransaction: send, + }; + const client = await PushChain.initialize(signer, { + network: PUSH_NETWORK.TESTNET_DONUT, + rpcUrls: { [CHAIN.PUSH_TESTNET_DONUT]: [rpc] }, + }); + const now = Number((await push.getBlock()).timestamp); + const plan = await client.market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + for (const mode of [1, 2, 3]) { + const tx = await controller.writeContract({ + address: contract, + chain: push.chain, + abi: artifact.abi, + functionName: 'setResponseMode', + args: [mode], + }); + await push.waitForTransactionReceipt({ hash: tx }); + await expect(client.market.openJob(plan)).rejects.toMatchObject({ + code: 'OPEN_JOB_SIMULATION_FAILED', + }); + } + expect(send).not.toHaveBeenCalled(); + const reset = await controller.writeContract({ + address: contract, + chain: push.chain, + abi: artifact.abi, + functionName: 'setResponseMode', + args: [0], + }); + await push.waitForTransactionReceipt({ hash: reset }); + const opened = await client.market.openJob(plan); + const signatures = signTypedData.mock.calls.length; + const recovered = await client.market.openJob(plan, { resume: opened.tx }); + expect(recovered.jobId).toBe(opened.jobId); + expect(signTypedData.mock.calls).toHaveLength(signatures); + expect(send).toHaveBeenCalledTimes(1); + await (await client.job.cancel(opened.jobId)).wait(); + expect((await client.job.status(opened.jobId)).status).toBe('Rejected'); +}); +it('accepts a compatible EIP-7702 verifier and refuses an echo fallback before broadcast', async () => { + const controller = createWalletClient({ + account: accounts[2], + chain: push.chain, + transport: http(rpc), + }); + const artifact = ownerArtifact('Marketplace1271Owner'); + const deployed = await controller.deployContract({ + chain: push.chain, + abi: artifact.abi, + bytecode: artifact.bytecode.object, + args: [accounts[2].address], + }); + const delegate = (await push.waitForTransactionReceipt({ hash: deployed })) + .contractAddress as Address; + await push.request({ + method: 'anvil_setCode' as never, + params: [ + accounts[2].address, + ('0xef0100' + delegate.slice(2)) as Hex, + ] as never, + }); + const signer = await PushChain.utils.signer.toUniversalFromKeypair( + controller, + { chain: CHAIN.PUSH_TESTNET_DONUT, library: LIBRARY.ETHEREUM_VIEM } + ); + const client = await PushChain.initialize(signer, { + network: PUSH_NETWORK.TESTNET_DONUT, + rpcUrls: { [CHAIN.PUSH_TESTNET_DONUT]: [rpc] }, + }); + const now = Number((await push.getBlock()).timestamp); + const plan = await client.market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + const result = await client.market.openJob(plan); + await (await client.job.cancel(result.jobId)).wait(); + const echoArtifact = ownerArtifact('MarketplaceEchoOwner'); + const echoHash = await controller.deployContract({ + chain: push.chain, + abi: echoArtifact.abi, + bytecode: echoArtifact.bytecode.object, + args: [], + }); + const echo = (await push.waitForTransactionReceipt({ hash: echoHash })) + .contractAddress as Address; + await push.request({ + method: 'anvil_setCode' as never, + params: [accounts[2].address, ('0xef0100' + echo.slice(2)) as Hex] as never, + }); + const next = await client.market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + await expect(client.market.openJob(next)).rejects.toMatchObject({ + code: 'OPEN_JOB_SIMULATION_FAILED', + }); +}); +it('recovers the original card and opening when a provider updates in the same mined block', async () => { + const before = await provider.market.card(cardId); + const now = Number((await push.getBlock()).timestamp); + const plan = await owner.market.preview(cardId, { + principal: BigInt(10), + executeBy: now + 600, + expiredAt: now + 7200, + }); + let signature: Hex | undefined; + const sign = owner.universal.signTypedData.bind(owner.universal); + const capture = jest + .spyOn(owner.universal, 'signTypedData') + .mockImplementationOnce(async (data) => { + signature = (await sign(data)) as Hex; + throw new Error('Captured locally before broadcast'); + }); + await expect(owner.market.openJob(plan)).rejects.toThrow( + 'Captured locally before broadcast' + ); + capture.mockRestore(); + if (!signature) throw new Error('No owner signature captured'); + const openData = encodeFunctionData({ + abi: (await import('../src/lib/market/contracts/UniversalMarketplace')) + .MARKETPLACE_ABI, + functionName: 'openJob', + args: [ + { + cardId, + cardVersion: before.version, + job: plan.job, + session: plan.session, + intent: plan.intent, + sig: signature, + label: plan.label, + }, + ], + }); + const { chain, ...fields } = before; + const updateData = encodeFunctionData({ + abi: (await import('../src/lib/market/contracts/UniversalMarketplace')) + .MARKETPLACE_ABI, + functionName: 'updateAgentCard', + args: [ + cardId, + { + ...fields, + jobType: fields.jobType as Hex, + chainNamespace: chain, + providerFee: BigInt(7000000), + }, + PushChain.utils.market.encodeAgentCardRules( + { ...before.rules, maxGasPerCall: BigInt(17) }, + { chain } + ), + before.jobSpecTemplateBytes, + ], + }); + let openingHash: Hex; + let updateHash: Hex; + await push.request({ + method: 'evm_setAutomine' as never, + params: [false] as never, + }); + try { + openingHash = (await push.request({ + method: 'eth_sendTransaction' as never, + params: [ + { + from: accounts[0].address, + to: D.marketplace, + data: openData, + gas: '0x5b8d80', + }, + ] as never, + })) as Hex; + updateHash = (await push.request({ + method: 'eth_sendTransaction' as never, + params: [ + { + from: accounts[1].address, + to: D.marketplace, + data: updateData, + gas: '0x5b8d80', + }, + ] as never, + })) as Hex; + await push.request({ method: 'evm_mine' as never, params: [] as never }); + } finally { + await push.request({ + method: 'evm_setAutomine' as never, + params: [true] as never, + }); + } + const [openedReceipt, updatedReceipt] = await Promise.all([ + push.waitForTransactionReceipt({ hash: openingHash }), + push.waitForTransactionReceipt({ hash: updateHash }), + ]); + expect(openedReceipt.status).toBe('success'); + expect(updatedReceipt.status).toBe('success'); + expect(openedReceipt.blockNumber).toBe(updatedReceipt.blockNumber); + expect((await provider.market.card(cardId)).version).toBe( + before.version + BigInt(1) + ); + const recovered = await owner.market.openJob(plan, { + resume: await owner.universal.trackTransaction(openingHash), + }); + const source = '0x0A16CBa65FfCAa4C2282b27b027Ab4A2fE46E0Bf'; + await push.request({ + method: 'anvil_impersonateAccount' as never, + params: [source] as never, + }); + const funder = createWalletClient({ + account: source, + chain: push.chain, + transport: http(rpc), + }); + for (const [asset, amount] of [ + [token, BigInt(10)], + [D.paymentToken, before.providerFee], + ] as const) { + const hash = await funder.writeContract({ + address: asset, + chain: push.chain, + abi: erc20Abi, + functionName: 'transfer', + args: [accounts[0].address, amount], + }); + await push.waitForTransactionReceipt({ hash }); + } + const deposited = await owner.market.deposit(recovered.jobId); + expect(deposited.cardVersion).toBe(before.version); + expect(deposited.txs.map((t) => [t.step, t.amount])).toEqual([ + ['PRINCIPAL', BigInt(10)], + ['PROVIDER_FEE', before.providerFee], + ]); + await (await owner.job.cancel(recovered.jobId)).wait(); +}); diff --git a/packages/core/__market-local__/setup.ts b/packages/core/__market-local__/setup.ts new file mode 100644 index 000000000..3855681fd --- /dev/null +++ b/packages/core/__market-local__/setup.ts @@ -0,0 +1,42 @@ +/** Real viem RPC/actions, with local-fork timeouts and polling instead of production defaults. */ +jest.mock('viem', () => { + const actual = jest.requireActual('viem'); + return { + ...actual, + createPublicClient: (options: { + pollingInterval?: number; + [key: string]: unknown; + }) => + actual.createPublicClient({ + ...options, + pollingInterval: options.pollingInterval ?? 25, + }), + http: (url?: string, options?: Record) => + actual.http(url, { + ...(url?.startsWith('http://127.0.0.1:') ? { timeout: 120000 } : {}), + ...options, + }), + createWalletClient: (options: { + chain?: { rpcUrls?: { default?: { http?: string[] } } }; + [key: string]: unknown; + }) => { + const client = actual.createWalletClient(options); + const url = options.chain?.rpcUrls?.default?.http?.[0]; + if (url?.startsWith('http://127.0.0.1:')) { + const send = client.sendTransaction; + const local = actual.createPublicClient({ + transport: actual.http(url, { timeout: 120000 }), + }); + client.sendTransaction = async (params: unknown) => { + const hash = await send(params); + // Anvil may return the hash before its receipt is indexed, then forward a + // premature receipt query to the public fork RPC. Mine confirmation blocks locally before + // handing the real hash to SDK receipt polling. No result is mocked. + await local.request({ method: 'anvil_mine', params: ['0x4'] }); + return hash; + }; + } + return client; + }, + }; +}); diff --git a/packages/core/jest.marketplace-local.config.ts b/packages/core/jest.marketplace-local.config.ts new file mode 100644 index 000000000..36c35c626 --- /dev/null +++ b/packages/core/jest.marketplace-local.config.ts @@ -0,0 +1,15 @@ +export default { + displayName: 'marketplace-local', + preset: '../../jest.preset.js', + testEnvironment: 'node', + setupFilesAfterEnv: ['/__market-local__/setup.ts'], + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.marketplace-local.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js'], + testTimeout: 180000, + testMatch: ['/__market-local__/**/*.spec.ts'], +}; diff --git a/packages/core/package.json b/packages/core/package.json index 279ded7a0..24da34c1a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -73,6 +73,7 @@ "src/**/*.json", "README.md", "AGW.md", + "MARKETPLACE.md", "CHANGELOG.md" ], "devDependencies": { diff --git a/packages/core/scripts/agw-local/contracts/MarketplaceOwnerFixtures.sol b/packages/core/scripts/agw-local/contracts/MarketplaceOwnerFixtures.sol new file mode 100644 index 000000000..c52cb5670 --- /dev/null +++ b/packages/core/scripts/agw-local/contracts/MarketplaceOwnerFixtures.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// SDK acceptance only: a real ERC-1271 owner and an EIP-7702-compatible verifier. +contract Marketplace1271Owner { + address public immutable controller; + uint8 public responseMode; + + constructor(address controller_) { controller = controller_; } + receive() external payable {} + + function setResponseMode(uint8 mode) external { + require(msg.sender == controller, "controller"); + responseMode = mode; + } + + function isValidSignature(bytes32 digest, bytes calldata signature) external view returns (bytes4) { + if (responseMode == 1) return 0xffffffff; + if (responseMode == 2) { + // A magic prefix with nonzero trailing bytes must not count as ERC-1271 success. + assembly { mstore(0, or(shl(224, 0x1626ba7e), 1)) return(0, 32) } + } + if (responseMode == 3) { + assembly { mstore(0, shl(224, 0x1626ba7e)) return(0, 4) } + } + if (signature.length != 65) return 0xffffffff; + bytes32 r; + bytes32 s; + uint8 v; + assembly { r := calldataload(signature.offset) s := calldataload(add(signature.offset, 32)) v := byte(0, calldataload(add(signature.offset, 64))) } + if (uint256(s) > 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0 || (v != 27 && v != 28)) return 0xffffffff; + return ecrecover(digest, v, r, s) == controller ? bytes4(0x1626ba7e) : bytes4(0xffffffff); + } + + function execute(address target, uint256 value, bytes calldata data) external payable returns (bytes memory) { + require(msg.sender == controller, "controller"); + (bool success, bytes memory result) = target.call{value: value}(data); + if (!success) assembly { revert(add(result, 32), mload(result)) } + return result; + } +} + +/// Nonreverting fallback: neither an empty nor an echoed return is a signature verifier. +contract MarketplaceEchoOwner { + fallback() external { assembly { calldatacopy(0, 0, calldatasize()) return(0, calldatasize()) } } +} diff --git a/packages/core/scripts/check-agw-api.js b/packages/core/scripts/check-agw-api.js index 4aa6c9294..ff2c461f3 100644 --- a/packages/core/scripts/check-agw-api.js +++ b/packages/core/scripts/check-agw-api.js @@ -169,6 +169,9 @@ function snapshot(entry) { (context) => { const visit = (node) => { node = ts.visitEachChild(node, visit, context); + // Source property keys can use single quotes while declarations emit + // double quotes. Canonicalize syntax without changing the literal value. + if (ts.isStringLiteral(node)) return ts.factory.createStringLiteral(node.text); if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { const types = [...node.types].sort((a, b) => printer diff --git a/packages/core/scripts/check-agw-docs.js b/packages/core/scripts/check-agw-docs.js index ec10c4b13..af27c1045 100644 --- a/packages/core/scripts/check-agw-docs.js +++ b/packages/core/scripts/check-agw-docs.js @@ -4,11 +4,12 @@ const path = require('node:path'); const ts = require('typescript'); const root = path.resolve(__dirname, '../../..'); -const guide = path.join(root, 'packages/core/AGW.md'); +const guide = path.join(root, process.argv[2] || 'packages/core/AGW.md'); +const guideName = path.basename(guide, '.md'); const blocks = [ ...fs.readFileSync(guide, 'utf8').matchAll(/```ts\n([\s\S]*?)```/g), ].map((m) => m[1]); -if (!blocks.length) throw new Error('No AGW TypeScript examples found'); +if (!blocks.length) throw new Error(`No ${guideName} TypeScript examples found`); const cache = path.join(root, 'node_modules/.cache'); fs.mkdirSync(cache, { recursive: true }); const dir = fs.mkdtempSync(path.join(cache, 'agw-docs-')); @@ -42,7 +43,7 @@ try { process.exitCode = 1; } else { process.stdout.write( - `AGW guide: ${blocks.length} TypeScript blocks typechecked against public exports; no examples executed.\n` + `${guideName} guide: ${blocks.length} TypeScript blocks typechecked against public exports; no examples executed.\n` ); } } finally { diff --git a/packages/core/scripts/check-agw-package.js b/packages/core/scripts/check-agw-package.js index 7384461d0..453042a38 100644 --- a/packages/core/scripts/check-agw-package.js +++ b/packages/core/scripts/check-agw-package.js @@ -22,10 +22,13 @@ async function main() { }) ); assert(pack.files.some((f) => f.path === 'AGW.md')); + assert(pack.files.some((f) => f.path === 'MARKETPLACE.md')); + assert.equal(typeof cjs.PushChain.utils.job.buildJobSpec, 'function'); + assert.equal(typeof cjs.PushChain.utils.market.encodeAgentCardRules, 'function'); assert(!pack.files.some((f) => /(^|\/)\.env($|\.)/.test(f.path))); assert( !pack.files.some((f) => - /(^|\/)(plan|__e2e__|__agw-local__|scripts)(\/|$)/.test(f.path) + /(^|\/)(plan|__e2e__|__agw-local__|__market-local__|scripts)(\/|$)/.test(f.path) ) ); const preset = cjs.PushChain.utils.agentic.presets.get('LENDING', { @@ -53,6 +56,9 @@ async function main() { try { const file = path.join(tmp, 'consumer.ts'); fs.writeFileSync(file, source); + const marketFile = path.join(tmp, 'market-consumer.ts'); + const marketGuide = fs.readFileSync(path.join(built, 'MARKETPLACE.md'), 'utf8'); + fs.writeFileSync(marketFile, [...marketGuide.matchAll(/```ts\n([\s\S]*?)```/g)].map(m => m[1]).join('\n\n')); const config = ts.readConfigFile( path.join(root, 'packages/core/tsconfig.lib.json'), ts.sys.readFile @@ -68,7 +74,7 @@ async function main() { paths: { '@pushchain/core': [path.join(built, 'src/index.d.ts')] }, }; const diagnostics = ts.getPreEmitDiagnostics( - ts.createProgram([file], options) + ts.createProgram([file, marketFile], options) ); if (diagnostics.length) throw new Error( diff --git a/packages/core/scripts/marketplace-local/prepare.sh b/packages/core/scripts/marketplace-local/prepare.sh new file mode 100644 index 000000000..ae6f6a7b9 --- /dev/null +++ b/packages/core/scripts/marketplace-local/prepare.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Compile repo-owned signature acceptance fixtures. Never broadcasts or clones another repository. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MARKET_FIXTURE_DIR="${MARKETPLACE_LOCAL_DIR:-${TMPDIR:-/tmp}/push-marketplace-local}" +mkdir -p "$MARKET_FIXTURE_DIR/src" +cp "$SCRIPT_DIR/../agw-local/contracts/MarketplaceOwnerFixtures.sol" "$MARKET_FIXTURE_DIR/src/" +cat > "$MARKET_FIXTURE_DIR/foundry.toml" <<'EOF' +[profile.default] +src = "src" +out = "out" +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 200 +EOF +forge build --root "$MARKET_FIXTURE_DIR" --offline >/dev/null +echo "$MARKET_FIXTURE_DIR" diff --git a/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts new file mode 100644 index 000000000..4a80316b5 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts @@ -0,0 +1,206 @@ +import { AgenticNamespaceImpl } from '../agentic'; +import { + AgenticError, + AgenticRevertError, + AGENTIC_ERROR_CODE, +} from '../errors'; +import { agenticPresets } from '../presets'; +import { normalizePublicRule, publicRules } from '../public-rule'; +import { validateUniversalRuleShape } from '../codec/universal'; +import type { UniversalRule } from '../agentic.types'; +import { wrapSendError } from '../management/common'; +import { ADDR, FakeChain, registerFakeGeneration } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; +import { resetAgenticGenerations } from '../deployments'; + +const chainNamespace = 'eip155:11155111' as const; +const options = { + chainNamespace, + agent: ADDR.agent, + validUntil: 2_000_000_000, +}; +beforeEach(registerFakeGeneration); +afterEach(resetAgenticGenerations); + +it.each(['LENDING', 'STAKING'] as const)( + 'public %s create gives a typed token-route error before any transaction', + async (category) => { + const cause = new Error( + 'Unable to infer origin chain or token symbol for token' + ); + const rt = mockRuntime(new FakeChain(), { + signer: ADDR.owner, + resolvePrc20: () => { + throw cause; + }, + }); + const sdk = new AgenticNamespaceImpl(rt); + const { rules } = agenticPresets.get(category, options); + let failure: unknown; + try { + await sdk.create('Test', { rules }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(AgenticError); + expect(failure).toMatchObject({ + code: 'TOKEN_ROUTE_UNAVAILABLE', + cause, + details: { + ruleIndex: 0, + assetIndex: 0, + chainNamespace, + field: 'assets[0].token', + }, + userMessage: expect.stringContaining( + 'Rule 1: This token cannot be sent from Push to Ethereum Sepolia' + ), + hint: expect.stringContaining("funding: 'destination'"), + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + expect(rt.atomicMock).not.toHaveBeenCalled(); + } +); + +it('reports the complete 40-condition count for duplicated DEX entries, without dropping them', async () => { + const rt = mockRuntime(new FakeChain(), { signer: ADDR.owner }); + const sdk = new AgenticNamespaceImpl(rt); + const { rules } = agenticPresets.get('DEXES', { + ...options, + funding: 'destination', + }); + const calls = rules[0].allowedCalls; + const duplicated = { ...rules[0], allowedCalls: [...calls, ...calls] }; + await expect( + sdk.create('Test', { rules: [duplicated] }) + ).rejects.toMatchObject({ + code: 'INVALID_RULE', + details: { + reason: 'RULE_CONSTRAINT_LIMIT', + actual: 40, + limit: 32, + callCount: 12, + ruleIndex: 0, + chainNamespace, + }, + message: expect.stringContaining('40 conditions'), + userMessage: expect.stringContaining('Rule 1:'), + }); + expect(duplicated.allowedCalls).toHaveLength(12); + expect(rt.executeMock).not.toHaveBeenCalled(); +}); + +it('validates stock DEX rules independently: two 20-condition rules do not become a 40-condition rule', async () => { + const rt = mockRuntime(new FakeChain()); + for (const chain of ['eip155:11155111', 'eip155:84532'] as const) { + const { rules } = agenticPresets.get('DEXES', { + ...options, + chainNamespace: chain, + funding: 'destination', + }); + const normalized = (await normalizePublicRule( + publicRules(rules)[0], + ADDR.other, + rt + )) as UniversalRule; + expect( + normalized.allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) + ).toBe(20); + expect(() => + validateUniversalRuleShape(normalized, rt.nowSeconds()) + ).not.toThrow(); + } +}); + +it('does not misclassify a destination RPC failure as an unavailable token', async () => { + const cause = new Error('RPC timeout: internal endpoint details'); + const rt = mockRuntime(new FakeChain(), { + signer: ADDR.owner, + resolveCEA: async () => { + throw cause; + }, + }); + const sdk = new AgenticNamespaceImpl(rt); + await expect( + sdk.create('Test', { rules: agenticPresets.get('LENDING', options).rules }) + ).rejects.toMatchObject({ + code: 'RULE_READ_FAILED', + cause, + userMessage: + 'Rule 1: The wallet’s destination account could not be loaded. Try again shortly.', + details: { ruleIndex: 0, chainNamespace, stage: 'destinationAccount' }, + }); + expect(rt.executeMock).not.toHaveBeenCalled(); +}); + +it.each(Object.values(AGENTIC_ERROR_CODE))( + 'provides safe UI copy for %s while retaining diagnostic text', + (code) => { + const error = new AgenticError(code, 'raw RPC details: 0x12345678'); + expect(error.userMessage.length).toBeGreaterThan(10); + expect(error.userMessage).not.toContain('raw RPC'); + expect(error.message).toBe('raw RPC details: 0x12345678'); + } +); + +it('keeps submitted hashes and uncertainty visible instead of inviting a blind retry', () => { + const error = new AgenticError( + 'RECEIPT_UNAVAILABLE', + 'technical receipt diagnostic', + { details: { txHash: '0x1234' } } + ); + expect(error.userMessage).toContain('submitted'); + expect(error.userMessage).toContain('Check its status'); + expect(error.details).toEqual({ txHash: '0x1234' }); +}); + +it('explains the create and native action ceilings using the available structured reason', () => { + expect( + new AgenticError( + 'INVALID_RULE', + 'create accepts at most eight initial rules' + ).userMessage + ).toContain('at most eight rules'); + expect( + new AgenticError('CAPABILITY_UNAVAILABLE', 'diagnostic', { + details: { capability: 'nativeActions' }, + }).userMessage + ).toContain('at most eight functions'); +}); + +it('preserves decoded contract failures and cancellation causes with UI messages', () => { + const decodedError = { + name: 'PolicyCheckReverted(ArgPinAboveMax)', + selector: '0x12345678', + }; + const cause = new Error('provider detail'); + const error = new AgenticRevertError('technical revert', { + decodedError, + cause, + }); + expect(error.userMessage).toContain('exceeds'); + expect(error.decodedError).toEqual(decodedError); + expect(error.cause).toBe(cause); + const rejected = { code: 4001, message: 'User rejected request' }; + expect(wrapSendError(rejected)).toMatchObject({ + code: 'REQUEST_REJECTED', + cause: rejected, + userMessage: expect.stringContaining('cancelled'), + }); +}); + +it.each([ + ['OwnerSigExpired', 'approval has expired'], + ['RulesExpired', 'rule has expired'], + ['ERC20InsufficientAllowance', 'spending approval'], + ['ERC20InsufficientBalance', 'not enough funds'], + ['TooManyAllowedCallPins', 'more than 32 conditions'], +])( + 'explains %s without confusing approvals, rules and balances', + (name, text) => { + expect( + new AgenticRevertError('diagnostic', { decodedError: { name } }) + .userMessage + ).toContain(text); + } +); diff --git a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts index 0adcce47c..25b479219 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts @@ -45,7 +45,7 @@ it('rejects native-token markers and oversized pin expansion', () => { ...input, assets: [...input.assets!, { token: ADDR.owner }], }) - ).toThrow(/32 pins/); + ).toThrow(/maximum is 32/); expect(() => agenticPresets.get('DEXES', { ...input, diff --git a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts index 5d1a73f17..caa42aa34 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts @@ -223,5 +223,5 @@ it('refuses an overlarge token expansion without dropping restrictions', () => { asset(getAddress('0x88541670E55cC00bEEFD87eB59EDd1b7C511AC9a')), ], }) - ).toThrow(/32 pins/); + ).toThrow(/maximum is 32/); }); diff --git a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts index 3dac9d67c..19d266272 100644 --- a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts @@ -228,7 +228,9 @@ describe('public surface', () => { it('exposes the wallet marker and preset API, keeping deployment wiring and codecs internal', () => { expect(CONSTANTS.AGENTIC.WALLET).toBe('agentic:wallet'); expect(Object.keys(CONSTANTS.AGENTIC).sort()).toEqual([ + 'JOB', 'PRESETS', + 'PRINCIPAL', 'WALLET', ]); expect(Object.isFrozen(CONSTANTS.AGENTIC)).toBe(true); diff --git a/packages/core/src/lib/agentic/codec/rules.ts b/packages/core/src/lib/agentic/codec/rules.ts index e180f2695..402929d5f 100644 --- a/packages/core/src/lib/agentic/codec/rules.ts +++ b/packages/core/src/lib/agentic/codec/rules.ts @@ -7,7 +7,7 @@ import { type Hex, } from 'viem'; import { AgenticCapability, requireCapability } from '../capabilities'; -import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { AGENTIC_ERROR_CODE, AgenticError, ruleErrorContext } from '../errors'; import type { NativeRule, InternalRule, @@ -96,112 +96,116 @@ export function prepareRules( ); } return rules.map((rule, i) => { - if (!rule || typeof rule !== 'object') { - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - `rules[${i}] is not an object` - ); - } - if ( - !isAddress(rule.agent, { strict: false }) || - /^0x0{40}$/i.test(rule.agent) - ) { - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - `rules[${i}].agent must be a non-zero Push address` - ); - } - const agent = getAddress(rule.agent); - if (ctx.owner && agent === getAddress(ctx.owner)) { - throw new AgenticError( - AGENTIC_ERROR_CODE.AGENT_IS_OWNER, - `rules[${i}].agent is the wallet owner; the owner uses the owner door` - ); - } - const chainNamespace = ruleChain(rule, ctx.pushChainNamespace); - if ( - isUniversalRule(rule) && - rule.chainNamespace === ctx.pushChainNamespace - ) { - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - `rules[${i}] names the connected Push chain; omit chainNamespace for a native rule` - ); - } - if ('ref' in rule) { - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - 'InternalRule ref is not supported; remove it from the input' - ); - } - - if (isSolanaRule(rule)) { - requireCapability( - ctx.capabilities, - AgenticCapability.UNIVERSAL_SVM_RULES - ); - const terms = ctx.svm?.get(rule); - if (!terms || !ctx.gateway) + try { + if (!rule || typeof rule !== 'object') { throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, - 'SVM rules require resolved wallet context and validated instruction layouts' + `rules[${i}] is not an object` ); - return { - rule, - agent, - chainNamespace, - svmTerms: terms, - session: buildSession({ - validator: ctx.validator, - rulesPolicy: ctx.rulesPolicy, - agent, - actions: [ - { - target: ctx.gateway, - selector: SEND_OUTBOUND_SELECTOR, - initData: encodeEnvelope( - chainNamespace, - encodeSvmTerms(terms, ctx.nowSeconds) - ), - }, - ], - }), - }; - } - if (isUniversalRule(rule)) { - return encodeUniversalRule(rule, ctx); - } - requireCapability(ctx.capabilities, AgenticCapability.NATIVE_RULES); - const actions = nativeRuleToActionTerms(rule as NativeRule, ctx).map( - (a) => a.terms - ); - const forbidden = [ - '0x0000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000001', - ...(ctx.forbiddenTargets ?? []), - ].map((a) => a.toLowerCase()); - actions.forEach((terms, j) => { - if (forbidden.includes(terms.target.toLowerCase())) { - const where = - actions.length > 1 ? `rules[${i}].actions[${j}]` : `rules[${i}]`; + } + if ( + !isAddress(rule.agent, { strict: false }) || + /^0x0{40}$/i.test(rule.agent) + ) { throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, - `${where}.target ${terms.target} is a wallet-infrastructure address the contracts refuse as a native target` + `rules[${i}].agent must be a non-zero Push address` ); } - }); - // One rule, one rule ID: every action is a separate (target, selector) - // entry of the same session, each with its own stored terms. - const session = buildSession({ - validator: ctx.validator, - agent, - rulesPolicy: ctx.rulesPolicy, - actions: actions.map((terms) => ({ - target: terms.target, - selector: terms.selector, - initData: encodeEnvelope(chainNamespace, encodeNativeTerms(terms)), - })), - }); - return { rule, agent, chainNamespace, session, nativeTerms: actions }; + const agent = getAddress(rule.agent); + if (ctx.owner && agent === getAddress(ctx.owner)) { + throw new AgenticError( + AGENTIC_ERROR_CODE.AGENT_IS_OWNER, + `rules[${i}].agent is the wallet owner; the owner uses the owner door` + ); + } + const chainNamespace = ruleChain(rule, ctx.pushChainNamespace); + if ( + isUniversalRule(rule) && + rule.chainNamespace === ctx.pushChainNamespace + ) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `rules[${i}] names the connected Push chain; omit chainNamespace for a native rule` + ); + } + if ('ref' in rule) { + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'InternalRule ref is not supported; remove it from the input' + ); + } + + if (isSolanaRule(rule)) { + requireCapability( + ctx.capabilities, + AgenticCapability.UNIVERSAL_SVM_RULES + ); + const terms = ctx.svm?.get(rule); + if (!terms || !ctx.gateway) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'SVM rules require resolved wallet context and validated instruction layouts' + ); + return { + rule, + agent, + chainNamespace, + svmTerms: terms, + session: buildSession({ + validator: ctx.validator, + rulesPolicy: ctx.rulesPolicy, + agent, + actions: [ + { + target: ctx.gateway, + selector: SEND_OUTBOUND_SELECTOR, + initData: encodeEnvelope( + chainNamespace, + encodeSvmTerms(terms, ctx.nowSeconds) + ), + }, + ], + }), + }; + } + if (isUniversalRule(rule)) { + return encodeUniversalRule(rule, ctx); + } + requireCapability(ctx.capabilities, AgenticCapability.NATIVE_RULES); + const actions = nativeRuleToActionTerms(rule as NativeRule, ctx).map( + (a) => a.terms + ); + const forbidden = [ + '0x0000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000001', + ...(ctx.forbiddenTargets ?? []), + ].map((a) => a.toLowerCase()); + actions.forEach((terms, j) => { + if (forbidden.includes(terms.target.toLowerCase())) { + const where = + actions.length > 1 ? `rules[${i}].actions[${j}]` : `rules[${i}]`; + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `${where}.target ${terms.target} is a wallet-infrastructure address the contracts refuse as a native target` + ); + } + }); + // One rule, one rule ID: every action is a separate (target, selector) + // entry of the same session, each with its own stored terms. + const session = buildSession({ + validator: ctx.validator, + agent, + rulesPolicy: ctx.rulesPolicy, + actions: actions.map((terms) => ({ + target: terms.target, + selector: terms.selector, + initData: encodeEnvelope(chainNamespace, encodeNativeTerms(terms)), + })), + }); + return { rule, agent, chainNamespace, session, nativeTerms: actions }; + } catch (error) { + throw ruleErrorContext(error, i, rule?.chainNamespace); + } }); } diff --git a/packages/core/src/lib/agentic/codec/universal.ts b/packages/core/src/lib/agentic/codec/universal.ts index fa6b6d6f9..d91e5ff6a 100644 --- a/packages/core/src/lib/agentic/codec/universal.ts +++ b/packages/core/src/lib/agentic/codec/universal.ts @@ -1,7 +1,11 @@ import { validatePins } from './pins'; import { getAddress, isAddress, type Address } from 'viem'; import { AgenticCapability, requireCapability } from '../capabilities'; -import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { + AGENTIC_ERROR_CODE, + AgenticError, + ruleConstraintLimit, +} from '../errors'; import type { UniversalRule } from '../agentic.types'; import type { RuleEncodeContext, PreparedRule } from './rules'; import { argumentOffset, rawOffset } from './abi-layout'; @@ -84,11 +88,20 @@ export function validateUniversalRuleShape( try { validatePins(call.pins ?? []); } catch (error) { - throw invalid((error as Error).message); + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + (error as Error).message, + { + cause: error, + details: { + callIndex: i, + field: `allowedCalls[${i}].pins`, + chainNamespace: rule.chainNamespace, + }, + } + ); } pinCount += call.pins?.length ?? 0; - if (pinCount > 32) - throw invalid('at most 32 pins across all allowed calls'); const key = `${call.target.toLowerCase()}:${parsed.selector}`; const restricted = !!call.pins?.length || @@ -100,11 +113,27 @@ export function validateUniversalRuleShape( ); seen.set(key, restricted); }); + if (pinCount > 32) + throw ruleConstraintLimit( + pinCount, + rule.allowedCalls.length, + rule.chainNamespace + ); const tokens = new Set(); rule.assets.forEach((a, i) => { const address = typeof a.token === 'string' ? a.token : a.token?.address; if (typeof address !== 'string') throw invalid(`assets[${i}].token is missing an address`); + if (!isAddress(address, { strict: false })) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'The selected token has an invalid address.', + { + userMessage: + 'The selected token has an invalid address. Select the token again.', + details: { assetIndex: i, field: `assets[${i}].token` }, + } + ); const key = address.toLowerCase(); if (tokens.has(key)) throw invalid(`assets[${i}] duplicates an earlier token`); diff --git a/packages/core/src/lib/agentic/constants.ts b/packages/core/src/lib/agentic/constants.ts index 943b896c7..b5e798de1 100644 --- a/packages/core/src/lib/agentic/constants.ts +++ b/packages/core/src/lib/agentic/constants.ts @@ -1,6 +1,19 @@ -/** Public section-7 discovery constants; deployment wiring stays internal. */ +import { JOB_VOCABULARY, PRINCIPAL } from '../job-spec/constants'; +import { DONUT_MARKETPLACE } from '../market/deployment'; +/** Public vocabulary. JOB.ADDRESS is the explicit per-chain job integration surface. */ export const AGENTIC = Object.freeze({ WALLET: 'agentic:wallet' as const, + PRINCIPAL, + JOB: Object.freeze({ + ...JOB_VOCABULARY, + ADDRESS: Object.freeze({ + 'eip155:42101': Object.freeze({ + KERNEL: DONUT_MARKETPLACE.kernel, + HOOK: DONUT_MARKETPLACE.hook, + EVALUATOR: DONUT_MARKETPLACE.evaluator, + }), + }), + }), PRESETS: Object.freeze({ CATEGORY: Object.freeze({ DEXES: 'DEXES' as const, diff --git a/packages/core/src/lib/agentic/contracts/reader.ts b/packages/core/src/lib/agentic/contracts/reader.ts index 7b9780684..2ea447435 100644 --- a/packages/core/src/lib/agentic/contracts/reader.ts +++ b/packages/core/src/lib/agentic/contracts/reader.ts @@ -14,6 +14,7 @@ export interface ChainReader { blockNumber?: bigint; }): Promise; getBlockNumber(): Promise; + getBlock?(args: { blockNumber: bigint }): Promise<{ timestamp: bigint; hash?: Hex | null }>; getCode(args: { address: Address; blockNumber?: bigint }): Promise; getBalance(args: { address: Address; blockNumber?: bigint }): Promise; getLogs(args: { @@ -24,6 +25,9 @@ export interface ChainReader { toBlock: bigint; }): Promise; getTransactionReceipt(args: { hash: Hex }): Promise; + getTransaction?(args: { hash: Hex }): Promise<{ input: Hex; blockNumber: bigint | null }>; + /** Raw eth_call, used to verify complete signed marketplace requests before broadcast. */ + call?(args: { to: Address; data: Hex; account?: Address; blockNumber?: bigint }): Promise<{ data?: Hex }>; } export function chainReaderFromPublicClient(client: PublicClient): ChainReader { @@ -33,10 +37,13 @@ export function chainReaderFromPublicClient(client: PublicClient): ChainReader { readContract: (args) => client.readContract(args as Parameters[0]), getBlockNumber: () => client.getBlockNumber({ cacheTime: 0 }), + getBlock: (args) => client.getBlock(args), getCode: (args) => client.getCode(args), getBalance: (args) => client.getBalance(args), getLogs: (args) => client.getLogs(args as Parameters[0]) as Promise, getTransactionReceipt: (args) => client.getTransactionReceipt(args), + getTransaction: (args) => client.getTransaction(args), + call: (args) => client.call(args), }; } diff --git a/packages/core/src/lib/agentic/error-messages.ts b/packages/core/src/lib/agentic/error-messages.ts new file mode 100644 index 000000000..88722c604 --- /dev/null +++ b/packages/core/src/lib/agentic/error-messages.ts @@ -0,0 +1,167 @@ +import type { AgenticErrorCode } from './errors'; + +/** UI copy only. Codes, original messages, hints and causes remain diagnostic data. */ +const MESSAGES: Record = { + DUPLICATE_RULE: + 'Keep one rule per agent and network. Edit the existing rule or remove the duplicate.', + AGENT_IS_OWNER: 'Choose an agent address different from the wallet owner.', + AMBIGUOUS_RULE: + 'More than one rule matches this agent and network. Keep one active matching rule before sending.', + TARGET_NOT_ON_RULE_CHAIN: + 'Choose an app on the network selected for this rule.', + ASSET_CHAIN_MISMATCH: + 'The selected token belongs to a different network. Choose a token on this rule’s network.', + TOKEN_ROUTE_UNAVAILABLE: + 'This token cannot currently be transferred from Push to the selected network. Choose a supported token or use funds already on the destination network.', + REQUEST_REJECTED: + 'The wallet request was cancelled. Check wallet activity before trying again.', + NOT_OWNER_OR_AGENT: + 'Connect the wallet owner or an agent authorized by its rules.', + WALLET_NOT_DEPLOYED: 'This agentic wallet has not been created yet.', + NOT_AGENTIC_WALLET: 'This address is not a supported agentic wallet.', + DELETE_NEEDS_TARGET: 'Choose which rules to remove.', + NO_RULES_FOR_CHAIN: 'This agent has no active rule for the selected network.', + FROM_NOT_ALLOWED: + 'The agentic wallet is the sender. Remove the custom sender selection.', + NOT_ALLOWED_IN_AGENTIC_MODE: + 'This action is unavailable while using an agentic wallet.', + INVALID_RULE: + 'Some rule settings are invalid. Review the rule and try again.', + CAPABILITY_UNAVAILABLE: + 'This feature is not available for this wallet or network yet.', + READ_ONLY: 'Connect a signing wallet to continue.', + NOT_WALLET_OWNER: 'Connect the owner wallet to change these permissions.', + RULE_NOT_FOUND: + 'This rule could not be found. Refresh the wallet’s rules and try again.', + RULE_READ_FAILED: + 'The wallet’s current state could not be loaded. Refresh and try again.', + INCONSISTENT_READ: + 'The wallet changed while it was being checked. Refresh before trying again.', + INDEX_RACE: + 'The expected wallet address changed. Refresh wallet information before trying again.', + RECEIPT_MISMATCH: + 'The transaction result needs verification. Check its status before retrying.', + RECEIPT_UNAVAILABLE: + 'The transaction was submitted, but its status could not be confirmed. Check its status before trying again.', + AGENT_GAS_INSUFFICIENT: + 'The agent needs more Push coins to pay transaction fees.', + WALLET_BALANCE_INSUFFICIENT: + 'The agentic wallet does not have enough funds for this action.', +}; + +export function agenticUserMessage( + code: AgenticErrorCode, + diagnostic: string, + details?: Readonly> +): string { + if (code === 'CAPABILITY_UNAVAILABLE') { + if (details?.['capability'] === 'nativeActions') + return 'A Push rule can allow at most eight functions. Select fewer functions.'; + if (details?.['capability'] === 'presetCalls') + return 'Choose a preset configuration with 1 to 32 allowed functions.'; + if ( + details?.['capability'] === 'presetChain' || + details?.['capability'] === 'presetCatalog' + ) + return 'This preset is unavailable for the selected network.'; + } + if (code !== 'INVALID_RULE') return MESSAGES[code]; + if (/validUntil|expiry|expired|future.*timestamp/i.test(diagnostic)) + return 'Choose an expiry date in the future.'; + if (/label.*(64|length|most)/i.test(diagnostic)) + return 'The wallet name is too long. Shorten it and try again.'; + if (/label/i.test(diagnostic)) return 'Enter a valid wallet name.'; + if (/maxGasPerCall.*positive/i.test(diagnostic)) + return 'Set a gas budget greater than zero for this rule.'; + if (/allowedCalls.*(1\.\.32|contain|array)/i.test(diagnostic)) + return 'Select between 1 and 32 allowed functions for this rule.'; + if (/rules.*(eight|8)|eight initial rules/i.test(diagnostic)) + return 'A wallet can be created with at most eight rules. Remove some rules before creating it.'; + if (/assets.*(eight|8)/i.test(diagnostic)) + return 'A rule can include at most eight tokens. Select fewer tokens.'; + if (/duplicate.*pin|pin.*duplicate/i.test(diagnostic)) + return 'The same function input has more than one condition. Keep one condition for each input.'; + if (/pins.*(eight|8)/i.test(diagnostic)) + return 'A function can have at most eight conditions. Choose a simpler function configuration.'; + if (/pins.*32|32.*pins/i.test(diagnostic)) + return 'This rule has too many conditions. Select fewer tokens or allowed functions.'; + if (/selector|function signature/i.test(diagnostic)) + return 'The selected function format is invalid. Select the function again or check its signature.'; + if (/dynamic|static|offset|argument.*range|tuple/i.test(diagnostic)) + return 'This function input cannot be constrained in that format. Choose a supported input or function.'; + if (/uint256|bigint|non-negative/i.test(diagnostic)) + return 'One of the spending limits is invalid. Review the amounts and try again.'; + if (/chainNamespace|unsupported.*chain/i.test(diagnostic)) + return 'Choose a supported network for this rule.'; + if (/address/i.test(diagnostic)) + return 'Enter a valid wallet, token or app address for this rule.'; + return MESSAGES.INVALID_RULE; +} + +export function agenticRevertUserMessage(name?: string): string { + if (!name) + return 'The transaction could not be completed. Check wallet activity before trying again.'; + if (/TooManyAllowedCallPins/.test(name)) + return 'This rule has more than 32 conditions. Select fewer tokens or allowed functions.'; + if (/TooMany.*Pins|PinRuleOutOfRange/.test(name)) + return 'This function has too many conditions. Choose a simpler rule configuration.'; + if (/TooManyActions|TooManyInitialRules|BatchSizeOutOfRange/.test(name)) + return 'Too many rules or functions are selected. Reduce the selection and try again.'; + if (/LabelTooLong/.test(name)) + return 'The wallet name is too long. Shorten it and try again.'; + if (/DuplicateAction|DuplicateAsset|DuplicateSvmPin/.test(name)) + return 'This rule contains duplicate functions, tokens or conditions. Remove the duplicate selection.'; + if ( + /SpentMismatch|SpentLengthMismatch|AssetSpentMismatch|IndexMismatch|InvalidNonce|IntentGrantNonceMismatch|IntentWalletMismatch/.test( + name + ) + ) + return 'The wallet changed while this action was being prepared. Refresh its state before trying again.'; + if (/EnforcedPause/.test(name)) + return 'This operation is temporarily paused. Try again later.'; + if (/OwnerSigExpired|SignatureExpired/.test(name)) + return 'The wallet approval has expired. Review and sign a new request.'; + if (/OutsideTimeWindow/.test(name)) + return 'This request is outside its valid time window. Refresh and approve a new request.'; + if (/InvalidExpiry/.test(name)) + return 'Choose a valid future expiry for this rule.'; + if (/Allowance|Approval/.test(name)) + return 'The token spending approval is missing or too small. Review the token approvals.'; + if ( + /RuleWithoutPin|CeaAccountsMissExpectedCEA|CeaAccountAtUnpinnedIndex/.test( + name + ) + ) + return 'The rule is missing a required wallet-recipient restriction. Review its recipient settings.'; + if (/Unsupported|WrongVmForCall|WrongModeForCall/.test(name)) + return 'This action is not supported on the selected network or wallet version.'; + if ( + /InvalidOwnerSignature|InvalidEnableSignature|InvalidSessionKeySignature|SignerNotFound/.test( + name + ) + ) + return 'The signature could not be verified. Reconnect the authorized wallet and try again.'; + if (/PCValueExceedsCap|Gas.*Cap/.test(name ?? '')) + return 'The required gas exceeds this rule’s gas budget. Review the budget before trying again.'; + if ( + /ArgPinAboveMax|AmountExceedsCap|TotalSpendCapExceeded|Value.*Cap|MaxCalls|CallLimitReached|TotalNativeAmountExceeded|TotalValueExceeded/.test( + name ?? '' + ) + ) + return 'This action exceeds a rule’s spending or usage limit. Reduce the amount or ask the owner to update the rule.'; + if (/Expired|Expiry/.test(name ?? '')) + return 'This rule has expired. Ask the owner to renew it.'; + if ( + /ArgPinMismatch|Beneficiary|NotAllowed|NotEnabled|Permission|RuleNot|RulesNot|RecipientTargetMismatch|TargetMismatch|SelectorMismatch|SvmAccountPinMismatch|SvmDataPinMismatch/.test( + name ?? '' + ) + ) + return 'This action does not match the wallet’s permissions. Check the token, recipient and allowed function.'; + if (/CallerIsNotOwner|UnauthorizedOwner|OwnerLaneRequired/.test(name ?? '')) + return 'Connect the owner wallet to make this change.'; + if (/CallerIsNotAgent/.test(name ?? '')) + return 'Connect the agent authorized by this rule.'; + if (/Insufficient|Balance/.test(name ?? '')) + return 'There are not enough funds for this action. Check the wallet balance and fees.'; + return 'The wallet contract rejected this transaction. Review the permissions and transaction details before trying again.'; +} diff --git a/packages/core/src/lib/agentic/errors.ts b/packages/core/src/lib/agentic/errors.ts index bbe55e4bd..5aac40335 100644 --- a/packages/core/src/lib/agentic/errors.ts +++ b/packages/core/src/lib/agentic/errors.ts @@ -2,6 +2,7 @@ import { PushChainExecutionError, type DecodedErrorPayload, } from '../orchestrator/internals/errors'; +import { agenticUserMessage, agenticRevertUserMessage } from './error-messages'; /** * Stable AGW error codes. The first groups are specified by the AGW SDK page @@ -15,6 +16,8 @@ export const AGENTIC_ERROR_CODE = { AMBIGUOUS_RULE: 'AMBIGUOUS_RULE', TARGET_NOT_ON_RULE_CHAIN: 'TARGET_NOT_ON_RULE_CHAIN', ASSET_CHAIN_MISMATCH: 'ASSET_CHAIN_MISMATCH', + TOKEN_ROUTE_UNAVAILABLE: 'TOKEN_ROUTE_UNAVAILABLE', + REQUEST_REJECTED: 'REQUEST_REJECTED', // initialize with agenticWallet (spec 3.f) NOT_OWNER_OR_AGENT: 'NOT_OWNER_OR_AGENT', WALLET_NOT_DEPLOYED: 'WALLET_NOT_DEPLOYED', @@ -48,24 +51,34 @@ export type AgenticErrorCode = * eligibility or a read that cannot be trusted. Nothing was signed or sent * except receipt errors, which retain the submitted transaction hash. */ -export class AgenticError extends Error { - readonly code: AgenticErrorCode; +export class AgenticError< + Code extends string = AgenticErrorCode +> extends Error { + readonly code: Code; + /** Safe end-user copy; message/cause retain technical diagnostics. */ + readonly userMessage: string; readonly hint?: string; readonly details?: Readonly>; override readonly cause?: unknown; constructor( - code: AgenticErrorCode, + code: Code, message: string, opts: { hint?: string; details?: Record; cause?: unknown; + userMessage?: string; } = {} ) { super(message); this.name = 'AgenticError'; this.code = code; + this.userMessage = + opts.userMessage ?? + (Object.prototype.hasOwnProperty.call(AGENTIC_ERROR_CODE, code) + ? agenticUserMessage(code as AgenticErrorCode, message, opts.details) + : message); this.hint = opts.hint; this.details = opts.details; this.cause = opts.cause; @@ -80,6 +93,7 @@ export class AgenticError extends Error { */ export class AgenticRevertError extends PushChainExecutionError { readonly agenticCode?: string; + readonly userMessage: string; constructor( message: string, @@ -97,9 +111,88 @@ export class AgenticRevertError extends PushChainExecutionError { }); this.name = 'AgenticRevertError'; this.agenticCode = opts.agenticCode; + this.userMessage = agenticRevertUserMessage( + opts.decodedError?.name ?? opts.agenticCode + ); } } +export function ruleConstraintLimit( + actual: number, + callCount: number, + chainNamespace?: string +): AgenticError { + const message = `This rule has ${actual} conditions. The maximum is 32. Remove duplicate functions or select fewer tokens or functions.`; + return new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message, { + userMessage: message, + hint: 'Regenerate the preset after changing selections. Do not drop individual conditions or bypass the contract limit.', + details: { + reason: 'RULE_CONSTRAINT_LIMIT', + field: 'allowedCalls', + actual, + limit: 32, + callCount, + chainNamespace, + }, + }); +} + +export function ruleErrorContext( + error: unknown, + ruleIndex: number, + chainNamespace?: string +): unknown { + if (!(error instanceof AgenticError)) return error; + if (error.details?.['ruleIndex'] !== undefined) return error; + return new AgenticError(error.code, error.message, { + hint: error.hint, + userMessage: `Rule ${ruleIndex + 1}: ${error.userMessage}`, + cause: error.cause ?? error, + details: { + ...error.details, + ruleIndex, + ...(chainNamespace ? { chainNamespace } : {}), + }, + }); +} + +export function tokenRouteUnavailable( + cause: unknown, + token: string, + chainNamespace: string, + assetIndex: number +): AgenticError { + if (cause instanceof AgenticError) return cause; + const names: Record = { + 'eip155:1': 'Ethereum', + 'eip155:11155111': 'Ethereum Sepolia', + 'eip155:8453': 'Base', + 'eip155:84532': 'Base Sepolia', + 'eip155:42161': 'Arbitrum', + 'eip155:421614': 'Arbitrum Sepolia', + 'eip155:56': 'BNB Chain', + 'eip155:97': 'BNB Testnet', + }; + const network = + names[chainNamespace] ?? + (chainNamespace.startsWith('solana:') + ? 'the selected Solana network' + : 'the selected network'); + const message = `This token cannot be sent from Push to ${network}. Choose a supported token, or use destination funding for balances held on that network.`; + return new AgenticError(AGENTIC_ERROR_CODE.TOKEN_ROUTE_UNAVAILABLE, message, { + userMessage: message, + hint: "For prefunded preset destinations, use funding: 'destination' and pass the returned rules unchanged. selectedAssets is review metadata; do not copy it back into rule.assets.", + details: { + token, + chainNamespace, + assetIndex, + field: `assets[${assetIndex}].token`, + funding: 'push', + }, + cause, + }); +} + export function capabilityUnavailable( capability: string, dependency: string, diff --git a/packages/core/src/lib/agentic/management/common.ts b/packages/core/src/lib/agentic/management/common.ts index 84271b3c6..1200109c7 100644 --- a/packages/core/src/lib/agentic/management/common.ts +++ b/packages/core/src/lib/agentic/management/common.ts @@ -156,6 +156,18 @@ export function wrapSendError(err: unknown): unknown { cause: err, }); } + if ( + err && + typeof err === 'object' && + (('code' in err && err.code === 4001) || + ('name' in err && err.name === 'UserRejectedRequestError')) + ) { + return new AgenticError( + AGENTIC_ERROR_CODE.REQUEST_REJECTED, + 'The wallet request was cancelled.', + { cause: err } + ); + } if (decoded || err instanceof PushChainExecutionError) { const e = err as { message?: string; diff --git a/packages/core/src/lib/agentic/management/destination.ts b/packages/core/src/lib/agentic/management/destination.ts new file mode 100644 index 000000000..d1dacf63d --- /dev/null +++ b/packages/core/src/lib/agentic/management/destination.ts @@ -0,0 +1,38 @@ +import { getAddress, type Address } from 'viem'; +import type { CHAIN } from '../../constants/enums'; +import type { AgenticRuntime } from '../runtime'; +import { AgenticError, AGENTIC_ERROR_CODE } from '../errors'; + +/** Preserve read failures as read failures; never report them as bad token routes. */ +export async function resolveRuleDestination( + runtime: AgenticRuntime, + wallet: Address, + chain: CHAIN +): Promise
{ + try { + return getAddress((await runtime.resolveCEA(wallet, chain)).cea); + } catch (cause) { + if (cause instanceof AgenticError) throw cause; + const unavailable = + cause instanceof Error && + cause.message.includes('CEAFactory not available on chain'); + const message = unavailable + ? 'Agentic wallets are not available on this destination network yet. Choose a supported network.' + : 'The wallet’s destination account could not be loaded. Try again shortly.'; + throw new AgenticError( + unavailable + ? AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE + : AGENTIC_ERROR_CODE.RULE_READ_FAILED, + message, + { + userMessage: message, + cause, + details: { + chainNamespace: chain, + field: 'chainNamespace', + stage: 'destinationAccount', + }, + } + ); + } +} diff --git a/packages/core/src/lib/agentic/management/prepare.ts b/packages/core/src/lib/agentic/management/prepare.ts index 195841b06..df51da54b 100644 --- a/packages/core/src/lib/agentic/management/prepare.ts +++ b/packages/core/src/lib/agentic/management/prepare.ts @@ -16,10 +16,16 @@ import { type AgenticGeneration, } from '../deployments'; import { AgenticCapability, requireCapability } from '../capabilities'; -import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; +import { + AGENTIC_ERROR_CODE, + AgenticError, + tokenRouteUnavailable, + ruleErrorContext, +} from '../errors'; import type { InternalRule, UniversalRule, SolanaRule } from '../agentic.types'; import type { AgenticRuntime } from '../runtime'; import { Snapshot } from '../reads/snapshot'; +import { resolveRuleDestination } from './destination'; import { PRC20_SOURCE_ABI, @@ -37,103 +43,125 @@ export async function prepareWalletRules( snap: Snapshot ) { const rules = await Promise.all( - inputs.map((rule) => - isPublicRule(rule) ? normalizePublicRule(rule, wallet, runtime) : rule - ) + inputs.map(async (rule, ruleIndex) => { + try { + return isPublicRule(rule) + ? await normalizePublicRule(rule, wallet, runtime) + : rule; + } catch (error) { + throw ruleErrorContext(error, ruleIndex, rule?.chainNamespace); + } + }) ); if (rules.length) await verifyPolicyVersion(runtime.reader, gen); const svm = new Map(); const universal = new Map(); - for (const rule of rules) { - if (rule && isSolanaRule(rule)) { - requireCapability( - gen.capabilities, - AgenticCapability.UNIVERSAL_SVM_RULES - ); - svm.set(rule, await preparePublicSvmRule(runtime, snap, wallet, rule)); - continue; - } - if (!isUniversalRule(rule)) continue; - if (rule.chainNamespace.startsWith('solana:')) + for (const [ruleIndex, rule] of rules.entries()) { + try { + if (rule && isSolanaRule(rule)) { + requireCapability( + gen.capabilities, + AgenticCapability.UNIVERSAL_SVM_RULES + ); + svm.set(rule, await preparePublicSvmRule(runtime, snap, wallet, rule)); + continue; + } + if (!isUniversalRule(rule)) continue; + if (rule.chainNamespace.startsWith('solana:')) + requireCapability( + gen.capabilities, + AgenticCapability.UNIVERSAL_SVM_RULES + ); + validateUniversalRuleShape(rule, runtime.nowSeconds()); requireCapability( gen.capabilities, - AgenticCapability.UNIVERSAL_SVM_RULES - ); - validateUniversalRuleShape(rule, runtime.nowSeconds()); - requireCapability( - gen.capabilities, - rule.chainNamespace.startsWith('solana:') - ? AgenticCapability.UNIVERSAL_SVM_RULES - : AgenticCapability.UNIVERSAL_EVM_RULES - ); - const chain = rule.chainNamespace as CHAIN; - if (CHAIN_INFO[chain]?.vm !== VM.EVM) - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - `unsupported EVM rule chain ${chain}` + rule.chainNamespace.startsWith('solana:') + ? AgenticCapability.UNIVERSAL_SVM_RULES + : AgenticCapability.UNIVERSAL_EVM_RULES ); - const { cea } = await runtime.resolveCEA(wallet, chain); - const assets = rule.assets.length - ? rule.assets.map((a) => ({ - token: - typeof a.token === 'string' && getAddress(a.token) === zeroAddress - ? undefined - : a.token, - maxPerCall: a.maxPerCall, - maxTotal: withDefault( - a.maxTotal, - AGENTIC_DEFAULTS.universal.assetMaxTotal - ), - })) - : [{ token: undefined, maxPerCall: BigInt(0), maxTotal: BigInt(0) }]; - const resolved = []; - for (const asset of assets) { - const token = - asset.token === undefined - ? await readGasPrc20(snap, chain) - : runtime.resolvePrc20(asset.token, chain); - if (token === zeroAddress) + const chain = rule.chainNamespace as CHAIN; + if (CHAIN_INFO[chain]?.vm !== VM.EVM) throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, - `no gas PRC20 registered for ${chain}` - ); - const namespace = await snap.read( - token, - PRC20_SOURCE_ABI, - 'SOURCE_CHAIN_NAMESPACE' - ); - if (namespace !== chain) - throw new AgenticError( - AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, - `PRC20 ${token} belongs to ${namespace}, not ${chain}` + `unsupported EVM rule chain ${chain}` ); - if (asset.token !== undefined) { - const expected = - typeof asset.token === 'string' - ? getAddress(asset.token) - : getAddress(asset.token.address); - if (expected === zeroAddress) { - if ((await readGasPrc20(snap, chain)) !== getAddress(token)) - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - 'resolved token is not the registered native gas token' - ); - } else { - const source = await readOriginToken(snap, token, chain); - if (source !== expected) - throw new AgenticError( - AGENTIC_ERROR_CODE.INVALID_RULE, - `PRC20 ${token} represents ${source}, not requested token ${expected}` + const cea = await resolveRuleDestination(runtime, wallet, chain); + const assets = rule.assets.length + ? rule.assets.map((a) => ({ + token: + typeof a.token === 'string' && getAddress(a.token) === zeroAddress + ? undefined + : a.token, + maxPerCall: a.maxPerCall, + maxTotal: withDefault( + a.maxTotal, + AGENTIC_DEFAULTS.universal.assetMaxTotal + ), + })) + : [{ token: undefined, maxPerCall: BigInt(0), maxTotal: BigInt(0) }]; + const resolved = []; + for (const [assetIndex, asset] of assets.entries()) { + let token: Address; + if (asset.token === undefined) token = await readGasPrc20(snap, chain); + else { + try { + token = runtime.resolvePrc20(asset.token, chain); + } catch (cause) { + throw tokenRouteUnavailable( + cause, + typeof asset.token === 'string' + ? asset.token + : asset.token.address, + chain, + assetIndex ); + } + } + if (token === zeroAddress) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `no gas PRC20 registered for ${chain}` + ); + const namespace = await snap.read( + token, + PRC20_SOURCE_ABI, + 'SOURCE_CHAIN_NAMESPACE' + ); + if (namespace !== chain) + throw new AgenticError( + AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, + `PRC20 ${token} belongs to ${namespace}, not ${chain}` + ); + if (asset.token !== undefined) { + const expected = + typeof asset.token === 'string' + ? getAddress(asset.token) + : getAddress(asset.token.address); + if (expected === zeroAddress) { + if ((await readGasPrc20(snap, chain)) !== getAddress(token)) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'resolved token is not the registered native gas token' + ); + } else { + const source = await readOriginToken(snap, token, chain); + if (source !== expected) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `PRC20 ${token} represents ${source}, not requested token ${expected}` + ); + } } + resolved.push({ + token: getAddress(token), + maxPerCall: asset.maxPerCall, + maxTotal: asset.maxTotal, + }); } - resolved.push({ - token: getAddress(token), - maxPerCall: asset.maxPerCall, - maxTotal: asset.maxTotal, - }); + universal.set(rule, { expectedCEA: getAddress(cea), assets: resolved }); + } catch (error) { + throw ruleErrorContext(error, ruleIndex, rule?.chainNamespace); } - universal.set(rule, { expectedCEA: getAddress(cea), assets: resolved }); } return prepareRules(rules, { owner, diff --git a/packages/core/src/lib/agentic/management/svm-public.ts b/packages/core/src/lib/agentic/management/svm-public.ts index 3fcc1409f..42289b8a1 100644 --- a/packages/core/src/lib/agentic/management/svm-public.ts +++ b/packages/core/src/lib/agentic/management/svm-public.ts @@ -13,6 +13,7 @@ import { deriveAgwSvmAta, svmKey } from '../codec/svm-accounts'; import { compileSvmIdlRule } from '../codec/svm-idl'; import { svmInvalid } from '../codec/svm-terms'; import { validateSvmTerms } from '../codec/svm-terms'; +import { tokenRouteUnavailable } from '../errors'; export function svmMetadata(runtime: AgenticRuntime) { return ( @@ -48,7 +49,7 @@ export async function preparePublicSvmRule( const tokenAccounts = new Map(), mints = new Set(); const sources: { key?: Hex; token: Address }[] = []; - for (const asset of requested) { + for (const [assetIndex, asset] of requested.entries()) { const source = typeof asset.token === 'string' ? asset.token : asset.token?.address; if (typeof source !== 'string') @@ -68,9 +69,15 @@ export async function preparePublicSvmRule( typeof asset.token === 'string' && key ? new PublicKey(hexToBytes(key)).toBase58() : asset.token; - const token = native - ? await readGasPrc20(snap, chain) - : runtime.resolvePrc20(sourceToken, chain); + let token: Address; + if (native) token = await readGasPrc20(snap, chain); + else { + try { + token = runtime.resolvePrc20(sourceToken, chain); + } catch (cause) { + throw tokenRouteUnavailable(cause, source, chain, assetIndex); + } + } if (caps.some((c) => c.token === getAddress(token))) throw svmInvalid('duplicate SVM asset'); caps.push({ diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index e80145513..84b7f84d3 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -3,6 +3,7 @@ import { AgenticError, AGENTIC_ERROR_CODE, capabilityUnavailable, + ruleConstraintLimit, } from '../errors'; import type { RuleAsset, RuleCall, Pin } from '../agentic.types'; import type { @@ -360,10 +361,15 @@ export function buildPresetApi( 'presetCalls', 'preset must contain 1..32 allowed calls including approvals' ); - if (calls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) > 32) - throw capabilityUnavailable( - 'presetPins', - 'preset expansion exceeds 32 pins; reduce the token list or split rules across wallets' + const conditionCount = calls.reduce( + (n, c) => n + (c.pins?.length ?? 0), + 0 + ); + if (conditionCount > 32) + throw ruleConstraintLimit( + conditionCount, + calls.length, + chain.chainNamespace ); const pairs = new Map(); for (const call of calls) { diff --git a/packages/core/src/lib/agentic/public-rule.ts b/packages/core/src/lib/agentic/public-rule.ts index 8dd8f9f0f..abf454047 100644 --- a/packages/core/src/lib/agentic/public-rule.ts +++ b/packages/core/src/lib/agentic/public-rule.ts @@ -38,6 +38,7 @@ import { UINT256_MAX } from './codec/defaults'; import { deriveAgwSvmCea, svmKey } from './codec/svm-accounts'; import type { AgenticRuntime } from './runtime'; import { CHAIN } from '../constants/enums'; +import { resolveRuleDestination } from './management/destination'; export const WALLET = 'agentic:wallet' as const; const PUBLIC_INPUT = Symbol('AGW public rule'); @@ -115,6 +116,15 @@ export async function normalizePublicRule( const rule = input as unknown as RuleInput; if ('format' in rule && rule.format === 'decoded') return input; const r = rule as Rule; + if (!isAddress(r.agent, { strict: false }) || /^0x0{40}$/i.test(r.agent)) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'Enter a valid agent wallet address.', + { + userMessage: 'Enter a valid agent wallet address.', + details: { field: 'agent' }, + } + ); rejectFields( r, [ @@ -196,12 +206,20 @@ export async function normalizePublicRule( throw invalid('native rules do not carry assets or maxGasPerCall'); const own = native ? wallet - : getAddress( - (await runtime.resolveCEA(wallet, r.chainNamespace as CHAIN)).cea - ); + : await resolveRuleDestination(runtime, wallet, r.chainNamespace as CHAIN); const nativeActions: NativeAction[] = []; const calls: UniversalRule['allowedCalls'] = []; - for (const call of r.allowedCalls) { + for (const [callIndex, call] of r.allowedCalls.entries()) { + if (!isAddress(call.target, { strict: false })) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'An allowed function has an invalid app address.', + { + userMessage: + 'An allowed function has an invalid app address. Select the app again.', + details: { field: `allowedCalls[${callIndex}].target`, callIndex }, + } + ); const pins = call.pins ?? []; const wirePins: ArgPinWire[] = []; const occupied = new Set(); diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 1783b4396..ebea7e1b5 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -1,4 +1,53 @@ import { PushChain } from './push-chain/push-chain'; +export { MarketError, JobError, JobSpecError } from './market'; +export type { + AgentCardRules, + MarketAssetToken, + MarketPin, + AgentCard, + AgentCardInput, + AgentCardSummary, + MarketNamespace, + MarketPreviewOptions, + MarketWriteOptions, + MarketOpenOptions, + MarketDepositOptions, + MarketDepositResult, + MarketDepositStep, + JobPlan, + OwnerIntent, +} from './market'; +export type { + JobNamespace, + Job, + JobStatus, + JobEvent, + JobWatchCursor, + JobWatchOptions, + CreateJobOptions, + FundJobOptions, +} from './job/types'; +export type { + JobSpec, + JobSpecTemplate, + JobRead, + PreparedJobRead, + JobReadInput, + JobCheck, + JobCheckTemplate, + LogicTree, + Evaluation, + Operation, + LogicKind, + AllowedMutability, + PrincipalMarker, + PrepareJobReadOptions, + ExecuteJobReadOptions, + BuildJobSpecOptions, + BuildJobSpecTemplateOptions, + JobSpecPreview, + PreviewJobSpecOptions, +} from './job-spec'; export { CHAIN } from './constants/enums'; export type { ConversionQuote } from './constants/tokens'; export type { diff --git a/packages/core/src/lib/job-spec/__tests__/codec.spec.ts b/packages/core/src/lib/job-spec/__tests__/codec.spec.ts new file mode 100644 index 000000000..aac64c3d7 --- /dev/null +++ b/packages/core/src/lib/job-spec/__tests__/codec.spec.ts @@ -0,0 +1,190 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + parseAbi, + parseAbiParameters, + zeroAddress, + zeroHash, +} from 'viem'; +import { + buildJobSpec, + buildJobSpecTemplate, + decodeJobSpec, + encodeJobSpec, +} from '../codec'; +import { prepareJobRead } from '../read'; +import { PRINCIPAL } from '../constants'; +import { JOB_SPEC_PARAM } from '../schema'; +import { resolveJobSpec, specOrigin } from '../helpers'; +import type { LogicTree } from '../types'; +const address = '0x1111111111111111111111111111111111111111'; +const abi = parseAbi(['function balanceOf(address) view returns (uint256)']); +const read = () => + prepareJobRead({ + chain: 'eip155:11155111', + target: address, + abi, + functionName: 'balanceOf', + args: [address], + }); +const check = { + read: 0, + evaluation: 'COMPARE' as const, + operation: 'GREATER_OR_EQUAL' as const, + target: BigInt(9), +}; +const spec = () => + buildJobSpec({ + reads: [read()], + checks: [check], + logic: 'ALL', + executeBy: 1800000000, + failFinalAt: 1800003600, + }); + +it('prepares exact selectors, binary output codes and literal zero arguments without inventing holes', () => { + const p = prepareJobRead({ + chain: 'eip155:11155111', + target: address, + abi, + functionName: 'balanceOf', + args: [zeroAddress], + }); + expect(p.selector).toBe('0x70a08231'); + expect(p.outputs).toBe('0x080101'); + expect(p.fills).toBeUndefined(); +}); +it('preserves WALLET/PARAM positions and resolves them independently of literal zeros', () => { + const r = prepareJobRead({ + chain: 'eip155:11155111', + target: address, + abi: parseAbi([ + 'function quote(address,uint256,uint256) view returns (uint256)', + ]), + functionName: 'quote', + args: ['agentic:wallet', { param: 0 }, BigInt(0)], + }); + expect(r.fills).toEqual([ + { word: 0, source: 'WALLET' }, + { word: 1, source: 'PARAM', param: 0 }, + ]); + const t = buildJobSpecTemplate({ + reads: [r], + checks: [{ ...check, target: PRINCIPAL }], + logic: 'ALL', + params: [{ min: BigInt(1), max: BigInt(20) }], + }); + const resolved = resolveJobSpec(t, { + principal: BigInt(50), + params: [BigInt(7)], + accounts: { 'eip155:11155111': address }, + origin: zeroHash, + executeBy: 1800000000, + settleWindow: 3600, + }); + expect( + decodeAbiParameters( + parseAbiParameters('address,uint256,uint256'), + resolved.reads[0].args + ) + ).toEqual([address, BigInt(7), BigInt(0)]); + expect(resolved.checks[0].target).toBe(BigInt(50)); + expect(t.reads[0].args).not.toBe(resolved.reads[0].args); +}); +it('encodes the deployed field and enum order and roundtrips a complete spec canonically', () => { + const bytes = encodeJobSpec(spec()); + const w = decodeAbiParameters([JOB_SPEC_PARAM], bytes)[0]; + expect(w.allowedMutability).toBe(0); + expect(w.logic[0].kind).toBe(0); + expect(w.origin).toBe(zeroHash); + expect(w.executeBy).toBe(BigInt(1800000000)); + expect(encodeJobSpec(decodeJobSpec(bytes))).toBe(bytes); +}); +it('roundtrips templates including principal-BPS targets and bounds', () => { + const t = buildJobSpecTemplate({ + reads: [read()], + checks: [{ ...check, target: { principalBps: BigInt(9999) } }], + logic: { kind: 'AT_LEAST', k: 1, of: [0] }, + }); + const bytes = encodeJobSpec(t); + expect(encodeJobSpec(decodeJobSpec(bytes, { format: 'template' }))).toBe( + bytes + ); +}); +it('keeps a valid non-DFS topological logic order when decoding unchanged signed bytes', () => { + const w = decodeAbiParameters([JOB_SPEC_PARAM], encodeJobSpec(spec()))[0]; + const bytes = encodeAbiParameters( + [JOB_SPEC_PARAM], + [ + { + ...w, + logic: [ + { kind: 0, checks: [], children: [2, 1], k: 0 }, + { kind: 1, checks: [0], children: [], k: 0 }, + { kind: 0, checks: [0], children: [], k: 0 }, + ], + }, + ] + ); + expect(encodeJobSpec(decodeJobSpec(bytes))).toBe(bytes); +}); +it('rejects cycles, invalid indexes and unsupported template holes before encoding', () => { + const tree: LogicTree = { kind: 'ALL', of: [] }; + tree.of.push(tree); + expect(() => + buildJobSpec({ + reads: [read()], + checks: [check], + logic: tree, + executeBy: 1, + failFinalAt: 2, + }) + ).toThrow(/cyclic/); + expect(() => + buildJobSpec({ + reads: [read()], + checks: [{ ...check, read: 2 }], + logic: 'ALL', + executeBy: 1, + failFinalAt: 2, + }) + ).toThrow(/index/); + expect(() => + prepareJobRead({ + chain: 'eip155:1', + target: address, + abi: parseAbi(['function x(address[]) view returns (uint256)']), + functionName: 'x', + args: [['agentic:wallet']], + }) + ).toThrow(); +}); +it('rejects malformed enums, trailing bytes, foreign wire and filled format ambiguity', () => { + const bytes = encodeJobSpec(spec()); + const w = decodeAbiParameters([JOB_SPEC_PARAM], bytes)[0]; + expect(() => decodeJobSpec((bytes + '00') as `0x${string}`)).toThrow( + /canonical/ + ); + expect(() => + decodeJobSpec( + encodeAbiParameters([JOB_SPEC_PARAM], [{ ...w, allowedMutability: 9 }]) + ) + ).toThrow(); + const foreign = encodeAbiParameters( + parseAbiParameters('(uint64,uint64,bytes[],bytes[],bytes[],uint8,bytes32)'), + [[BigInt(1), BigInt(2), [], [], [], 1, zeroHash]] + ); + expect(() => decodeJobSpec(foreign)).toThrow(); +}); +it('binds origin to marketplace, integer card version and principal with ABI words', () => { + const expected = + '0x' + + address.slice(2).padStart(64, '0') + + [BigInt(1), BigInt(2), BigInt(3)] + .map((n) => n.toString(16).padStart(64, '0')) + .join(''); + const { keccak256 } = jest.requireActual('viem'); + expect(specOrigin(address, BigInt(1), BigInt(2), BigInt(3))).toBe( + keccak256(expected) + ); +}); diff --git a/packages/core/src/lib/job-spec/__tests__/description.spec.ts b/packages/core/src/lib/job-spec/__tests__/description.spec.ts new file mode 100644 index 000000000..a3dda1f97 --- /dev/null +++ b/packages/core/src/lib/job-spec/__tests__/description.spec.ts @@ -0,0 +1,62 @@ +import { parseAbi } from 'viem'; +import { prepareJobRead, jobReadOptions } from '../read'; +import { buildJobSpec } from '../codec'; +import { describeJobSpec } from '../helpers'; +const token = '0x1111111111111111111111111111111111111111'; +const make = () => + buildJobSpec({ + reads: [ + prepareJobRead({ + chain: 'eip155:97', + target: token, + abi: parseAbi(['function totalSupply() view returns (uint256)']), + functionName: 'totalSupply', + }), + ], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: BigInt(499950000), + }, + ], + logic: 'ALL', + executeBy: 1800000000, + failFinalAt: 1800000060, + }); +it('retains ABI names and formats decimal targets without hiding their raw units', () => { + expect( + describeJobSpec(make(), { decimals: { [token]: 6 } }).checks[0] + ).toContain('totalSupply'); + expect( + describeJobSpec(make(), { decimals: { [token]: 6 } }).checks[0] + ).toContain('499.95 (499950000 raw units; 6 decimals)'); + expect(() => + describeJobSpec(make(), { decimals: { [token]: -1 } }) + ).toThrow(); +}); +it('labels signed percentage targets in percentage and basis points', () => { + const spec = make(); + spec.checks[0].evaluation = 'PERCENTAGE'; + spec.checks[0].target = BigInt(-250); + expect(describeJobSpec(spec).checks[0]).toContain('-2.5% (-250 bps)'); +}); +it('maps a resolved prepared read to the paid call and refuses unfilled markers before sending', () => { + const read = make().reads[0]; + expect(jobReadOptions(read)).toMatchObject({ + chain: 'eip155:97', + callData: read.selector, + }); + const hole = prepareJobRead({ + chain: 'eip155:97', + target: token, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: ['agentic:wallet'], + }); + expect(() => jobReadOptions(hole)).toThrow( + expect.objectContaining({ code: 'UNFILLED_PLACEHOLDER' }) + ); + expect(() => jobReadOptions({ ...read, outputs: '0x080101f' })).toThrow(); +}); diff --git a/packages/core/src/lib/job-spec/__tests__/preview.spec.ts b/packages/core/src/lib/job-spec/__tests__/preview.spec.ts new file mode 100644 index 000000000..f21653efe --- /dev/null +++ b/packages/core/src/lib/job-spec/__tests__/preview.spec.ts @@ -0,0 +1,320 @@ +import { + encodeAbiParameters, + keccak256, + parseAbi, + parseAbiParameters, + stringToHex, + toHex, + type Hex, +} from 'viem'; +import { buildJobSpec, buildJobSpecTemplate } from '../codec'; +import { prepareJobRead } from '../read'; +import { + bindJobPreviewAccounts, + decodeJobAnswer, + previewJobSpec, +} from '../preview'; +import { READ_OUTCOME } from '../../read-state/read-state.types'; +import { PRINCIPAL } from '../constants'; +import { encodeEvmQueryEnvelope } from '../../read-state/envelopes'; +import type { PreviewJobSpecOptions, JobSpec } from '../types'; +const target = '0x1111111111111111111111111111111111111111'; +const word = (n: bigint): Hex => encodeAbiParameters([{ type: 'int256' }], [n]); +const abi = parseAbi(['function value() view returns (uint256)']); +const read = prepareJobRead({ + chain: 'eip155:97', + target, + abi, + functionName: 'value', +}); +function spec( + evaluation: 'COMPARE' | 'NUMBER' | 'PERCENTAGE' = 'COMPARE' +): JobSpec { + return buildJobSpec({ + reads: [read], + checks: [ + { + read: 0, + evaluation, + operation: 'GREATER_OR_EQUAL', + target: evaluation === 'PERCENTAGE' ? BigInt(2500) : BigInt(5), + }, + ], + logic: 'ALL', + executeBy: 1800000000, + failFinalAt: 1800000060, + }); +} +function fixture(values: (Hex | null)[] = [word(BigInt(5))]) { + const prepareRead = jest.fn(async () => ({ value: BigInt(2) })); + const responses = values.map((resultData, i) => { + const response = { + requestId: toHex(BigInt(i + 1), { size: 32 }), + txHash: toHex(BigInt(99), { size: 32 }), + request: { + spec: { + account: { chainNamespace: 'eip155', chainId: '97' }, + minConfirmations: 1, + blockNumber: BigInt(1), + query: encodeEvmQueryEnvelope( + { type: 'contractCall', target, callData: read.selector }, + { blockNumber: BigInt(1) } + ).encoded, + }, + }, + outcome: + resultData === null ? READ_OUTCOME.SOURCE_ERROR : READ_OUTCOME.SUCCESS, + raw: resultData === null ? null : { resultData }, + fees: { paid: BigInt(2) }, + errorMsg: '', + wait: jest.fn(), + }; + response.wait.mockResolvedValue(response); + return response; + }); + const executeReads = jest.fn(async () => ({ reads: responses })); + const trackRead = jest.fn(async () => responses[0]); + const options = { + client: { universal: { prepareRead, executeReads, trackRead } }, + } as unknown as PreviewJobSpecOptions; + return { options, prepareRead, executeReads, responses, trackRead }; +} +it('prepares raw calls through universal reads, pays the batch and evaluates comparisons', async () => { + const f = fixture(); + const result = await previewJobSpec(spec(), { + ...f.options, + maxTotalFee: BigInt(2), + }); + expect(f.prepareRead).toHaveBeenCalledWith( + target, + expect.objectContaining({ + chain: 'eip155:97', + callData: read.selector, + minConfirmations: 1, + }) + ); + expect(f.executeReads).toHaveBeenCalledWith( + [{ value: BigInt(2) }], + expect.objectContaining({ waitForCompletion: false }) + ); + expect(result).toMatchObject({ + pass: true, + checks: [{ value: BigInt(5), pass: true }], + fees: { quoted: BigInt(2), paid: BigInt(2) }, + }); +}); +it('checks the total fee cap after preparation and before any paid request', async () => { + const f = fixture(); + await expect( + previewJobSpec(spec(), { ...f.options, maxTotalFee: BigInt(1) }) + ).rejects.toMatchObject({ code: 'PREVIEW_FEE_EXCEEDED' }); + expect(f.executeReads).not.toHaveBeenCalled(); +}); +it('retains all request IDs after a wait timeout and resumes with no additional payment', async () => { + const f = fixture(); + f.responses[0].wait.mockRejectedValueOnce(new Error('Polling timed out')); + await expect(previewJobSpec(spec(), f.options)).rejects.toMatchObject({ + code: 'PREVIEW_INCOMPLETE', + details: { requestIds: [f.responses[0].requestId] }, + }); + const result = await previewJobSpec(spec(), { + ...f.options, + resume: [f.responses[0].requestId], + maxTotalFee: BigInt(0), + }); + expect(result).toMatchObject({ + pass: true, + fees: { paid: BigInt(2), newlyPaid: BigInt(0) }, + }); + expect(f.executeReads).toHaveBeenCalledTimes(1); +}); +it('rejects a recorded request for a different call before evaluating its result', async () => { + const f = fixture(); + const changed = spec(); + changed.reads[0].target = '0x2222222222222222222222222222222222222222'; + await expect( + previewJobSpec(changed, { + ...f.options, + resume: [f.responses[0].requestId], + }) + ).rejects.toMatchObject({ code: 'PREVIEW_REQUEST_MISMATCH' }); + expect(f.prepareRead).not.toHaveBeenCalled(); + expect(f.executeReads).not.toHaveBeenCalled(); + expect(f.responses[0].wait).not.toHaveBeenCalled(); +}); +it('does not invent baselines or treat unavailable source reads as failures of the criteria', async () => { + const f = fixture(); + expect(await previewJobSpec(spec('NUMBER'), f.options)).toMatchObject({ + pass: null, + checks: [{ pass: null, value: null, reason: 'BASELINE_REQUIRED' }], + }); + const failed = fixture([null]); + expect(await previewJobSpec(spec(), failed.options)).toMatchObject({ + pass: null, + reads: [{ decoded: null }], + checks: [{ reason: 'READ_UNAVAILABLE' }], + }); +}); +it('uses explicit baseline values for signed changes and basis-point percentages', async () => { + const f = fixture([word(BigInt(125))]); + expect( + await previewJobSpec(spec('PERCENTAGE'), { + ...f.options, + baseline: [BigInt(100)], + }) + ).toMatchObject({ pass: true, checks: [{ value: BigInt(2500) }] }); + expect( + await previewJobSpec(spec('PERCENTAGE'), { + ...f.options, + baseline: [BigInt(0)], + }) + ).toMatchObject({ pass: null, checks: [{ reason: 'ZERO_BASELINE' }] }); + const falling = fixture([word(BigInt(5))]); + expect( + await previewJobSpec(spec('NUMBER'), { + ...falling.options, + baseline: [BigInt(10)], + }) + ).toMatchObject({ pass: false, checks: [{ value: BigInt(-5) }] }); +}); +it('propagates unknowns with ALL, ANY and AT_LEAST without hiding conclusive results', async () => { + const s = spec(); + s.reads.push({ ...s.reads[0] }); + s.checks.push({ ...s.checks[0], read: 1 }); + const f = fixture([word(BigInt(5)), null]); + s.logic = { kind: 'ANY', of: [0, 1] }; + expect((await previewJobSpec(s, f.options)).pass).toBe(true); + s.logic = { kind: 'ALL', of: [0, 1] }; + expect((await previewJobSpec(s, f.options)).pass).toBeNull(); + s.logic = { kind: 'AT_LEAST', k: 1, of: [0, 1] }; + expect((await previewJobSpec(s, f.options)).pass).toBe(true); + const no = fixture([word(BigInt(0)), null]); + s.logic = { kind: 'ALL', of: [0, 1] }; + expect((await previewJobSpec(s, no.options)).pass).toBe(false); +}); +it('resolves template accounts explicitly and refuses missing principal before preparing reads', async () => { + const hole = prepareJobRead({ + chain: 'eip155:97', + target, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: ['agentic:wallet'], + }); + const template = buildJobSpecTemplate({ + reads: [hole], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: PRINCIPAL, + }, + ], + logic: 'ALL', + }); + const f = fixture(); + await expect(previewJobSpec(template, f.options)).rejects.toMatchObject({ + code: 'UNFILLED_PLACEHOLDER', + }); + expect(f.prepareRead).not.toHaveBeenCalled(); + await expect( + previewJobSpec(template, { + ...f.options, + principal: BigInt(5), + wallet: target, + }) + ).rejects.toMatchObject({ code: 'UNFILLED_PLACEHOLDER' }); + const result = await previewJobSpec(template, { + ...f.options, + principal: BigInt(5), + accounts: { 'eip155:97': target }, + }); + expect(result.pass).toBe(true); + expect(f.prepareRead).toHaveBeenCalledWith( + target, + expect.objectContaining({ + callData: '0x70a08231' + target.slice(2).padStart(64, '0'), + }) + ); +}); +it('decodes ABI offsets across tuples, dynamic tuple arrays and fixed arrays', () => { + const complex = prepareJobRead({ + chain: 'eip155:97', + target, + abi: parseAbi([ + 'function result() view returns ((uint256,address)[],uint256[2],string)', + ]), + functionName: 'result', + }); + const data = encodeAbiParameters( + parseAbiParameters('(uint256,address)[],uint256[2],string'), + [ + [ + [BigInt(7), target], + [BigInt(9), target], + ], + [BigInt(4), BigInt(8)], + 'ok', + ] + ); + const decode = (field: number[]) => + decodeJobAnswer({ ...complex, field, minConfirmations: 1 }, data); + expect(decode([0])).toBe(BigInt(2)); + expect(decode([0, 1, 0])).toBe(BigInt(9)); + expect(decode([1, 1])).toBe(BigInt(8)); + expect(decode([2])).toBe( + BigInt.asIntN(256, BigInt(keccak256(stringToHex('ok')))) + ); +}); +it('rejects truncated return data, invalid bools, unsigned overflow and out-of-range dynamic fields', () => { + const r = { ...read, field: [0], minConfirmations: 1 }; + expect(() => decodeJobAnswer(r, '0x')).toThrow(); + expect(() => decodeJobAnswer(r, word(BigInt(-1)))).toThrow(); + expect(() => + decodeJobAnswer({ ...r, outputs: '0x080103' }, word(BigInt(2))) + ).toThrow(); + expect(decodeJobAnswer({ ...r, outputs: '0x080102' }, word(BigInt(-1)))).toBe( + BigInt(-1) + ); +}); +it('resolves template holes with the configured client and snapshots criteria before paid reads', async () => { + const f = fixture(); + const destination = '0x2222222222222222222222222222222222222222' as const; + const resolve = jest.fn(async () => destination); + bindJobPreviewAccounts(f.options.client.universal, resolve); + const hole = prepareJobRead({ + chain: 'eip155:97', + target, + abi: parseAbi(['function balanceOf(address) view returns (uint256)']), + functionName: 'balanceOf', + args: ['agentic:wallet'], + }); + const template = buildJobSpecTemplate({ + reads: [hole], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: BigInt(5), + }, + ], + logic: 'ALL', + }); + f.prepareRead.mockImplementation(async () => { + template.checks[0].target = BigInt(999); + return { value: BigInt(2) }; + }); + const result = await previewJobSpec(template, { + ...f.options, + wallet: target, + }); + expect(resolve).toHaveBeenCalledWith(target, 'eip155:97'); + expect(f.prepareRead).toHaveBeenCalledWith( + target, + expect.objectContaining({ + callData: '0x70a08231' + destination.slice(2).padStart(64, '0'), + }) + ); + expect(result.pass).toBe(true); +}); diff --git a/packages/core/src/lib/job-spec/codec.ts b/packages/core/src/lib/job-spec/codec.ts new file mode 100644 index 000000000..139945af1 --- /dev/null +++ b/packages/core/src/lib/job-spec/codec.ts @@ -0,0 +1,453 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + isHex, + zeroHash, + type AbiParameter, + type Hex, +} from 'viem'; +import { JobSpecError } from '../market/errors'; +import { + EVALUATIONS, + OPERATIONS, + LOGIC_KINDS, + MUTABILITIES, + PRINCIPAL, +} from './constants'; +import { JOB_SPEC_PARAM, JOB_TEMPLATE_PARAM } from './schema'; +import { inheritReadLabel, validateJobRead } from './read'; +import type { + BuildJobSpecOptions, + BuildJobSpecTemplateOptions, + JobCheckTemplate, + JobRead, + JobReadInput, + JobSpec, + JobSpecTemplate, + LogicTree, +} from './types'; + +interface LogicWire { + kind: number; + checks: readonly number[]; + children: readonly number[]; + k: number; +} +interface CheckWire { + read: number; + evaluation: number; + operation: number; + target: bigint; +} +interface TemplateCheckWire { + read: number; + evaluation: number; + operation: number; + source: number; + value: bigint; +} +interface ReadWire extends Omit { + fills?: { word: number; source: number; param: number }[]; +} +interface SpecWire { + reads: ReadWire[]; + checks: CheckWire[]; + logic: LogicWire[]; + allowedMutability: number; + origin: Hex; + executeBy: bigint; + failFinalAt: bigint; +} +interface TemplateWire { + reads: ReadWire[]; + checks: TemplateCheckWire[]; + logic: LogicWire[]; + allowedMutability: number; + params: { min: bigint; max: bigint }[]; +} +const originalLogic = new WeakMap< + LogicTree, + { fingerprint: string; wire: LogicWire[] } +>(); +const fail = (message: string, code = 'INVALID_JOB_SPEC'): never => { + throw new JobSpecError(code, message); +}; +const fingerprint = (x: unknown): string => + JSON.stringify(x, (_, v) => (typeof v === 'bigint' ? v.toString() : v)); +export function int256(value: bigint, what = 'Target'): bigint { + if ( + typeof value !== 'bigint' || + value < -(BigInt(1) << BigInt(255)) || + value >= BigInt(1) << BigInt(255) + ) + fail(`${what} must fit int256.`); + return value; +} +function count( + values: readonly unknown[], + max: number, + what: string, + min = 1 +): void { + if (!Array.isArray(values) || values.length < min || values.length > max) + fail( + `${what} must contain ${min}..${max} entries.`, + `TOO_MANY_${what.toUpperCase()}` + ); +} +function timestamp(n: number): void { + if (!Number.isSafeInteger(n) || n < 0) + fail('Timestamps must be nonnegative safe integers.'); +} +function enumIndex(values: T[], value: T, name: string): number { + const i = values.indexOf(value); + if (i < 0) fail(`Unknown ${name}.`); + return i; +} +export function flattenLogic(tree: LogicTree, checkCount: number): LogicWire[] { + const nodes: LogicWire[] = []; + const active = new Set(); + function visit(t: LogicTree): number { + if (!t || active.has(t) || nodes.length >= 32) + fail('Logic is cyclic or exceeds 32 operators.', 'BAD_TREE'); + active.add(t); + const index = nodes.length; + const kind = enumIndex(LOGIC_KINDS, t.kind, 'logic kind'); + if (!Array.isArray(t.of) || !t.of.length || t.of.length > 255) + fail('Logic operators need 1..255 operands.', 'BAD_TREE'); + const k = t.kind === 'AT_LEAST' ? t.k ?? 0 : 0; + if ( + !Number.isInteger(k) || + (t.kind === 'AT_LEAST' && (k < 1 || k > t.of.length)) || + (t.kind !== 'AT_LEAST' && t.k !== undefined && t.k !== 0) + ) + fail('Invalid AT_LEAST threshold.', 'BAD_TREE'); + const n: LogicWire = { kind, checks: [], children: [], k: k }; + nodes.push(n); + for (const item of t.of) { + if (typeof item === 'number') { + if ( + !Number.isInteger(item) || + item < 0 || + item >= checkCount || + n.checks.includes(item) + ) + fail('Dangling or duplicated check index.', 'BAD_TREE'); + (n.checks as number[]).push(item); + } else (n.children as number[]).push(visit(item)); + } + active.delete(t); + return index; + } + visit(tree); + const saved = originalLogic.get(tree); + return saved?.fingerprint === fingerprint(tree) ? saved.wire : nodes; +} +function nestLogic(nodes: LogicWire[], checkCount: number): LogicTree { + count(nodes, 32, 'logic'); + const reached = new Set(); + let expanded = 0; + function visit(index: number, depth: number): LogicTree { + if (++expanded > 512 || depth > 32) + fail('Stored logic is too large to describe as a tree.', 'BAD_TREE'); + const n = nodes[index]; + if (!n || !LOGIC_KINDS[n.kind]) + fail('Unknown stored logic kind.', 'BAD_TREE'); + reached.add(index); + if ( + n.checks.some((i) => !Number.isInteger(i) || i < 0 || i >= checkCount) || + new Set(n.checks).size !== n.checks.length + ) + fail('Invalid stored check reference.', 'BAD_TREE'); + if ( + n.children.some((i) => i <= index || i >= nodes.length) || + new Set(n.children).size !== n.children.length + ) + fail('Invalid stored child reference.', 'BAD_TREE'); + const t: LogicTree = { + kind: LOGIC_KINDS[n.kind], + of: [...n.checks, ...n.children.map((i) => visit(i, depth + 1))], + ...(n.kind === 2 ? { k: n.k } : {}), + }; + if (n.kind !== 2 && n.k !== 0) + fail('Unexpected threshold on stored logic.', 'BAD_TREE'); + return t; + } + const tree = visit(0, 0); + if (reached.size !== nodes.length) + fail('Stored logic has unreachable operators.', 'BAD_TREE'); + // Preserve an unchanged, valid topological order instead of rewriting signed bytes. + originalLogic.set(tree, { fingerprint: fingerprint(tree), wire: nodes }); + flattenLogic(tree, checkCount); + return tree; +} +function normalizeReads(values: BuildJobSpecOptions['reads']): JobRead[] { + count(values, 16, 'reads'); + return values.map((input) => { + const wrapper = input as JobReadInput; + const source = 'read' in input ? wrapper.read : input; + const read: JobRead = { + ...source, + field: [...(wrapper.field ?? [0])], + minConfirmations: wrapper.minConfirmations ?? 1, + ...(source.fills ? { fills: source.fills.map((f) => ({ ...f })) } : {}), + }; + inheritReadLabel(source, read); + return read; + }); +} +function validate(spec: JobSpec | JobSpecTemplate): void { + count(spec.reads, 16, 'reads'); + count(spec.checks, 16, 'checks'); + enumIndex(MUTABILITIES, spec.allowedMutability, 'allowedMutability'); + const template = 'params' in spec; + if (template) { + count(spec.params, 16, 'params', 0); + for (const p of spec.params) + if (int256(p.min) > int256(p.max)) + fail('Parameter minimum exceeds its maximum.'); + } else { + timestamp(spec.executeBy); + timestamp(spec.failFinalAt); + if ( + spec.failFinalAt <= spec.executeBy || + !/^0x[\da-f]{64}$/i.test(spec.origin) + ) + fail('Invalid JobSpec timing or origin.'); + } + for (const r of spec.reads) { + validateJobRead(r); + if ( + !Number.isInteger(r.minConfirmations) || + r.minConfirmations < 1 || + r.minConfirmations > 65535 + ) + fail('Read confirmations must be 1..65535.'); + const words = new Set(); + for (const f of r.fills ?? []) { + if (!template) + fail( + 'A complete JobSpec cannot contain placeholders.', + 'UNFILLED_PLACEHOLDER' + ); + if ( + !Number.isInteger(f.word) || + f.word < 0 || + f.word > 255 || + f.word * 32 + 32 > (r.args.length - 2) / 2 || + words.has(f.word) + ) + fail('Invalid or duplicate fill word.'); + words.add(f.word); + if (!['WALLET', 'PARAM'].includes(f.source)) fail('Unknown fill source.'); + if ( + f.source === 'PARAM' && + (!Number.isInteger(f.param) || + (f.param ?? -1) < 0 || + (f.param ?? -1) >= (spec as JobSpecTemplate).params.length) + ) + fail('Fill parameter is out of range.'); + } + } + for (const c of spec.checks) { + if (!Number.isInteger(c.read) || c.read < 0 || c.read >= spec.reads.length) + fail('Check read index is out of range.'); + enumIndex(EVALUATIONS, c.evaluation, 'evaluation'); + enumIndex(OPERATIONS, c.operation, 'operation'); + if (c.evaluation === 'BOOLEAN' && c.operation !== 'EQUAL') + fail('BOOLEAN checks use EQUAL.'); + if (typeof c.target === 'bigint') int256(c.target); + else if (!template) + fail( + 'Complete JobSpec targets must be resolved integers.', + 'UNFILLED_PLACEHOLDER' + ); + else targetWire(c.target, (spec as JobSpecTemplate).params.length); + } + flattenLogic(spec.logic, spec.checks.length); +} +function targetWire( + target: JobCheckTemplate['target'], + params: number +): { source: number; value: bigint } { + if (typeof target === 'bigint') return { source: 0, value: int256(target) }; + if (target === PRINCIPAL) return { source: 1, value: BigInt(10000) }; + if (target === 'agentic:wallet') return { source: 3, value: BigInt(0) }; + if (target && typeof target === 'object' && 'principalBps' in target) { + if (int256(target.principalBps) <= BigInt(0)) + fail('principalBps must be positive.'); + return { source: 1, value: target.principalBps }; + } + if ( + target && + typeof target === 'object' && + 'param' in target && + Number.isInteger(target.param) && + target.param >= 0 && + target.param < params + ) + return { source: 2, value: BigInt(target.param) }; + return fail('Unknown or out-of-range template target.'); +} +export function buildJobSpec(options: BuildJobSpecOptions): JobSpec { + const spec: JobSpec = { + reads: normalizeReads(options.reads), + checks: options.checks.map((c) => ({ ...c })), + logic: + typeof options.logic === 'string' + ? { kind: options.logic, of: options.checks.map((_, i) => i) } + : options.logic, + allowedMutability: options.allowedMutability ?? 'NONE', + origin: options.origin ?? zeroHash, + executeBy: options.executeBy, + failFinalAt: options.failFinalAt, + }; + validate(spec); + if (options.expiredAt !== undefined) { + timestamp(options.expiredAt); + if (options.failFinalAt >= options.expiredAt) + fail('failFinalAt must precede job expiry.', 'FAIL_WINDOW_THIN'); + } + return spec; +} +export function buildJobSpecTemplate( + options: BuildJobSpecTemplateOptions +): JobSpecTemplate { + const t: JobSpecTemplate = { + reads: normalizeReads(options.reads), + checks: options.checks.map((c) => ({ ...c })), + logic: + typeof options.logic === 'string' + ? { kind: options.logic, of: options.checks.map((_, i) => i) } + : options.logic, + allowedMutability: options.allowedMutability ?? 'NONE', + params: (options.params ?? []).map((p) => ({ ...p })), + }; + validate(t); + return t; +} +export function encodeJobSpec(spec: JobSpec | JobSpecTemplate): Hex { + validate(spec); + const base = { + reads: spec.reads.map(({ fills, ...r }) => + 'params' in spec + ? { + ...r, + fills: (fills ?? []).map((f) => ({ + word: f.word, + source: f.source === 'WALLET' ? 0 : 1, + param: f.param ?? 0, + })), + } + : r + ), + logic: flattenLogic(spec.logic, spec.checks.length), + allowedMutability: MUTABILITIES.indexOf(spec.allowedMutability), + }; + const checks = spec.checks.map((c) => ({ + read: c.read, + evaluation: EVALUATIONS.indexOf(c.evaluation), + operation: OPERATIONS.indexOf(c.operation), + ...('params' in spec + ? targetWire(c.target, spec.params.length) + : { target: c.target }), + })); + const value = + 'params' in spec + ? { ...base, checks, params: spec.params } + : { + ...base, + checks, + origin: spec.origin, + executeBy: BigInt(spec.executeBy), + failFinalAt: BigInt(spec.failFinalAt), + }; + return encodeAbiParameters( + [('params' in spec ? JOB_TEMPLATE_PARAM : JOB_SPEC_PARAM) as AbiParameter], + [value] + ); +} +/** Known callers pass the format; inference requires one canonical validated decode. */ +export function decodeJobSpec( + bytes: Hex, + options?: { format?: 'spec' | 'template' } +): JobSpec | JobSpecTemplate { + if (!isHex(bytes) || bytes.length > 524290) + fail('Invalid or oversized JobSpec encoding.'); + const valid: (JobSpec | JobSpecTemplate)[] = []; + for (const template of options?.format + ? [options.format === 'template'] + : [false, true]) { + try { + const w = decodeAbiParameters( + [(template ? JOB_TEMPLATE_PARAM : JOB_SPEC_PARAM) as AbiParameter], + bytes + )[0] as SpecWire & TemplateWire; + const reads: JobRead[] = w.reads.map(({ fills: rawFills, ...r }) => ({ + ...r, + ...(template + ? { + fills: (rawFills ?? []).map((f) => { + if (f.source > 1 || (f.source === 0 && f.param !== 0)) + fail('Invalid stored fill.'); + return { + word: f.word, + source: + f.source === 0 ? ('WALLET' as const) : ('PARAM' as const), + ...(f.source === 1 ? { param: f.param } : {}), + }; + }), + } + : {}), + })); + const checks = w.checks.map((c) => { + const common = { + read: c.read, + evaluation: EVALUATIONS[c.evaluation], + operation: OPERATIONS[c.operation], + }; + if (!template) return { ...common, target: c.target }; + const t = c as unknown as TemplateCheckWire; + const target = + t.source === 0 + ? t.value + : t.source === 1 + ? t.value === BigInt(10000) + ? PRINCIPAL + : { principalBps: t.value } + : t.source === 2 + ? { param: Number(t.value) } + : t.source === 3 && t.value === BigInt(0) + ? ('agentic:wallet' as const) + : fail('Unknown template target.'); + return { ...common, target }; + }); + const common = { + reads, + checks, + logic: nestLogic(w.logic, checks.length), + allowedMutability: MUTABILITIES[w.allowedMutability], + }; + const spec = ( + template + ? { ...common, params: w.params } + : { + ...common, + origin: w.origin, + executeBy: Number(w.executeBy), + failFinalAt: Number(w.failFinalAt), + } + ) as JobSpec | JobSpecTemplate; + if (encodeJobSpec(spec).toLowerCase() === bytes.toLowerCase()) + valid.push(spec); + } catch { + /* Wrong shape, unsupported wire, malformed enums or noncanonical bytes. */ + } + } + if (valid.length !== 1) + fail( + 'The bytes do not identify one supported canonical deployed-v2 JobSpec format.', + 'UNSUPPORTED_JOB_SPEC' + ); + return valid[0]; +} diff --git a/packages/core/src/lib/job-spec/constants.ts b/packages/core/src/lib/job-spec/constants.ts new file mode 100644 index 000000000..fe6b4c168 --- /dev/null +++ b/packages/core/src/lib/job-spec/constants.ts @@ -0,0 +1,32 @@ +export const JOB_VOCABULARY = Object.freeze({ + EVALUATION: Object.freeze({ + BOOLEAN: 'BOOLEAN', + COMPARE: 'COMPARE', + NUMBER: 'NUMBER', + PERCENTAGE: 'PERCENTAGE', + } as const), + OPERATION: Object.freeze({ + EQUAL: 'EQUAL', + NOT_EQUAL: 'NOT_EQUAL', + GREATER_THAN: 'GREATER_THAN', + GREATER_OR_EQUAL: 'GREATER_OR_EQUAL', + LESS_THAN: 'LESS_THAN', + LESS_OR_EQUAL: 'LESS_OR_EQUAL', + } as const), + LOGIC: Object.freeze({ + ALL: 'ALL', + ANY: 'ANY', + AT_LEAST: 'AT_LEAST', + } as const), + ALLOWED_MUTABILITY: Object.freeze({ + NONE: 'NONE', + CLIENT: 'CLIENT', + PROVIDER: 'PROVIDER', + BOTH: 'BOTH', + } as const), +}); +export const PRINCIPAL = 'agentic:principal' as const; +export const EVALUATIONS = Object.values(JOB_VOCABULARY.EVALUATION); +export const OPERATIONS = Object.values(JOB_VOCABULARY.OPERATION); +export const LOGIC_KINDS = Object.values(JOB_VOCABULARY.LOGIC); +export const MUTABILITIES = Object.values(JOB_VOCABULARY.ALLOWED_MUTABILITY); diff --git a/packages/core/src/lib/job-spec/helpers.ts b/packages/core/src/lib/job-spec/helpers.ts new file mode 100644 index 000000000..31fd3ab5c --- /dev/null +++ b/packages/core/src/lib/job-spec/helpers.ts @@ -0,0 +1,205 @@ +import { + formatUnits, + getAddress, + keccak256, + toHex, + type Address, + type Hex, +} from 'viem'; +import { encodeJobSpec, int256 } from './codec'; +import { PRINCIPAL } from './constants'; +import { JobSpecError } from '../market/errors'; +import type { JobSpec, JobSpecTemplate, LogicTree } from './types'; +import { inheritReadLabel, jobReadLabel } from './read'; +export function resolveJobSpec( + template: JobSpecTemplate, + ctx: { + principal: bigint; + params: bigint[]; + accounts: Record; + origin: Hex; + executeBy: number; + settleWindow: number; + } +): JobSpec { + encodeJobSpec(template); + if ( + ctx.params.length !== template.params.length || + ctx.params.some( + (n, i) => int256(n) < template.params[i].min || n > template.params[i].max + ) + ) + throw new JobSpecError( + 'PARAM_OUT_OF_RANGE', + 'The job parameters do not match the template bounds.' + ); + const account = (read: number): Address => { + const r = template.reads[read]; + const a = ctx.accounts[`${r.chainNamespace}:${r.chainId}`]; + if (!a) + throw new JobSpecError( + 'UNFILLED_PLACEHOLDER', + 'Resolve the wallet account on every read chain.' + ); + return getAddress(a); + }; + const reads = template.reads.map((r, i) => { + let args = r.args; + for (const f of r.fills ?? []) { + const value = + f.source === 'WALLET' + ? BigInt(account(i)) + : BigInt.asUintN(256, ctx.params[f.param ?? 0]); + const at = 2 + f.word * 64; + args = (args.slice(0, at) + + toHex(value, { size: 32 }).slice(2) + + args.slice(at + 64)) as Hex; + } + const read = { ...r }; + delete read.fills; + const resolved = { ...read, args }; + inheritReadLabel(r, resolved); + return resolved; + }); + const checks = template.checks.map((c) => { + const t = c.target; + const target = + typeof t === 'bigint' + ? t + : t === PRINCIPAL + ? ctx.principal + : t === 'agentic:wallet' + ? BigInt(account(c.read)) + : 'param' in t + ? ctx.params[t.param] + : (() => { + const product = ctx.principal * t.principalBps; + if (product >= BigInt(1) << BigInt(256)) + throw new JobSpecError( + 'TARGET_OVERFLOW', + 'The principal-based target overflows uint256.' + ); + return product / BigInt(10000); + })(); + return { ...c, target: int256(target) }; + }); + const spec = { + reads, + checks, + logic: template.logic, + allowedMutability: template.allowedMutability, + origin: ctx.origin, + executeBy: ctx.executeBy, + failFinalAt: ctx.executeBy + ctx.settleWindow, + }; + encodeJobSpec(spec); + return spec; +} +const words = { + EQUAL: 'equals', + NOT_EQUAL: 'does not equal', + GREATER_THAN: 'is greater than', + GREATER_OR_EQUAL: 'is at least', + LESS_THAN: 'is less than', + LESS_OR_EQUAL: 'is at most', +}; +export function describeJobSpec( + spec: JobSpec | JobSpecTemplate, + options?: { principal?: bigint; decimals?: Record } +): { checks: string[]; logic: string; timing: string } { + encodeJobSpec(spec); + if (options?.principal !== undefined && int256(options.principal) < BigInt(0)) + throw new JobSpecError( + 'INVALID_DESCRIPTION_OPTIONS', + 'Principal must be nonnegative.' + ); + const decimals = new Map(); + for (const [address, value] of Object.entries(options?.decimals ?? {})) { + if (!Number.isInteger(value) || value < 0 || value > 255) + throw new JobSpecError( + 'INVALID_DESCRIPTION_OPTIONS', + 'Token decimals must be integers from 0 to 255.' + ); + try { + decimals.set(getAddress(address).toLowerCase(), value); + } catch (cause) { + throw new JobSpecError( + 'INVALID_DESCRIPTION_OPTIONS', + 'Decimals must be keyed by token address.', + undefined, + cause + ); + } + } + function logic(t: LogicTree): string { + return `${ + t.kind === 'ALL' ? 'All' : t.kind === 'ANY' ? 'Any' : `At least ${t.k}` + } of (${t.of + .map((x) => (typeof x === 'number' ? `check ${x + 1}` : logic(x))) + .join(', ')})`; + } + return { + checks: spec.checks.map((c) => { + const r = spec.reads[c.read]; + const t = c.target; + const resolvedTarget = + typeof t === 'bigint' + ? t + : t === PRINCIPAL + ? options?.principal ?? 'principal' + : t === 'agentic:wallet' + ? 'the wallet on this chain' + : 'param' in t + ? `parameter ${t.param + 1}` + : options?.principal !== undefined + ? (options.principal * t.principalBps) / BigInt(10000) + : `${t.principalBps} basis points of principal`; + const precision = decimals.get(r.target.toLowerCase()); + const target = + typeof resolvedTarget === 'bigint' + ? c.evaluation === 'PERCENTAGE' + ? `${formatUnits(resolvedTarget, 2)}% (${resolvedTarget} bps)` + : precision !== undefined && c.evaluation !== 'BOOLEAN' + ? `${formatUnits( + resolvedTarget, + precision + )} (${resolvedTarget} raw units; ${precision} decimals)` + : `${resolvedTarget} (raw units)` + : `${resolvedTarget}${ + c.evaluation === 'PERCENTAGE' ? ' bps' : ' (raw units)' + }`; + const name = jobReadLabel(r); + return `Read ${c.read + 1}: ${r.target} ${name ? name + ' ' : ''}${ + r.selector + } on ${r.chainNamespace}:${r.chainId}, field [${r.field.join(',')}], ${ + c.evaluation + } ${words[c.operation]} ${target}.`; + }), + logic: logic(spec.logic), + timing: + 'executeBy' in spec + ? `Execute by ${new Date( + spec.executeBy * 1000 + ).toISOString()}; failure is final after ${new Date( + spec.failFinalAt * 1000 + ).toISOString()}.` + : '', + }; +} +export function specOrigin( + marketplace: Address, + cardId: bigint, + version: bigint, + principal: bigint +): Hex { + // Imported here to keep the public helpers independent of client state. + return keccak256( + ('0x' + + [ + marketplace.slice(2).padStart(64, '0'), + ...[cardId, version, principal].map((n) => + toHex(n, { size: 32 }).slice(2) + ), + ].join('')) as Hex + ); +} diff --git a/packages/core/src/lib/job-spec/index.ts b/packages/core/src/lib/job-spec/index.ts new file mode 100644 index 000000000..8b1818578 --- /dev/null +++ b/packages/core/src/lib/job-spec/index.ts @@ -0,0 +1,19 @@ +import { + buildJobSpec, + buildJobSpecTemplate, + encodeJobSpec, + decodeJobSpec, +} from './codec'; +import { describeJobSpec } from './helpers'; +import { prepareJobRead } from './read'; +import { previewJobSpec } from './preview'; +export const jobSpecUtils = Object.freeze({ + buildJobSpec, + buildJobSpecTemplate, + encodeJobSpec, + decodeJobSpec, + describeJobSpec, + prepareRead: prepareJobRead, + previewJobSpec, +}); +export type * from './types'; diff --git a/packages/core/src/lib/job-spec/preview.ts b/packages/core/src/lib/job-spec/preview.ts new file mode 100644 index 000000000..c409fe0f5 --- /dev/null +++ b/packages/core/src/lib/job-spec/preview.ts @@ -0,0 +1,396 @@ +import { + decodeAbiParameters, + getAddress, + keccak256, + zeroHash, + type Address, + type Hex, +} from 'viem'; +import type { CHAIN } from '../constants/enums'; +import { READ_OUTCOME } from '../read-state/read-state.types'; +import type { UniversalReadResponse } from '../read-state/read-state.types'; +import { decodeEvmQueryEnvelope } from '../read-state/envelopes'; +import type { ReadPrepareOptions } from '../read-state/read-params'; +import { JobSpecError } from '../market/errors'; +import { decodeJobSpec, encodeJobSpec, int256 } from './codec'; +import { resolveJobSpec } from './helpers'; +import { parseOutputs, type OutputNode } from './read'; +import { PRINCIPAL } from './constants'; +import type { + JobRead, + JobSpec, + JobSpecTemplate, + JobSpecPreview, + LogicTree, + Operation, + PreviewJobSpecOptions, +} from './types'; + +const resolvers = new WeakMap< + object, + (wallet: Address, chain: CHAIN) => Promise
+>(); +/** @internal Bind the real client's configured CEA resolver without exposing its runtime. */ +export function bindJobPreviewAccounts( + universal: object, + resolve: (wallet: Address, chain: CHAIN) => Promise
+): void { + resolvers.set(universal, resolve); +} + +/** Follow the deployed V2 type codes; uint values above int256.max cannot be compared. */ +export function decodeJobAnswer(read: JobRead, data: Hex): bigint { + const bytes = BigInt((data.length - 2) / 2); + const invalid = (): never => { + throw new JobSpecError( + 'INVALID_READ_RESULT', + 'The consensus bytes do not contain the selected JobSpec field.' + ); + }; + const word = (at: bigint): bigint => { + if (at < BigInt(0) || at + BigInt(32) > bytes) return invalid(); + return BigInt('0x' + data.slice(2 + Number(at) * 2, 66 + Number(at) * 2)); + }; + const dynamic = (n: OutputNode): boolean => + [6, 7, 9].includes(n.code) || n.children.some(dynamic); + const head = (n: OutputNode): bigint => + dynamic(n) + ? BigInt(1) + : n.code === 8 + ? n.children.reduce((sum, c) => sum + head(c), BigInt(0)) + : n.code === 10 + ? BigInt(n.size ?? 0) * head(n.children[0]) + : BigInt(1); + let node = parseOutputs(read.outputs); + let base = BigInt(0); + for (const index of read.field) { + let child: OutputNode; + let start = base; + let slot: bigint; + if (node.code === 8) { + child = node.children[index]; + if (!child) return invalid(); + slot = + base + + BigInt(32) * + node.children + .slice(0, index) + .reduce((sum, c) => sum + head(c), BigInt(0)); + } else if (node.code === 9 || node.code === 10) { + if ( + BigInt(index) >= (node.code === 9 ? word(base) : BigInt(node.size ?? 0)) + ) + return invalid(); + child = node.children[0]; + start += node.code === 9 ? BigInt(32) : BigInt(0); + slot = start + BigInt(32) * BigInt(index) * head(child); + } else return invalid(); + base = dynamic(child) ? start + word(slot) : slot; + if (base > bytes) return invalid(); + node = child; + } + const value = word(base); + if (node.code === 6 || node.code === 7) { + if (base + BigInt(32) + value > bytes) return invalid(); + return BigInt.asIntN( + 256, + BigInt( + keccak256( + ('0x' + + data.slice( + 2 + Number(base + BigInt(32)) * 2, + 2 + Number(base + BigInt(32) + value) * 2 + )) as Hex + ) + ) + ); + } + if (node.code === 9) return BigInt.asIntN(256, value); + if ( + (node.code === 1 && value >= BigInt(1) << BigInt(255)) || + (node.code === 3 && value > BigInt(1)) || + (node.code === 4 && value >= BigInt(1) << BigInt(160)) + ) + return invalid(); + return BigInt.asIntN(256, value); +} + +function compare(value: bigint, op: Operation, target: bigint): boolean { + switch (op) { + case 'EQUAL': + return value === target; + case 'NOT_EQUAL': + return value !== target; + case 'GREATER_THAN': + return value > target; + case 'GREATER_OR_EQUAL': + return value >= target; + case 'LESS_THAN': + return value < target; + case 'LESS_OR_EQUAL': + return value <= target; + } +} +function combine( + tree: LogicTree, + checks: JobSpecPreview['checks'] +): boolean | null { + const results = tree.of.map((x) => + typeof x === 'number' ? checks[x].pass : combine(x, checks) + ); + const yes = results.filter((v) => v === true).length; + const unknown = results.filter((v) => v === null).length; + const required = + tree.kind === 'ALL' + ? results.length + : tree.kind === 'ANY' + ? 1 + : tree.k ?? 0; + return yes >= required ? true : yes + unknown < required ? false : null; +} + +export async function previewJobSpec( + subject: JobSpec | JobSpecTemplate, + options: PreviewJobSpecOptions +): Promise { + // Validate and copy before asynchronous work; later caller mutations must not change paid criteria. + const input = decodeJobSpec(encodeJobSpec(subject), { + format: 'params' in subject ? 'template' : 'spec', + }); + options = { + ...options, + params: options.params && [...options.params], + baseline: options.baseline && [...options.baseline], + accounts: { ...options.accounts }, + resume: options.resume && [...options.resume], + }; + if ( + options.maxTotalFee !== undefined && + (typeof options.maxTotalFee !== 'bigint' || options.maxTotalFee < BigInt(0)) + ) + throw new JobSpecError( + 'INVALID_AMOUNT', + 'maxTotalFee must be a nonnegative bigint.' + ); + if (options.baseline) { + if (options.baseline.length !== input.reads.length) + throw new JobSpecError( + 'INVALID_BASELINE', + 'Provide one baseline entry per read; use null for unknown entries.' + ); + options.baseline.forEach((value) => { + if (value !== null) int256(value); + }); + } + let spec: JobSpec; + if ('params' in input) { + if ( + input.checks.some( + (c) => + c.target === PRINCIPAL || + (typeof c.target === 'object' && 'principalBps' in c.target) + ) && + options.principal === undefined + ) + throw new JobSpecError( + 'UNFILLED_PLACEHOLDER', + 'Supply principal to resolve principal-based preview targets.' + ); + const accounts = { ...options.accounts }; + const resolve = resolvers.get(options.client.universal); + for (const [index, read] of input.reads.entries()) { + const chain = `${read.chainNamespace}:${read.chainId}`; + const needed = + read.fills?.some((f) => f.source === 'WALLET') || + input.checks.some( + (c) => c.read === index && c.target === 'agentic:wallet' + ); + if (needed && !accounts[chain] && options.wallet && resolve) + accounts[chain] = await resolve( + getAddress(options.wallet), + chain as CHAIN + ); + } + spec = resolveJobSpec(input, { + principal: options.principal ?? BigInt(0), + params: options.params ?? [], + accounts, + origin: zeroHash, + executeBy: 0, + settleWindow: 1, + }); + } else spec = input; + const waitOptions = { + timeoutMs: options.advanced?.timeout, + pollingIntervalMs: options.advanced?.pollingIntervalMs, + resultShape: { kind: 'raw' as const }, + }; + let responses: UniversalReadResponse[]; + let quoted = BigInt(0); + let newlyPaid = BigInt(0); + if (options.resume) { + if ( + options.resume.length !== spec.reads.length || + options.resume.some((id) => !/^0x[\da-f]{64}$/i.test(id)) || + new Set(options.resume.map((x) => x.toLowerCase())).size !== + options.resume.length + ) + throw new JobSpecError( + 'INVALID_PREVIEW_RESUME', + 'Provide one distinct request ID per read, in read order.' + ); + responses = []; + for (const [i, id] of options.resume.entries()) { + const response = await options.client.universal.trackRead( + { requestId: id }, + { resultShape: { kind: 'raw' }, progressHook: options.progressHook } + ); + const read = spec.reads[i]; + let matched = false; + try { + const actual = response.request.spec; + const envelope = decodeEvmQueryEnvelope(actual.query); + const [target, calldata] = decodeAbiParameters( + [{ type: 'address' }, { type: 'bytes' }], + envelope.payload + ); + matched = + response.requestId.toLowerCase() === id.toLowerCase() && + actual.account.chainNamespace === read.chainNamespace && + actual.account.chainId === read.chainId && + actual.minConfirmations >= read.minConfirmations && + envelope.queryType === 1 && + envelope.refType === 0 && + envelope.blockNumber === actual.blockNumber && + getAddress(target) === getAddress(read.target) && + calldata.toLowerCase() === + (read.selector + read.args.slice(2)).toLowerCase(); + } catch { + /* A request from another schema/query cannot stand in for this logical read. */ + } + if (!matched) + throw new JobSpecError( + 'PREVIEW_REQUEST_MISMATCH', + 'The recorded request does not match this resolved JobSpec read.', + { read: i, requestId: id } + ); + responses.push(response); + } + quoted = responses.reduce( + (sum, response) => sum + response.fees.paid, + BigInt(0) + ); + } else { + const prepared = []; + for (const read of spec.reads) { + const readOptions: ReadPrepareOptions = { + chain: `${read.chainNamespace}:${read.chainId}` as Extract< + CHAIN, + `eip155:${string}` + >, + callData: (read.selector + read.args.slice(2)) as Hex, + minConfirmations: read.minConfirmations, + expiryBlocks: options.expiryBlocks, + refundTo: options.refundTo, + callback: options.callback as ReadPrepareOptions['callback'], + }; + prepared.push( + await options.client.universal.prepareRead(read.target, readOptions) + ); + } + quoted = prepared.reduce((sum, read) => sum + read.value, BigInt(0)); + if (options.maxTotalFee !== undefined && quoted > options.maxTotalFee) + throw new JobSpecError( + 'PREVIEW_FEE_EXCEEDED', + 'The prepared read fees exceed maxTotalFee. No read was submitted.', + { quoted, maxTotalFee: options.maxTotalFee } + ); + let batch; + try { + batch = await options.client.universal.executeReads(prepared, { + progressHook: options.progressHook, + advanced: options.advanced, + waitForCompletion: false, + }); + } catch (cause) { + const error = cause as { + txHash?: string; + transactionHashes?: readonly Hex[]; + pendingTransactionHash?: Hex; + }; + throw new JobSpecError( + 'PREVIEW_INCOMPLETE', + 'Read submission could not be confirmed. Track the recorded transactions before requesting more reads.', + { + txHash: error?.txHash, + transactionHashes: error?.transactionHashes, + pendingTransactionHash: error?.pendingTransactionHash, + }, + cause + ); + } + responses = batch.reads; + newlyPaid = quoted; + } + const requestIds = responses.map((r) => r.requestId); + try { + responses = await Promise.all(responses.map((r) => r.wait(waitOptions))); + } catch (cause) { + throw new JobSpecError( + 'PREVIEW_INCOMPLETE', + 'The paid reads are still recoverable. Resume with these request IDs instead of paying again.', + { requestIds, txHashes: responses.map((r) => r.txHash) }, + cause + ); + } + const reads: JobSpecPreview['reads'] = responses.map((response, i) => { + const value = response.raw?.resultData ?? null; + if (response.outcome !== READ_OUTCOME.SUCCESS || value === null) + return { + value, + decoded: null, + error: + response.errorMsg || 'The paid read did not complete successfully.', + response, + }; + try { + return { + value, + decoded: decodeJobAnswer(spec.reads[i], value), + response, + }; + } catch (e) { + return { value, decoded: null, error: (e as Error).message, response }; + } + }); + const checks: JobSpecPreview['checks'] = spec.checks.map((check) => { + let value = reads[check.read].decoded; + if (value === null) + return { pass: null, value, reason: 'READ_UNAVAILABLE' }; + if (check.evaluation === 'NUMBER' || check.evaluation === 'PERCENTAGE') { + const before = options.baseline?.[check.read]; + if (before === undefined || before === null) + return { pass: null, value: null, reason: 'BASELINE_REQUIRED' }; + if (check.evaluation === 'PERCENTAGE' && before === BigInt(0)) + return { pass: null, value: null, reason: 'ZERO_BASELINE' }; + try { + value = int256(value - before); + if (check.evaluation === 'PERCENTAGE') + value = int256(value * BigInt(10000)) / before; + } catch { + return { pass: null, value: null, reason: 'ARITHMETIC_OVERFLOW' }; + } + } + return { pass: compare(value, check.operation, check.target), value }; + }); + return { + reads, + checks, + pass: combine(spec.logic, checks), + fees: { + quoted, + paid: reads.reduce((sum, r) => sum + r.response.fees.paid, BigInt(0)), + newlyPaid, + }, + }; +} diff --git a/packages/core/src/lib/job-spec/read.ts b/packages/core/src/lib/job-spec/read.ts new file mode 100644 index 000000000..7dfedb5b1 --- /dev/null +++ b/packages/core/src/lib/job-spec/read.ts @@ -0,0 +1,341 @@ +import { + encodeAbiParameters, + getAddress, + isAddress, + isHex, + toFunctionSelector, + zeroAddress, + type AbiFunction, + type AbiParameter, + type Hex, +} from 'viem'; +import { argumentLocation } from '../agentic/codec/abi-layout'; +import { JobSpecError } from '../market/errors'; +import type { + JobFill, + PrepareJobReadOptions, + PreparedJobRead, + ExecuteJobReadOptions, +} from './types'; +import type { ReadOptions } from '../read-state/read-params'; +import type { CHAIN } from '../constants/enums'; + +const labels = new WeakMap< + PreparedJobRead, + { + selector: Hex; + outputs: Hex; + target: string; + name: string; + parameters: readonly AbiParameter[]; + } +>(); +export function inheritReadLabel( + source: PreparedJobRead, + target: PreparedJobRead +): void { + const value = labels.get(source); + if ( + value && + value.selector === source.selector && + value.outputs === source.outputs && + value.target === source.target.toLowerCase() + ) + labels.set(target, value); +} +export function jobReadLabel(read: PreparedJobRead): string | undefined { + const value = labels.get(read); + return value && + value.selector === read.selector && + value.outputs === read.outputs && + value.target === read.target.toLowerCase() + ? value.name + : undefined; +} +/** Same logical call as a JobSpec read, through the normal paid universal-read path. */ +export function jobReadOptions( + read: PreparedJobRead, + options: ExecuteJobReadOptions = {} +): ReadOptions { + validateJobRead(read); + if (read.fills?.length) + throw new JobSpecError( + 'UNFILLED_PLACEHOLDER', + 'Resolve WALLET/PARAM holes before sending a prepared read.' + ); + return { + ...options, + chain: `${read.chainNamespace}:${read.chainId}` as Extract< + CHAIN, + `eip155:${string}` + >, + callData: (read.selector + read.args.slice(2)) as Hex, + }; +} + +export function outputTypes(params: readonly AbiParameter[]): Hex { + function type(p: AbiParameter, depth: number): number[] { + if (depth > 16) + throw new JobSpecError('INVALID_READ', 'Output type nesting exceeds 16.'); + const a = /^(.*)\[(\d*)\]$/.exec(p.type); + if (a) { + const base = { ...p, type: a[1] } as AbiParameter; + if (!a[2]) return [9, ...type(base, depth + 1)]; + const n = Number(a[2]); + if (n < 1 || n > 255) + throw new JobSpecError( + 'INVALID_READ', + 'Fixed output arrays must contain 1..255 elements.' + ); + return [10, n, ...type(base, depth + 1)]; + } + if (p.type === 'tuple') + return tuple( + (p as { components: readonly AbiParameter[] }).components, + depth + 1 + ); + if (/^uint(\d+)?$/.test(p.type)) return [1]; + if (/^int(\d+)?$/.test(p.type)) return [2]; + if (p.type === 'bool') return [3]; + if (p.type === 'address') return [4]; + if (/^bytes([1-9]|[12]\d|3[0-2])$/.test(p.type)) return [5]; + if (p.type === 'bytes') return [6]; + if (p.type === 'string') return [7]; + throw new JobSpecError('INVALID_READ', `Unsupported ABI output ${p.type}.`); + } + function tuple(items: readonly AbiParameter[], depth: number): number[] { + if (items.length < 1 || items.length > 255) + throw new JobSpecError( + 'INVALID_READ', + 'An output tuple needs 1..255 fields.' + ); + return [8, items.length, ...items.flatMap((p) => type(p, depth))]; + } + return ('0x' + + tuple(params, 0) + .map((x) => x.toString(16).padStart(2, '0')) + .join('')) as Hex; +} + +/** Pure ABI preparation, independent of read fees, RPCs and the request preflight. */ +export function prepareJobRead( + options: PrepareJobReadOptions +): PreparedJobRead { + try { + if (!/^eip155:[1-9]\d*$/.test(options.chain)) + throw new JobSpecError( + 'CHAIN_UNSUPPORTED', + 'JobSpec reads currently support EVM chain identifiers only.' + ); + const candidates = options.abi.filter( + (x): x is AbiFunction => + x.type === 'function' && + x.name === options.functionName && + x.inputs.length === (options.args?.length ?? 0) + ); + if (candidates.length !== 1) + throw new JobSpecError( + 'INVALID_READ', + 'Select one exact function ABI for this read, including overloaded functions.' + ); + const fn = candidates[0]; + if (!['view', 'pure'].includes(fn.stateMutability)) + throw new JobSpecError( + 'INVALID_READ', + 'A JobSpec read must use a view or pure function.' + ); + const fills: JobFill[] = []; + const lower = ( + p: AbiParameter, + value: unknown, + indices: number[], + depth: number + ): unknown => { + if (depth > 16) + throw new JobSpecError( + 'INVALID_READ', + 'Read arguments are nested too deeply.' + ); + const marker = value === 'agentic:wallet'; + const param = + value && typeof value === 'object' && 'param' in value + ? (value as { param: unknown }).param + : undefined; + if (marker || param !== undefined) { + if (marker && p.type !== 'address') + throw new JobSpecError( + 'INVALID_READ', + 'WALLET read arguments must be addresses.' + ); + if (!marker && !/^u?int(\d+)?$/.test(p.type)) + throw new JobSpecError( + 'INVALID_READ', + 'PARAM read arguments must be static integers.' + ); + if ( + !marker && + (!Number.isInteger(param) || Number(param) < 0 || Number(param) > 255) + ) + throw new JobSpecError( + 'INVALID_READ', + 'Invalid template parameter index.' + ); + const word = + (argumentLocation(fn.inputs, indices, 'Read placeholder').offset - + 4) / + 32; + if (word > 255) + throw new JobSpecError( + 'INVALID_READ', + 'Read placeholder is outside the uint8 word range.' + ); + fills.push({ + word, + source: marker ? 'WALLET' : 'PARAM', + ...(!marker ? { param: Number(param) } : {}), + }); + return marker ? zeroAddress : BigInt(0); + } + const a = /^(.*)\[(\d*)\]$/.exec(p.type); + if (a) { + if (!Array.isArray(value)) + throw new JobSpecError( + 'INVALID_READ', + 'Array argument must be an array.' + ); + return value.map((v, i) => + lower( + { ...p, type: a[1] } as AbiParameter, + v, + [...indices, i], + depth + 1 + ) + ); + } + if (p.type === 'tuple') { + const fields = (p as { components: readonly AbiParameter[] }) + .components; + return fields.map((field, i) => + lower( + field, + Array.isArray(value) + ? value[i] + : (value as Record)?.[field.name ?? ''], + [...indices, i], + depth + 1 + ) + ); + } + return value; + }; + const values = fn.inputs.map((p, i) => lower(p, options.args?.[i], [i], 0)); + const [chainNamespace, chainId] = options.chain.split(':'); + const prepared = { + chainNamespace, + chainId, + target: getAddress(options.target), + selector: toFunctionSelector(fn), + args: encodeAbiParameters(fn.inputs, values), + outputs: outputTypes(fn.outputs), + ...(fills.length ? { fills } : {}), + }; + labels.set(prepared, { + selector: prepared.selector, + outputs: prepared.outputs, + target: prepared.target.toLowerCase(), + name: fn.name, + parameters: fn.outputs, + }); + return prepared; + } catch (cause) { + if (cause instanceof JobSpecError) throw cause; + throw new JobSpecError( + 'INVALID_READ', + 'The read arguments or ABI are invalid.', + undefined, + cause + ); + } +} + +export interface OutputNode { + code: number; + children: OutputNode[]; + size?: number; +} +export function parseOutputs(outputs: Hex): OutputNode { + if (!isHex(outputs) || outputs.length % 2 !== 0 || outputs.length > 8194) + throw new JobSpecError('INVALID_READ', 'Invalid output type layout.'); + const bytes = + outputs + .slice(2) + .match(/../g) + ?.map((x) => parseInt(x, 16)) ?? []; + let pos = 0; + function read(depth: number): OutputNode { + if (depth > 16 || pos >= bytes.length) + throw new JobSpecError('INVALID_READ', 'Invalid output type layout.'); + const code = bytes[pos++]; + const n: OutputNode = { code, children: [] }; + if (code === 8 || code === 10) { + n.size = bytes[pos++]; + if (!n.size) + throw new JobSpecError( + 'INVALID_READ', + 'An output tuple/array cannot be empty.' + ); + if (code === 8) + for (let i = 0; i < n.size; i++) n.children.push(read(depth + 1)); + else n.children.push(read(depth + 1)); + } else if (code === 9) n.children.push(read(depth + 1)); + else if (code < 1 || code > 7) + throw new JobSpecError('INVALID_READ', 'Unknown output type code.'); + return n; + } + const root = read(0); + if (root.code !== 8 || pos !== bytes.length) + throw new JobSpecError( + 'INVALID_READ', + 'Outputs must encode exactly one root tuple.' + ); + return root; +} +export function validateJobRead( + r: PreparedJobRead & { field?: number[]; minConfirmations?: number } +): void { + if ( + r.chainNamespace !== 'eip155' || + !/^[1-9]\d*$/.test(r.chainId) || + !isAddress(r.target) || + !/^0x[\da-f]{8}$/i.test(r.selector) || + !isHex(r.args) || + r.args.length % 2 + ) + throw new JobSpecError('INVALID_READ', 'Invalid EVM JobSpec read.'); + let node = parseOutputs(r.outputs); + const field = r.field ?? [0]; + if (field.length < 1 || field.length > 16) + throw new JobSpecError( + 'INVALID_READ', + 'Output field path must have 1..16 indexes.' + ); + for (const i of field) { + if ( + !Number.isInteger(i) || + i < 0 || + i > 255 || + ![8, 9, 10].includes(node.code) || + (node.code !== 9 && i >= (node.size ?? 0)) + ) + throw new JobSpecError( + 'INVALID_READ', + 'Output field path does not resolve.' + ); + node = node.children[node.code === 8 ? i : 0]; + } + if ([8, 10].includes(node.code)) + throw new JobSpecError( + 'INVALID_READ', + 'Choose a scalar or dynamic-length output field.' + ); +} diff --git a/packages/core/src/lib/job-spec/schema.ts b/packages/core/src/lib/job-spec/schema.ts new file mode 100644 index 000000000..93b7e7942 --- /dev/null +++ b/packages/core/src/lib/job-spec/schema.ts @@ -0,0 +1,66 @@ +export const READ_FIELDS = [ + { name: 'chainNamespace', type: 'string' }, + { name: 'chainId', type: 'string' }, + { name: 'minConfirmations', type: 'uint16' }, + { name: 'target', type: 'address' }, + { name: 'selector', type: 'bytes4' }, + { name: 'args', type: 'bytes' }, + { name: 'outputs', type: 'bytes' }, + { name: 'field', type: 'uint8[]' }, +] as const; +export const FILL_FIELDS = [ + { name: 'word', type: 'uint8' }, + { name: 'source', type: 'uint8' }, + { name: 'param', type: 'uint8' }, +] as const; +export const TEMPLATE_READ_FIELDS = [ + ...READ_FIELDS.slice(0, 6), + { name: 'fills', type: 'tuple[]', components: FILL_FIELDS }, + ...READ_FIELDS.slice(6), +] as const; +export const CHECK_FIELDS = [ + { name: 'read', type: 'uint8' }, + { name: 'evaluation', type: 'uint8' }, + { name: 'operation', type: 'uint8' }, + { name: 'target', type: 'int256' }, +] as const; +export const TEMPLATE_CHECK_FIELDS = [ + ...CHECK_FIELDS.slice(0, 3), + { name: 'source', type: 'uint8' }, + { name: 'value', type: 'int256' }, +] as const; +export const LOGIC_FIELDS = [ + { name: 'kind', type: 'uint8' }, + { name: 'checks', type: 'uint8[]' }, + { name: 'children', type: 'uint8[]' }, + { name: 'k', type: 'uint8' }, +] as const; +export const JOB_SPEC_PARAM = { + type: 'tuple', + components: [ + { name: 'reads', type: 'tuple[]', components: READ_FIELDS }, + { name: 'checks', type: 'tuple[]', components: CHECK_FIELDS }, + { name: 'logic', type: 'tuple[]', components: LOGIC_FIELDS }, + { name: 'allowedMutability', type: 'uint8' }, + { name: 'origin', type: 'bytes32' }, + { name: 'executeBy', type: 'uint64' }, + { name: 'failFinalAt', type: 'uint64' }, + ], +} as const; +export const JOB_TEMPLATE_PARAM = { + type: 'tuple', + components: [ + { name: 'reads', type: 'tuple[]', components: TEMPLATE_READ_FIELDS }, + { name: 'checks', type: 'tuple[]', components: TEMPLATE_CHECK_FIELDS }, + { name: 'logic', type: 'tuple[]', components: LOGIC_FIELDS }, + { name: 'allowedMutability', type: 'uint8' }, + { + name: 'params', + type: 'tuple[]', + components: [ + { name: 'min', type: 'int256' }, + { name: 'max', type: 'int256' }, + ], + }, + ], +} as const; diff --git a/packages/core/src/lib/job-spec/types.ts b/packages/core/src/lib/job-spec/types.ts new file mode 100644 index 000000000..d80e70ab9 --- /dev/null +++ b/packages/core/src/lib/job-spec/types.ts @@ -0,0 +1,148 @@ +import type { Abi, Address, Hex } from 'viem'; +import type { ArgumentPath, WalletMarker } from '../agentic/agentic.types'; +import type { JOB_VOCABULARY, PRINCIPAL } from './constants'; +import type { PushChain } from '../push-chain/push-chain'; +import type { + ReadCallback, + UniversalReadResponse, +} from '../read-state/read-state.types'; +import type { ReadExecuteOptions } from '../read-state/read-params'; +import type { ReadOptions } from '../read-state/read-params'; +export type Evaluation = + (typeof JOB_VOCABULARY.EVALUATION)[keyof typeof JOB_VOCABULARY.EVALUATION]; +export type Operation = + (typeof JOB_VOCABULARY.OPERATION)[keyof typeof JOB_VOCABULARY.OPERATION]; +export type LogicKind = + (typeof JOB_VOCABULARY.LOGIC)[keyof typeof JOB_VOCABULARY.LOGIC]; +export type AllowedMutability = + (typeof JOB_VOCABULARY.ALLOWED_MUTABILITY)[keyof typeof JOB_VOCABULARY.ALLOWED_MUTABILITY]; +export type PrincipalMarker = typeof PRINCIPAL; +export interface JobFill { + word: number; + source: 'WALLET' | 'PARAM'; + param?: number; +} +export interface PreparedJobRead { + chainNamespace: string; + chainId: string; + target: Address; + selector: Hex; + args: Hex; + outputs: Hex; + /** Placeholder positions are explicit; zero argument values are never treated as holes. */ + fills?: JobFill[]; +} +export interface JobRead extends PreparedJobRead { + minConfirmations: number; + field: number[]; +} +export interface PrepareJobReadOptions { + chain: string; + target: Address; + abi: Abi; + functionName: string; + args?: readonly unknown[]; +} +export type ExecuteJobReadOptions = Pick< + ReadOptions, + | 'callback' + | 'minConfirmations' + | 'blockNumber' + | 'expiryBlocks' + | 'maxFee' + | 'refundTo' + | 'waitForCompletion' + | 'progressHook' + | 'advanced' +>; +export interface JobReadInput { + read: PreparedJobRead; + minConfirmations?: number; + field?: number[]; +} +export interface JobCheck { + read: number; + evaluation: Evaluation; + operation: Operation; + target: bigint; +} +export interface JobCheckTemplate extends Omit { + target: + | bigint + | PrincipalMarker + | WalletMarker + | { principalBps: bigint } + | { param: number }; +} +export interface LogicTree { + kind: LogicKind; + of: (number | LogicTree)[]; + k?: number; +} +export interface JobSpec { + reads: JobRead[]; + checks: JobCheck[]; + logic: LogicTree; + allowedMutability: AllowedMutability; + origin: Hex; + executeBy: number; + failFinalAt: number; +} +export interface JobSpecTemplate { + reads: JobRead[]; + checks: JobCheckTemplate[]; + logic: LogicTree; + allowedMutability: AllowedMutability; + params: { min: bigint; max: bigint }[]; +} +export interface BuildJobSpecOptions { + reads: (PreparedJobRead | JobReadInput)[]; + checks: JobCheck[]; + logic: 'ALL' | 'ANY' | LogicTree; + allowedMutability?: AllowedMutability; + executeBy: number; + failFinalAt: number; + expiredAt?: number; + origin?: Hex; +} +export interface BuildJobSpecTemplateOptions + extends Omit< + BuildJobSpecOptions, + 'checks' | 'executeBy' | 'failFinalAt' | 'expiredAt' | 'origin' + > { + checks: JobCheckTemplate[]; + params?: { min: bigint; max: bigint }[]; +} +export interface PreviewJobSpecOptions { + client: Pick; + wallet?: Address; + principal?: bigint; + params?: bigint[]; + /** Explicit destination accounts override CEA derivation from wallet on a real SDK client. */ + accounts?: Record; + /** Optional baseline per read for NUMBER/PERCENTAGE. Missing baselines remain unknown. */ + baseline?: readonly (bigint | null)[]; + /** Existing universal-read registry is used by default. */ + callback?: ReadCallback; + /** Maximum sum of prepared read request values, checked before any payment. */ + maxTotalFee?: bigint; + expiryBlocks?: bigint; + refundTo?: Address; + progressHook?: ReadExecuteOptions['progressHook']; + advanced?: ReadExecuteOptions['advanced']; + /** One existing request ID per read, in read order. Validated against the resolved call; no new fees. */ + resume?: readonly Hex[]; +} +export interface JobSpecPreview { + /** A preview is a paid observation, never an evaluator verdict. */ + reads: { + value: Hex | null; + decoded: bigint | null; + error?: string; + response: UniversalReadResponse; + }[]; + checks: { pass: boolean | null; value: bigint | null; reason?: string }[]; + pass: boolean | null; + fees: { quoted: bigint; paid: bigint; newlyPaid: bigint }; +} +export type JobArgumentPath = ArgumentPath; diff --git a/packages/core/src/lib/job/job.ts b/packages/core/src/lib/job/job.ts new file mode 100644 index 000000000..498658304 --- /dev/null +++ b/packages/core/src/lib/job/job.ts @@ -0,0 +1,600 @@ +import { + decodeEventLog, + encodeAbiParameters, + encodeFunctionData, + erc20Abi, + getAbiItem, + getAddress, + keccak256, + stringToHex, + zeroAddress, + zeroHash, + type Address, + type Hex, +} from 'viem'; +import { JOB_KERNEL_ABI as ABI } from '../market/contracts/AgenticCommerce'; +import { RULES_HOOK_ABI } from '../market/contracts/RulesBindingHook'; +import { MARKETPLACE_ABI } from '../market/contracts/UniversalMarketplace'; +import { KERNEL_READ_ABI, encodeCreateJob } from '../market/kernel-wire'; +import { + deployment, + eventArgs, + ownerBatch, + snapshot, + write, + type MarketSnapshot, + type StackRuntime, +} from '../market/runtime'; +import { JobError, unavailable } from '../market/errors'; +import { currentGeneration } from '../agentic/deployments'; +import { getRule } from '../agentic/reads/rules'; +import { Snapshot, scanLogs } from '../agentic/reads/snapshot'; +import { decodeJobSpec } from '../job-spec/codec'; +import type { JobSpec } from '../job-spec/types'; +import type { Call } from '../agentic/contracts/v6'; +import { watchJob } from './watch'; +import type { + CreateJobOptions, + FundJobOptions, + Job, + JobEvent, + JobNamespace, + JobStatus, + JobWriteOptions, + JobWatchOptions, +} from './types'; + +const STATES: JobStatus[] = [ + 'Open', + 'Funded', + 'Submitted', + 'Completed', + 'Rejected', + 'Expired', +]; +interface KernelJob { + client: Address; + status: number; + provider: Address; + expiredAt: number; + evaluator: Address; + hook: Address; + budget: bigint; + description: Hex; +} +function validId(jobId: bigint): void { + if (typeof jobId !== 'bigint' || jobId < BigInt(1)) + throw new JobError('INVALID_JOB_ID', 'Job IDs are positive bigint values.'); +} +function amount(n: bigint): void { + if (typeof n !== 'bigint' || n < BigInt(0) || n >= BigInt(1) << BigInt(256)) + throw new JobError('INVALID_AMOUNT', 'The amount must fit uint256.'); +} + +export class JobNamespaceImpl implements JobNamespace { + constructor(private readonly rt: StackRuntime) {} + private async raw(jobId: bigint, s: MarketSnapshot): Promise { + validId(jobId); + const j = await s.read( + deployment(this.rt).kernel, + KERNEL_READ_ABI, + 'getJob', + [jobId] + ); + if (getAddress(j.client) === zeroAddress || !STATES[j.status]) + throw new JobError('JOB_NOT_FOUND', 'This job does not exist.'); + return j; + } + private async jobAt(jobId: bigint, s: MarketSnapshot): Promise { + const d = deployment(this.rt); + const j = await this.raw(jobId, s); + const paymentToken = await s.read
(d.kernel, ABI, 'paymentToken'); + let spec: JobSpec | undefined; + let decodeError: string | undefined; + if (j.description !== '0x') { + try { + spec = decodeJobSpec(j.description, { format: 'spec' }) as JobSpec; + } catch (e) { + decodeError = (e as Error).message; + } + } + const rulesId = + getAddress(j.hook) === getAddress(d.hook) + ? await s.read(d.hook, RULES_HOOK_ABI, 'rulesOfJob', [jobId]) + : undefined; + let deliverable: Hex | undefined; + if (j.status >= 2) { + const logs = await scanLogs( + this.rt.agw.reader, + { + address: d.kernel, + event: getAbiItem({ abi: ABI, name: 'JobSubmitted' }), + args: { jobId }, + }, + this.rt.jobBlocks?.get(jobId) ?? d.startBlock, + s.blockNumber + ); + if (logs.length > 1) + throw new JobError( + 'INCONSISTENT_READ', + 'The job has multiple submission events.' + ); + if (logs.length === 1) { + const event = decodeEventLog({ + abi: ABI, + eventName: 'JobSubmitted', + data: logs[0].data, + topics: logs[0].topics, + }); + deliverable = event.args.deliverable; + } + } + return { + ...j, + jobId, + status: STATES[j.status], + paymentToken, + ...(deliverable ? { deliverable } : {}), + ...(rulesId && rulesId !== zeroHash ? { rulesId } : {}), + ...(spec ? { jobSpec: { spec, bytes: j.description } } : {}), + ...(decodeError ? { jobSpecDecodeError: decodeError } : {}), + evaluation: { + available: false, + reason: + getAddress(j.evaluator) === getAddress(d.evaluator) + ? 'MANUAL_EVALUATOR' + : 'UNVERIFIED_EVALUATOR', + }, + }; + } + async status(jobId: bigint): Promise { + return this.jobAt(jobId, await snapshot(this.rt)); + } + async list( + options: { + client?: Address; + provider?: Address; + status?: JobStatus; + fromJobId?: bigint; + } = {} + ): Promise<{ jobs: Job[] }> { + const s = await snapshot(this.rt); + const count = await s.read( + deployment(this.rt).kernel, + ABI, + 'jobCounter' + ); + const from = options.fromJobId ?? BigInt(1); + validId(from); + const jobs: Job[] = []; + for (let i = from; i <= count; i += BigInt(8)) { + const rows = await Promise.all( + Array.from( + { + length: Number( + count - i + BigInt(1) > BigInt(8) + ? BigInt(8) + : count - i + BigInt(1) + ), + }, + (_, n) => this.jobAt(i + BigInt(n), s) + ) + ); + jobs.push( + ...rows.filter( + (j) => + (!options.client || + getAddress(options.client) === getAddress(j.client)) && + (!options.provider || + getAddress(options.provider) === getAddress(j.provider)) && + (!options.status || options.status === j.status) + ) + ); + } + return { jobs }; + } + async create(options: CreateJobOptions) { + options = { ...options }; + const d = deployment(this.rt); + const s = await snapshot(this.rt); + const token = await s.read
(d.kernel, ABI, 'paymentToken'); + if (getAddress(options.paymentToken) !== getAddress(token)) + throw new JobError( + 'TOKEN_MISMATCH', + 'The job token must equal this kernel’s payment token.' + ); + if (options.jobSpec) { + const spec = decodeJobSpec(options.jobSpec, { + format: 'spec', + }) as JobSpec; + if (spec.allowedMutability !== 'NONE') + unavailable('Mutable job criteria'); + if (spec.origin !== zeroHash) + throw new JobError( + 'INVALID_JOB_SPEC', + 'A plain job must have a zero marketplace origin.' + ); + if (spec.failFinalAt >= options.expiredAt) + throw new JobError( + 'INVALID_JOB_SPEC', + 'The failure deadline must precede expiry.' + ); + } + const min = await s.read(d.kernel, ABI, 'MIN_EXPIRY_WINDOW'); + if ( + !Number.isSafeInteger(options.expiredAt) || + options.expiredAt <= s.timestamp + Number(min) || + options.expiredAt >= 2 ** 48 + ) + throw new JobError( + 'EXPIRY_TOO_SHORT', + 'Choose a future expiry beyond the kernel minimum window.' + ); + const call = { + target: d.kernel, + value: BigInt(0), + data: encodeCreateJob( + getAddress(options.provider), + getAddress(options.evaluator), + options.expiredAt, + options, + getAddress(options.hook) + ), + }; + const wallet = this.rt.wallet(); + const tx = wallet + ? await ownerBatch(this.rt, wallet, [call], options.progressHook) + : await write(this.rt, call, options.progressHook, 'JOB', true); + const e = await eventArgs(this.rt, tx, d.kernel, ABI, 'JobCreated'); + if (getAddress(e['client'] as Address) !== getAddress(this.rt.account())) + throw new JobError( + 'RECEIPT_MISMATCH', + 'The created job has a different client.', + { txHash: tx.hash } + ); + return { jobId: e['jobId'] as bigint, tx }; + } + private provider(j: KernelJob): void { + if (getAddress(j.provider) !== getAddress(this.rt.agw.signerPushAccount())) + throw new JobError('NOT_PROVIDER', 'Connect this job’s provider.'); + } + private open(j: KernelJob, now: number): void { + if (j.status !== 0 || j.expiredAt <= now) + throw new JobError('WRONG_STATUS', 'The job must be Open and unexpired.'); + } + async setBudget( + jobId: bigint, + budget: bigint, + options: JobWriteOptions & { jobSpec?: Hex } = {} + ) { + if (options.jobSpec !== undefined) + unavailable('Replacement JobSpec proposals'); + amount(budget); + const s = await snapshot(this.rt); + const j = await this.raw(jobId, s); + this.provider(j); + this.open(j, s.timestamp); + const d = deployment(this.rt); + return write( + this.rt, + { + target: d.kernel, + value: BigInt(0), + data: encodeFunctionData({ + abi: ABI, + functionName: 'setBudget', + args: [jobId, budget, '0x'], + }), + }, + options.progressHook, + 'JOB', + true + ); + } + async fund(jobId: bigint, options: FundJobOptions = {}) { + options = { ...options }; + const rt = this.rt; + const d = deployment(rt); + const s = await snapshot(rt); + const j = await this.raw(jobId, s); + this.open(j, s.timestamp); + const gen = currentGeneration(rt.agw.network); + const isWallet = await s.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'isWallet', + [j.client] + ); + if (isWallet) { + const owner = await s.read
( + j.client, + gen.contracts.abis.wallet, + 'owner' + ); + if (getAddress(owner) !== getAddress(rt.agw.signerPushAccount())) + throw new JobError('NOT_CLIENT', 'Connect the job wallet’s owner.'); + } else if (getAddress(j.client) !== getAddress(rt.agw.signerPushAccount())) + throw new JobError('NOT_CLIENT', 'Connect this job’s client.'); + let expected = options.expectedBudget; + const cardId = await s.read( + d.marketplace, + MARKETPLACE_ABI, + 'cardOfJob', + [jobId] + ); + let rulesId = options.rulesId; + if (cardId) { + const [version, view, recordedRules] = await Promise.all([ + s.read(d.marketplace, MARKETPLACE_ABI, 'cardVersionOfJob', [ + jobId, + ]), + s.read<{ version: bigint; card: { providerFee: bigint } }>( + d.marketplace, + MARKETPLACE_ABI, + 'getAgentCard', + [cardId] + ), + s.read(d.marketplace, MARKETPLACE_ABI, 'rulesOfJob', [jobId]), + ]); + if (rulesId && rulesId !== recordedRules) + throw new JobError( + 'RULES_MISMATCH', + 'Use the permission recorded when this job was opened.' + ); + rulesId = recordedRules; + if (expected === undefined && version === view.version) + expected = view.card.providerFee; + } + if (expected === undefined) + throw new JobError( + 'EXPECTED_BUDGET_REQUIRED', + 'Pass the exact budget you reviewed; this SDK does not silently accept the latest provider quote.' + ); + amount(expected); + if (expected !== j.budget) + throw new JobError( + 'BUDGET_MISMATCH', + 'The provider’s current price differs from the budget you reviewed.', + { expectedBudget: expected, currentBudget: j.budget } + ); + let optParams: Hex = '0x'; + if (getAddress(j.hook) === getAddress(d.hook)) { + if (!isWallet || !rulesId || !/^0x[\da-f]{64}$/i.test(rulesId)) + throw new JobError( + 'RULES_REQUIRED', + 'This rules hook needs an AGW client and its rulesId.' + ); + const record = await getRule( + new Snapshot(rt.agw.reader, s.blockNumber), + gen, + j.client, + rulesId, + rt.agw.pushChainNamespace + ); + if ( + getAddress(record.agent) !== getAddress(j.provider) || + record.validUntil < j.expiredAt + ) + throw new JobError( + 'RULES_MISMATCH', + 'The live rule must authorize this provider through the job expiry.' + ); + if ( + await s.read(d.hook, RULES_HOOK_ABI, 'activeJobOf', [j.client]) + ) + throw new JobError( + 'WALLET_HAS_ACTIVE_JOB', + 'This wallet already has a funded job under the rules hook.' + ); + optParams = encodeAbiParameters([{ type: 'bytes32' }], [rulesId]); + } else if (rulesId) + throw new JobError( + 'HOOK_MISMATCH', + 'A rulesId payload is supported only for the verified rules hook.' + ); + const token = await s.read
(d.kernel, ABI, 'paymentToken'); + const calls: Call[] = []; + if (expected > BigInt(0)) { + const [balance, allowance] = await Promise.all([ + s.read(token, erc20Abi, 'balanceOf', [j.client]), + s.read(token, erc20Abi, 'allowance', [j.client, d.kernel]), + ]); + if (balance < expected) + throw new JobError( + 'INSUFFICIENT_FUNDS', + 'The job client lacks the payment tokens required for escrow.' + ); + if (allowance < expected) { + if (allowance > BigInt(0)) + calls.push({ + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [d.kernel, BigInt(0)], + }), + }); + calls.push({ + target: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [d.kernel, expected], + }), + }); + } + } + calls.push({ + target: d.kernel, + value: BigInt(0), + data: encodeFunctionData({ + abi: ABI, + functionName: 'fund', + args: [jobId, expected, optParams], + }), + }); + if (isWallet) return ownerBatch(rt, j.client, calls, options.progressHook); + if (calls.length === 1) + return write(rt, calls[0], options.progressHook, 'JOB', true); + if (rt.agw.isReadOnly) + throw new JobError('READ_ONLY', 'Connect a signer to fund escrow.'); + // No sequential fallback: allowance and acceptance must belong to one atomic batch. + return rt.agw.executeAtomicBatch( + { + to: d.kernel, + value: BigInt(0), + data: calls.map((c) => ({ + to: c.target, + value: c.value, + data: c.data, + })), + }, + { progressHook: options.progressHook } + ); + } + async submit( + jobId: bigint, + options: JobWriteOptions & { deliverable: Hex | string } + ) { + options = { ...options }; + const s = await snapshot(this.rt); + const j = await this.raw(jobId, s); + this.provider(j); + if (j.status !== 1 || j.expiredAt <= s.timestamp) + throw new JobError( + 'WRONG_STATUS', + 'Only an unexpired Funded job can be submitted.' + ); + const deliverable = /^0x[\da-f]{64}$/i.test(options.deliverable) + ? (options.deliverable as Hex) + : keccak256(stringToHex(options.deliverable)); + return write( + this.rt, + { + target: deployment(this.rt).kernel, + value: BigInt(0), + data: encodeFunctionData({ + abi: ABI, + functionName: 'submit', + args: [jobId, deliverable, '0x'], + }), + }, + options.progressHook, + 'JOB', + true + ); + } + async cancel(jobId: bigint, options: JobWriteOptions = {}) { + const rt = this.rt; + const d = deployment(rt); + const s = await snapshot(rt); + const j = await this.raw(jobId, s); + if (j.status !== 0) + throw new JobError( + 'WRONG_STATUS', + 'Only an Open job can be cancelled by its client.' + ); + const gen = currentGeneration(rt.agw.network); + const isWallet = await s.read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'isWallet', + [j.client] + ); + const calls: Call[] = [ + { + target: d.kernel, + value: BigInt(0), + data: encodeFunctionData({ + abi: ABI, + functionName: 'reject', + args: [jobId, zeroHash, '0x'], + }), + }, + ]; + if (isWallet) { + const wallet = await s.read
( + d.marketplace, + MARKETPLACE_ABI, + 'agwOfJob', + [jobId] + ); + const rulesId = await s.read( + d.marketplace, + MARKETPLACE_ABI, + 'rulesOfJob', + [jobId] + ); + if ( + getAddress(wallet) === getAddress(j.client) && + rulesId !== zeroHash && + (await s.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'isPermissionEnabled', + [rulesId, j.client] + )) + ) + calls.push({ + target: j.client, + value: BigInt(0), + data: gen.contracts.encodeRevokeRules(rulesId), + }); + return ownerBatch(rt, j.client, calls, options.progressHook); + } + if (getAddress(j.client) !== getAddress(rt.agw.signerPushAccount())) + throw new JobError( + 'NOT_CLIENT', + 'Only the client can use this cancellation method.' + ); + return write(rt, calls[0], options.progressHook, 'JOB', true); + } + async claimRefund(jobId: bigint, options: JobWriteOptions = {}) { + const s = await snapshot(this.rt); + const j = await this.raw(jobId, s); + const d = deployment(this.rt); + if (![0, 1, 2].includes(j.status)) + throw new JobError('WRONG_STATUS', 'This job is already terminal.'); + const grace = + j.status === 2 + ? Number(await s.read(d.kernel, ABI, 'EVALUATOR_GRACE_PERIOD')) + : 0; + if (s.timestamp < j.expiredAt + grace) + throw new JobError( + 'REFUND_NOT_READY', + 'The expiry or evaluator grace window has not elapsed.' + ); + return write( + this.rt, + { + target: d.kernel, + value: BigInt(0), + data: encodeFunctionData({ + abi: ABI, + functionName: 'claimRefund', + args: [jobId], + }), + }, + options.progressHook, + 'JOB', + true + ); + } + async verify(_jobId: bigint, _options?: JobWriteOptions): Promise { + void _jobId; + void _options; + return unavailable( + 'Automatic job verification; the configured evaluator is manual' + ); + } + watch( + jobId: bigint, + callback: (event: JobEvent) => void, + options: JobWatchOptions = {} + ): () => void { + validId(jobId); + return watchJob(this.rt, jobId, callback, { ...options }); + } +} diff --git a/packages/core/src/lib/job/types.ts b/packages/core/src/lib/job/types.ts new file mode 100644 index 000000000..cf8105991 --- /dev/null +++ b/packages/core/src/lib/job/types.ts @@ -0,0 +1,110 @@ +import type { Address, Hex } from 'viem'; +import type { UniversalTxResponse } from '../orchestrator/orchestrator.types'; +import type { JobSpec } from '../job-spec/types'; +import type { StackProgressHook } from '../market/runtime'; +export type JobStatus = + | 'Open' + | 'Funded' + | 'Submitted' + | 'Completed' + | 'Rejected' + | 'Expired'; +export interface CreateJobOptions { + provider: Address; + evaluator: Address; + hook: Address; + expiredAt: number; + paymentToken: Address; + jobSpec?: Hex; + description?: string; + progressHook?: StackProgressHook; +} +export interface Job { + jobId: bigint; + client: Address; + provider: Address; + evaluator: Address; + hook: Address; + status: JobStatus; + expiredAt: number; + budget: bigint; + paymentToken: Address; + /** Raw Solidity string bytes, which may contain a binary JobSpec rather than UTF-8. */ + description: Hex; + deliverable?: Hex; + rulesId?: Hex; + jobSpec?: { + spec: JobSpec; + bytes: Hex; + /** Unavailable without a compatible evaluator. */ frozen?: boolean; + }; + jobSpecDecodeError?: string; + evaluation: { + available: false; + reason: 'MANUAL_EVALUATOR' | 'UNVERIFIED_EVALUATOR'; + }; +} +export interface JobWriteOptions { + progressHook?: StackProgressHook; +} +export interface FundJobOptions extends JobWriteOptions { + rulesId?: Hex; + /** The price the client reviewed. Required for plain jobs or an updated card version. */ + expectedBudget?: bigint; +} +export interface JobEvent { + jobId: bigint; + kind: string; + txHash?: Hex; + blockNumber?: bigint; + data?: unknown; + error?: unknown; + cursor?: JobWatchCursor; +} +/** Last delivered log; resume includes its block and excludes this log and earlier logs. */ +export interface JobWatchCursor { + blockNumber: bigint; + logIndex: number; + blockHash?: Hex; +} +export interface JobWatchOptions { + fromBlock?: bigint; + cursor?: JobWatchCursor; + pollIntervalMs?: number; +} +export interface JobNamespace { + create( + options: CreateJobOptions + ): Promise<{ jobId: bigint; tx: UniversalTxResponse }>; + setBudget( + jobId: bigint, + amount: bigint, + options?: JobWriteOptions & { jobSpec?: Hex } + ): Promise; + fund(jobId: bigint, options?: FundJobOptions): Promise; + submit( + jobId: bigint, + options: JobWriteOptions & { deliverable: Hex | string } + ): Promise; + cancel( + jobId: bigint, + options?: JobWriteOptions + ): Promise; + claimRefund( + jobId: bigint, + options?: JobWriteOptions + ): Promise; + verify(jobId: bigint, options?: JobWriteOptions): Promise; + status(jobId: bigint): Promise; + list(options?: { + client?: Address; + provider?: Address; + status?: JobStatus; + fromJobId?: bigint; + }): Promise<{ jobs: Job[] }>; + watch( + jobId: bigint, + callback: (event: JobEvent) => void, + options?: JobWatchOptions + ): () => void; +} diff --git a/packages/core/src/lib/job/watch.spec.ts b/packages/core/src/lib/job/watch.spec.ts new file mode 100644 index 000000000..fee0ebc42 --- /dev/null +++ b/packages/core/src/lib/job/watch.spec.ts @@ -0,0 +1,140 @@ +import { + encodeAbiParameters, + encodeEventTopics, + zeroHash, + type Log, +} from 'viem'; +import { watchJob } from './watch'; +import { JOB_KERNEL_ABI as ABI } from '../market/contracts/AgenticCommerce'; +import { DONUT_MARKETPLACE as D } from '../market/deployment'; +import { FakeChain } from '../agentic/__tests__/fake-chain'; +import { mockRuntime } from '../agentic/__tests__/mock-runtime'; +import type { StackRuntime } from '../market/runtime'; +import type { JobEvent } from './types'; +const makeLog = (logIndex: number): Log => + ({ + address: D.kernel, + blockNumber: BigInt(10), + blockHash: zeroHash, + logIndex, + transactionIndex: 0, + transactionHash: zeroHash, + removed: false, + topics: encodeEventTopics({ + abi: ABI, + eventName: 'BudgetSet', + args: { jobId: BigInt(1) }, + }), + data: encodeAbiParameters([{ type: 'uint256' }], [BigInt(logIndex)]), + } as Log); +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; +function fixture() { + const base = mockRuntime(new FakeChain()); + const state = { + failed: false, + logs: [makeLog(0), makeLog(1)], + blockHash: zeroHash as string, + }; + const getLogs = jest.fn(async ({ event }: { event: { name: string } }) => { + if (state.failed) throw new Error('RPC unavailable'); + return event.name === 'BudgetSet' ? state.logs : []; + }); + const rt: StackRuntime = { + agw: { + ...base, + reader: { + ...base.reader, + getBlockNumber: async () => BigInt(10), + getBlock: async () => ({ + timestamp: BigInt(1), + hash: state.blockHash as `0x${string}`, + }), + getLogs, + }, + }, + account: base.signerPushAccount, + wallet: () => undefined, + send: jest.fn(), + signTypedData: jest.fn(), + }; + return { rt, state, getLogs }; +} +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); +it('deduplicates provider replay and resumes after an exact log within the same block', async () => { + const f = fixture(); + f.state.logs.push(makeLog(1)); + const events: JobEvent[] = []; + const stop = watchJob(f.rt, BigInt(1), (e) => events.push(e), { + cursor: { blockNumber: BigInt(10), logIndex: 0, blockHash: zeroHash }, + pollIntervalMs: 25, + }); + await flush(); + stop(); + expect(events.map((e) => e.cursor?.logIndex)).toEqual([1]); +}); +it('does not rewind a delivered event when the consumer callback throws', async () => { + const f = fixture(); + const events: JobEvent[] = []; + const stop = watchJob( + f.rt, + BigInt(1), + (e) => { + events.push(e); + if (e.kind !== 'Error') throw new Error('Consumer failed'); + }, + { fromBlock: BigInt(10), pollIntervalMs: 25 } + ); + await flush(); + jest.advanceTimersByTime(25); + await flush(); + stop(); + expect(events.filter((e) => e.kind === 'BudgetSet')).toHaveLength(2); + expect(events.filter((e) => e.kind === 'Error')).toHaveLength(2); +}); +it('retries RPC failures without skipping a range', async () => { + const f = fixture(); + f.state.failed = true; + const events: JobEvent[] = []; + const stop = watchJob(f.rt, BigInt(1), (e) => events.push(e), { + fromBlock: BigInt(10), + pollIntervalMs: 25, + }); + await flush(); + f.state.failed = false; + jest.advanceTimersByTime(25); + await flush(); + stop(); + expect(events.map((e) => e.kind)).toEqual([ + 'Error', + 'BudgetSet', + 'BudgetSet', + ]); +}); +it('stops on a changed canonical cursor and suppresses callbacks after unsubscribe', async () => { + const f = fixture(); + f.state.blockHash = '0x' + 'ab'.repeat(32); + const events: JobEvent[] = []; + const stop = watchJob(f.rt, BigInt(1), (e) => events.push(e), { + cursor: { blockNumber: BigInt(10), logIndex: 0, blockHash: zeroHash }, + pollIntervalMs: 25, + }); + await flush(); + expect(events[0]).toMatchObject({ + kind: 'Error', + error: { code: 'WATCH_REORG' }, + }); + jest.advanceTimersByTime(25); + await flush(); + expect(events).toHaveLength(1); + stop(); + const callbacks = jest.fn(); + const cancel = watchJob(f.rt, BigInt(1), callbacks, { + fromBlock: BigInt(10), + }); + cancel(); + await flush(); + expect(callbacks).not.toHaveBeenCalled(); +}); diff --git a/packages/core/src/lib/job/watch.ts b/packages/core/src/lib/job/watch.ts new file mode 100644 index 000000000..d9a67b01e --- /dev/null +++ b/packages/core/src/lib/job/watch.ts @@ -0,0 +1,209 @@ +import { decodeEventLog, getAbiItem, getAddress, type Log } from 'viem'; +import { JOB_KERNEL_ABI as ABI } from '../market/contracts/AgenticCommerce'; +import { deployment, type StackRuntime } from '../market/runtime'; +import { JobError } from '../market/errors'; +import { fingerprint } from '../market/data'; +import type { JobEvent, JobWatchOptions } from './types'; + +const names = [ + 'JobCreated', + 'BudgetSet', + 'JobFunded', + 'JobSubmitted', + 'JobCompleted', + 'JobRejected', + 'JobExpired', + 'Refunded', +] as const; +const kinds = [ + 'Created', + 'BudgetSet', + 'Funded', + 'Submitted', + 'Completed', + 'Rejected', + 'Expired', + 'Refunded', +]; + +export function watchJob( + rt: StackRuntime, + jobId: bigint, + callback: (event: JobEvent) => void, + options: JobWatchOptions +): () => void { + const d = deployment(rt); + let stopped = false; + let timer: ReturnType | undefined; + let cursor = options.cursor && { ...options.cursor }; + let next = cursor?.blockNumber ?? options.fromBlock; + const interval = options.pollIntervalMs ?? 3000; + if ( + !Number.isSafeInteger(interval) || + interval < 25 || + (next !== undefined && (typeof next !== 'bigint' || next < BigInt(0))) || + (cursor && + (!Number.isSafeInteger(cursor.logIndex) || + cursor.logIndex < 0 || + (cursor.blockHash && !/^0x[\da-f]{64}$/i.test(cursor.blockHash)))) || + (cursor && options.fromBlock !== undefined) + ) + throw new JobError( + 'INVALID_WATCH_OPTIONS', + 'Use a nonnegative block or a delivered-log cursor, and a polling interval of at least 25ms.' + ); + const notify = (event: JobEvent): void => { + try { + callback(event); + } catch (error) { + if (event.kind !== 'Error' && !stopped) { + try { + callback({ + jobId, + kind: 'Error', + error, + cursor: cursor && { ...cursor }, + }); + } catch { + /* A consumer exception must not rewind delivery or reject an unobserved promise. */ + } + } + } + }; + const poll = async () => { + try { + const head = await rt.agw.reader.getBlockNumber(); + if (stopped) return; + if (cursor) { + if (head < cursor.blockNumber) + throw new JobError( + 'WATCH_REORG', + 'The delivered cursor is ahead of the canonical head. Reload status before continuing.' + ); + const block = + rt.agw.reader.getBlock && + (await rt.agw.reader.getBlock({ blockNumber: cursor.blockNumber })); + if ( + head < cursor.blockNumber || + (cursor.blockHash && + block?.hash && + cursor.blockHash.toLowerCase() !== block.hash.toLowerCase()) + ) + throw new JobError( + 'WATCH_REORG', + 'The delivered cursor is no longer canonical. Reload job status and restart from a reviewed block.' + ); + } + if (next === undefined) next = head; + const end = next + BigInt(999) < head ? next + BigInt(999) : head; + if (next <= end) { + const start = next; + const groups = await Promise.all( + names.map((name) => + rt.agw.reader.getLogs({ + address: d.kernel, + event: getAbiItem({ abi: ABI, name }), + args: { jobId }, + fromBlock: start, + toBlock: end, + }) + ) + ); + if (stopped) return; + const unique = new Map< + string, + { log: Log; event: ReturnType } + >(); + for (const log of groups.flat()) { + if (log.removed) + throw new JobError( + 'WATCH_REORG', + 'The RPC returned a removed job log. Reload status before continuing.' + ); + if ( + getAddress(log.address) !== getAddress(d.kernel) || + log.blockNumber === null || + log.logIndex === null || + log.blockNumber < start || + log.blockNumber > end + ) + throw new JobError( + 'INCONSISTENT_READ', + 'The RPC returned an unmined or out-of-range job log.' + ); + const event = decodeEventLog({ + abi: ABI, + data: log.data, + topics: log.topics, + strict: true, + }); + if ( + (event.args as { jobId?: bigint }).jobId !== jobId || + !names.includes(event.eventName as (typeof names)[number]) + ) + throw new JobError( + 'INCONSISTENT_READ', + 'The RPC returned a different job or unsupported event.' + ); + const key = `${log.blockNumber}:${log.logIndex}`; + const previous = unique.get(key); + if (previous && fingerprint(previous.log) !== fingerprint(log)) + throw new JobError( + 'INCONSISTENT_READ', + 'Two different logs occupy the same job cursor.' + ); + unique.set(key, { log, event }); + } + const ordered = [...unique.values()].sort( + (a, b) => + Number( + (a.log.blockNumber as bigint) - (b.log.blockNumber as bigint) + ) || (a.log.logIndex as number) - (b.log.logIndex as number) + ); + for (const { log, event } of ordered) { + if (stopped) return; + const blockNumber = log.blockNumber as bigint; + const logIndex = log.logIndex as number; + if ( + cursor && + (blockNumber < cursor.blockNumber || + (blockNumber === cursor.blockNumber && + logIndex <= cursor.logIndex)) + ) + continue; + cursor = { + blockNumber, + logIndex, + ...(log.blockHash ? { blockHash: log.blockHash } : {}), + }; + notify({ + jobId, + kind: kinds[ + names.indexOf(event.eventName as (typeof names)[number]) + ], + txHash: log.transactionHash ?? undefined, + blockNumber, + data: event.args, + cursor: { ...cursor }, + }); + } + next = end + BigInt(1); + } + } catch (error) { + if (stopped) return; + if (error instanceof JobError && error.code === 'WATCH_REORG') + stopped = true; + notify({ jobId, kind: 'Error', error, cursor: cursor && { ...cursor } }); + } finally { + if (!stopped) + timer = setTimeout(() => { + void poll(); + }, interval); + } + }; + void poll(); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; +} diff --git a/packages/core/src/lib/market/__tests__/ci-reporter.spec.ts b/packages/core/src/lib/market/__tests__/ci-reporter.spec.ts new file mode 100644 index 000000000..8fecaeaf6 --- /dev/null +++ b/packages/core/src/lib/market/__tests__/ci-reporter.spec.ts @@ -0,0 +1,38 @@ +const fs = require('node:fs'); +const CiReporter = require('../../../../__e2e__/ci/ci-reporter'); +export {}; +it('reports final counters without relying on the pre-final Jest success flag', () => { + const write = jest + .spyOn(fs, 'writeFileSync') + .mockImplementation(() => undefined); + const mkdir = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + try { + const report = (extra: object) => { + new CiReporter().onRunComplete(null, { + numTotalTests: 1, + numPassedTests: 1, + numFailedTests: 0, + numPendingTests: 0, + success: false, + testResults: [], + ...extra, + }); + return JSON.parse( + write.mock.calls[write.mock.calls.length - 1][1] as string + ); + }; + expect(report({})).toMatchObject({ + success: true, + jestReportedSuccess: false, + }); + expect(report({ numFailedTestSuites: 1 }).success).toBe(false); + expect(report({ numRuntimeErrorTestSuites: 1 }).success).toBe(false); + expect(report({ wasInterrupted: true }).success).toBe(false); + expect(report({ numPassedTests: 0, numPendingTests: 1 }).success).toBe( + false + ); + } finally { + write.mockRestore(); + mkdir.mockRestore(); + } +}); diff --git a/packages/core/src/lib/market/__tests__/deposit.spec.ts b/packages/core/src/lib/market/__tests__/deposit.spec.ts new file mode 100644 index 000000000..07008a852 --- /dev/null +++ b/packages/core/src/lib/market/__tests__/deposit.spec.ts @@ -0,0 +1,384 @@ +import { + decodeFunctionData, + encodeAbiParameters, + encodeEventTopics, + erc20Abi, + zeroHash, + type Address, + type Hex, +} from 'viem'; +import { depositJob, type DepositCheckpoint } from '../deposit'; +import { DONUT_MARKETPLACE as D } from '../deployment'; +import { MARKETPLACE_ABI } from '../contracts/UniversalMarketplace'; +import { encodeAgentCardRules } from '../rules'; +import { + fakeResponse, + mockRuntime, +} from '../../agentic/__tests__/mock-runtime'; +import { FakeChain } from '../../agentic/__tests__/fake-chain'; +import { buildJobSpec, encodeJobSpec } from '../../job-spec/codec'; +import { prepareJobRead } from '../../job-spec/read'; +import { specOrigin } from '../../job-spec/helpers'; +import { PRINCIPAL } from '../../job-spec/constants'; +import { CHAIN } from '../../constants/enums'; +import type { AgentCard } from '../types'; +import type { MarketSnapshot, StackRuntime } from '../runtime'; + +const sender = '0x1111111111111111111111111111111111111111' as Address; +const wallet = '0x2222222222222222222222222222222222222222' as Address; +const provider = '0x3333333333333333333333333333333333333333' as Address; +const principalToken = '0x4444444444444444444444444444444444444444' as Address; +function setup(sameToken = false) { + const base = mockRuntime(new FakeChain(), { signer: sender }); + const balances = new Map(); + const key = (token: string | undefined, a: string) => + `${token?.toLowerCase() ?? 'PC'}:${a.toLowerCase()}`; + const token = sameToken ? D.paymentToken : principalToken; + for (const t of [token, D.paymentToken, undefined]) + balances.set(key(t, sender), BigInt(1000)); + const state = { + status: 0, + spent: BigInt(0), + receiptUnavailable: false, + move: true, + }; + const description = encodeJobSpec( + buildJobSpec({ + reads: [ + prepareJobRead({ + chain: 'eip155:97', + target: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [wallet], + }), + ], + checks: [ + { + read: 0, + evaluation: 'COMPARE', + operation: 'GREATER_OR_EQUAL', + target: BigInt(1), + }, + ], + logic: 'ALL', + executeBy: 1700000010, + failFinalAt: 1700000020, + origin: specOrigin(D.marketplace, BigInt(1), BigInt(1), BigInt(10)), + }) + ); + const card = { + cardId: BigInt(1), + version: BigInt(1), + provider, + paymentToken: D.paymentToken, + providerFee: BigInt(5), + rules: { maxGasPerCall: BigInt(7) }, + rulesBytes: encodeAgentCardRules( + { + principalAsset: 0, + maxGasPerCall: BigInt(7), + assets: [ + { + token: { prc20: token }, + maxPerCall: PRINCIPAL, + maxTotal: PRINCIPAL, + }, + ], + allowedCalls: [{ target: token, selector: 'balanceOf(address)' }], + }, + { chain: 'eip155:97' } + ), + } as unknown as AgentCard; + const load = jest.fn(async (_id: bigint, _at: MarketSnapshot) => card); + const read = jest.fn( + async (r: { + functionName: string; + address: string; + args?: readonly unknown[]; + }) => { + switch (r.functionName) { + case 'cardOfJob': + case 'cardVersionOfJob': + return BigInt(1); + case 'agwOfJob': + return wallet; + case 'rulesOfJob': + return zeroHash; + case 'getJob': + return { + client: wallet, + provider, + status: state.status, + expiredAt: 1800000000, + description, + }; + case 'paymentToken': + return D.paymentToken; + case 'isPermissionEnabled': + return true; + case 'getEnabledActions': + return [zeroHash]; + case 'getConfig': + return { initialized: true, assets: [{ spent: state.spent }] }; + case 'balanceOf': + return balances.get(key(r.address, String(r.args?.[0]))) ?? BigInt(0); + default: + throw new Error(`Unexpected ${r.functionName}`); + } + } + ); + const rt: StackRuntime = { + agw: { + ...base, + reader: { + ...base.reader, + readContract: read, + getBlockNumber: async () => BigInt(20), + getBalance: async ({ address }) => + balances.get(key(undefined, address)) ?? BigInt(0), + getLogs: async () => + [ + { + address: D.marketplace, + blockNumber: BigInt(10), + data: encodeAbiParameters( + [ + { type: 'uint256' }, + { type: 'bytes32' }, + { type: 'uint256' }, + { type: 'uint256' }, + ], + [BigInt(1), zeroHash, BigInt(10), BigInt(1)] + ), + topics: encodeEventTopics({ + abi: MARKETPLACE_ABI, + eventName: 'JobOpened', + args: { cardId: BigInt(1), owner: sender, agw: wallet }, + }), + }, + ] as never, + }, + }, + account: () => sender, + wallet: () => undefined, + send: jest.fn(), + signTypedData: jest.fn(), + jobBlocks: new Map([[BigInt(1), BigInt(10)]]), + }; + base.executeMock.mockImplementation( + async (p: { + to: Address; + data?: Hex; + value?: bigint; + funds?: { amount: bigint }; + }) => { + let asset: Address | undefined; + let amount = p.value ?? BigInt(0); + if (p.funds) { + asset = D.paymentToken; + amount = p.funds.amount; + } else if (p.data && p.data !== '0x') { + const call = decodeFunctionData({ abi: erc20Abi, data: p.data }); + if (call.functionName !== 'transfer' || call.args[0] !== wallet) + throw new Error('Wrong transfer'); + asset = p.to; + amount = call.args[1]; + } + if (state.move) { + balances.set( + key(asset, wallet), + (balances.get(key(asset, wallet)) ?? BigInt(0)) + amount + ); + balances.set( + key(asset, sender), + (balances.get(key(asset, sender)) ?? BigInt(0)) - amount + ); + } + const tx = fakeResponse(); + const wait = tx.wait.bind(tx); + return { + ...tx, + wait: async () => { + if (state.receiptUnavailable) throw new Error('RPC timed out'); + return wait(); + }, + }; + } + ); + const checkpoints = new Map(); + const run = (options: Parameters[2] = {}) => + depositJob(rt, BigInt(1), options, load, checkpoints); + return { + run, + rt, + balances, + key, + token, + load, + card, + state, + execute: base.executeMock, + checkpoints, + }; +} +it('uses the opened version and original principal, not a newly updated card', async () => { + const f = setup(); + const result = await f.run(); + expect(f.load.mock.calls[0][1].blockNumber).toBe(BigInt(10)); + expect(result.cardVersion).toBe(BigInt(1)); + expect(result.txs.map((t) => [t.step, t.amount])).toEqual([ + ['PRINCIPAL', BigInt(10)], + ['PROVIDER_FEE', BigInt(5)], + ['PC', BigInt(7)], + ]); + expect((await f.run()).txs).toEqual([]); +}); +it('combines a shared principal/fee token before subtracting the existing balance', async () => { + const f = setup(true); + f.balances.set(f.key(f.token, wallet), BigInt(3)); + const result = await f.run({ pcAmount: BigInt(0) }); + expect(result.txs).toHaveLength(1); + expect(result.txs[0]).toMatchObject({ + step: 'PRINCIPAL_AND_FEE', + amount: BigInt(12), + }); +}); +it('preflights every asset before sending any partial deposit', async () => { + const f = setup(); + f.balances.set(f.key(D.paymentToken, sender), BigInt(0)); + await expect(f.run()).rejects.toMatchObject({ + code: 'DEPOSIT_INCOMPLETE', + details: { causeCode: 'INSUFFICIENT_FUNDS', txs: [] }, + }); + expect(f.execute).not.toHaveBeenCalled(); +}); +it('resumes an uncertain receipt without resending the committed principal', async () => { + const f = setup(); + f.state.receiptUnavailable = true; + await expect(f.run()).rejects.toMatchObject({ + code: 'DEPOSIT_INCOMPLETE', + details: { txs: [expect.objectContaining({ step: 'PRINCIPAL' })] }, + }); + expect(f.execute).toHaveBeenCalledTimes(1); + // An unresolved prior receipt must still prevent another transaction. + await expect(f.run()).rejects.toMatchObject({ code: 'DEPOSIT_INCOMPLETE' }); + expect(f.execute).toHaveBeenCalledTimes(1); + f.state.receiptUnavailable = false; + const resumed = await f.run(); + expect(resumed.txs.map((t) => t.step)).toEqual([ + 'PRINCIPAL', + 'PROVIDER_FEE', + 'PC', + ]); + expect(f.execute).toHaveBeenCalledTimes(3); +}); +it('refuses to replenish a resumed transfer that has since been consumed', async () => { + const f = setup(); + f.state.receiptUnavailable = true; + await expect(f.run()).rejects.toMatchObject({ code: 'DEPOSIT_INCOMPLETE' }); + f.state.receiptUnavailable = false; + f.balances.set(f.key(f.token, wallet), BigInt(0)); + await expect(f.run()).rejects.toMatchObject({ + details: { causeCode: 'DEPOSIT_BALANCE_CHANGED' }, + }); + expect(f.execute).toHaveBeenCalledTimes(1); +}); +it('fails closed if same-block updates hide the original card version', async () => { + const f = setup(); + f.card.version = BigInt(2); + await expect(f.run()).rejects.toMatchObject({ code: 'HISTORY_UNAVAILABLE' }); + expect(f.execute).not.toHaveBeenCalled(); +}); +it('refuses terminal jobs and permissions that have already moved assets', async () => { + const f = setup(); + f.state.status = 1; + await expect(f.run()).rejects.toMatchObject({ code: 'WRONG_STATUS' }); + f.state.status = 0; + f.state.spent = BigInt(1); + await expect(f.run()).rejects.toMatchObject({ + details: { causeCode: 'DEPOSIT_ALREADY_USED' }, + }); + expect(f.execute).not.toHaveBeenCalled(); +}); +it('retains a receipt when a token returns success without delivering the required balance', async () => { + const f = setup(); + f.state.move = false; + await expect(f.run()).rejects.toMatchObject({ + details: { causeCode: 'DEPOSIT_BALANCE_CHANGED', txs: [expect.anything()] }, + }); + expect(f.execute).toHaveBeenCalledTimes(1); +}); +it('uses an explicit registered origin route and keeps its recipient outside AGW execution', async () => { + const f = setup(true); + f.rt.agw.signerIsPushNative = () => false; + f.rt.agw.signerOrigin = () => ({ + chain: CHAIN.ETHEREUM_SEPOLIA, + address: sender, + }); + f.balances.set(f.key(D.paymentToken, sender), BigInt(0)); + const result = await f.run({ funding: 'origin', pcAmount: BigInt(0) }); + expect(result.txs[0]).toMatchObject({ + step: 'PRINCIPAL_AND_FEE', + amount: BigInt(15), + }); + expect(f.execute).toHaveBeenCalledWith( + { + to: wallet, + funds: { + token: expect.objectContaining({ symbol: 'USDC' }), + amount: BigInt(15), + }, + }, + expect.anything() + ); +}); +it('refuses an origin token mismatch before sending any leg', async () => { + const f = setup(); + f.rt.agw.signerIsPushNative = () => false; + f.rt.agw.signerOrigin = () => ({ + chain: CHAIN.ETHEREUM_SEPOLIA, + address: sender, + }); + await expect(f.run({ funding: 'origin' })).rejects.toMatchObject({ + details: { causeCode: 'TOKEN_ROUTE_UNAVAILABLE' }, + }); + expect(f.execute).not.toHaveBeenCalled(); +}); +it('blocks retries when the transport reports a broadcast hash without returning a response', async () => { + const f = setup(); + const hash = ('0x' + 'ab'.repeat(32)) as Hex; + f.execute.mockImplementationOnce(async () => { + f.balances.set(f.key(f.token, wallet), BigInt(10)); + throw Object.assign(new Error('Inbound confirmation unavailable'), { + gatewayTxHash: hash, + }); + }); + await expect(f.run()).rejects.toMatchObject({ + details: { + txs: [], + pending: { txHash: hash, token: f.token, amount: BigInt(10) }, + }, + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'DEPOSIT_INCOMPLETE', + details: { pending: { txHash: hash } }, + }); + expect(f.execute).toHaveBeenCalledTimes(1); + const result = await f.run({ + resume: [ + { + step: 'PRINCIPAL', + token: f.token, + amount: BigInt(10), + tx: fakeResponse({ hash }), + }, + ], + }); + expect(result.txs.map((t) => t.step)).toEqual([ + 'PRINCIPAL', + 'PROVIDER_FEE', + 'PC', + ]); + expect(f.execute).toHaveBeenCalledTimes(3); +}); diff --git a/packages/core/src/lib/market/__tests__/history.spec.ts b/packages/core/src/lib/market/__tests__/history.spec.ts new file mode 100644 index 000000000..937baf478 --- /dev/null +++ b/packages/core/src/lib/market/__tests__/history.spec.ts @@ -0,0 +1,143 @@ +import { + encodeAbiParameters, + encodeEventTopics, + encodeFunctionData, + keccak256, + stringToHex, + zeroHash, + type Address, + type Hex, + type Log, +} from 'viem'; +import { historicalCard } from '../history'; +import { MARKETPLACE_ABI as ABI } from '../contracts/UniversalMarketplace'; +import { DONUT_MARKETPLACE as D } from '../deployment'; +import { encodeAgentCardRules } from '../rules'; +import { PRINCIPAL } from '../../job-spec/constants'; +import { FakeChain } from '../../agentic/__tests__/fake-chain'; +import { mockRuntime } from '../../agentic/__tests__/mock-runtime'; +import type { StackRuntime } from '../runtime'; +const provider = '0x1111111111111111111111111111111111111111' as Address; +const hash = ('0x' + 'ab'.repeat(32)) as Hex; +function fixture() { + const base = mockRuntime(new FakeChain()); + const rules = encodeAgentCardRules( + { + assets: [ + { + token: { prc20: D.paymentToken }, + maxPerCall: PRINCIPAL, + maxTotal: PRINCIPAL, + }, + ], + principalAsset: 0, + maxGasPerCall: BigInt(17), + allowedCalls: [{ target: provider, selector: 'balanceOf(address)' }], + }, + { chain: 'eip155:97' } + ); + const c = { + provider, + active: true, + jobType: keccak256(stringToHex('test')), + metadataURI: 'test', + metadataHash: zeroHash, + chainNamespace: 'eip155:97', + providerFee: BigInt(7), + paymentToken: D.paymentToken, + principalMin: BigInt(1), + principalMax: BigInt(20), + minDuration: 3600, + maxDuration: 86400, + minExecuteWindow: 0, + settleWindow: 60, + }; + const data = encodeFunctionData({ + abi: ABI, + functionName: 'registerAgentCard', + args: [c, rules, '0x1234'], + }); + const log = { + address: D.marketplace, + blockNumber: BigInt(24443653), + transactionHash: hash, + logIndex: 0, + blockHash: zeroHash, + transactionIndex: 0, + removed: false, + topics: encodeEventTopics({ + abi: ABI, + eventName: 'AgentCardRegistered', + args: { cardId: BigInt(1), provider, jobType: c.jobType }, + }), + data: encodeAbiParameters( + [ + { type: 'string' }, + { type: 'bytes32' }, + { type: 'bytes32' }, + { type: 'bytes32' }, + { type: 'uint256' }, + ], + [ + c.chainNamespace, + keccak256(rules), + keccak256('0x1234'), + zeroHash, + BigInt(7), + ] + ), + } as Log; + const state = { + input: ('0x12345678' + + encodeAbiParameters([{ type: 'bytes' }], [data]).slice(2)) as Hex, + blockNumber: log.blockNumber, + }; + const logs = jest.fn(async () => [log]); + const rt: StackRuntime = { + agw: { + ...base, + reader: { + ...base.reader, + getBlockNumber: async () => BigInt(24443654), + readContract: async () => ({ card: c }), + getLogs: logs, + getTransaction: async () => state, + }, + }, + account: () => provider, + wallet: () => undefined, + send: jest.fn(), + signTypedData: jest.fn(), + }; + const load = jest.fn(async () => { + throw new Error('Archive unavailable'); + }); + const run = () => + historicalCard(rt, BigInt(1), BigInt(1), BigInt(24443654), load); + return { run, load, rt, state, rules, data, log, logs }; +} +it('recovers an exact version from event hashes and wrapped transaction input without archive state', async () => { + const f = fixture(); + expect(await f.run()).toMatchObject({ + version: BigInt(1), + providerFee: BigInt(7), + rulesBytes: f.rules, + rules: { maxGasPerCall: BigInt(17) }, + }); +}); +it('uses matching historical state without scanning transaction history', async () => { + const f = fixture(); + f.load.mockImplementation(async () => ({ version: BigInt(1) } as never)); + expect(await f.run()).toEqual({ version: BigInt(1) }); + expect(f.logs).not.toHaveBeenCalled(); +}); +it('rejects a selector-shaped decoy whose content hashes do not match the emitted version', async () => { + const f = fixture(); + f.state.input = f.data.replace('1234', '4321') as Hex; + await expect(f.run()).rejects.toMatchObject({ code: 'HISTORY_UNAVAILABLE' }); +}); +it('rejects history whose transaction is no longer mined in the event block', async () => { + const f = fixture(); + f.state.blockNumber = BigInt(24443654); + await expect(f.run()).rejects.toMatchObject({ code: 'HISTORY_UNAVAILABLE' }); +}); diff --git a/packages/core/src/lib/market/__tests__/kernel-wire.spec.ts b/packages/core/src/lib/market/__tests__/kernel-wire.spec.ts new file mode 100644 index 000000000..8f1a1fc3d --- /dev/null +++ b/packages/core/src/lib/market/__tests__/kernel-wire.spec.ts @@ -0,0 +1,55 @@ +import { + decodeAbiParameters, + getAbiItem, + toFunctionSelector, + zeroAddress, +} from 'viem'; +import { JOB_KERNEL_ABI } from '../contracts/AgenticCommerce'; +import { decodeCreateJob, encodeCreateJob } from '../kernel-wire'; +it('preserves arbitrary binary JobSpec bytes in the Solidity string slot with its original selector', () => { + const bytes = '0xff00c0af8081'; + const data = encodeCreateJob( + zeroAddress, + zeroAddress, + 1900000000, + { jobSpec: bytes }, + zeroAddress + ); + expect(data.slice(0, 10)).toBe( + toFunctionSelector(getAbiItem({ abi: JOB_KERNEL_ABI, name: 'createJob' })) + ); + expect(decodeCreateJob(data).description).toBe(bytes); + const decoded = decodeAbiParameters( + [ + { type: 'address' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'bytes' }, + { type: 'address' }, + ], + ('0x' + data.slice(10)) as `0x${string}` + ); + expect(decoded[3]).toBe(bytes); +}); +it('supports plain UTF-8 descriptions but refuses conflicting carriers', () => { + expect( + decodeCreateJob( + encodeCreateJob( + zeroAddress, + zeroAddress, + 1, + { description: 'plain job' }, + zeroAddress + ) + ).description + ).toBe('0x706c61696e206a6f62'); + expect(() => + encodeCreateJob( + zeroAddress, + zeroAddress, + 1, + { jobSpec: '0x00', description: 'x' }, + zeroAddress + ) + ).toThrow(); +}); diff --git a/packages/core/src/lib/market/__tests__/namespace.spec.ts b/packages/core/src/lib/market/__tests__/namespace.spec.ts new file mode 100644 index 000000000..daf23427c --- /dev/null +++ b/packages/core/src/lib/market/__tests__/namespace.spec.ts @@ -0,0 +1,182 @@ +import { zeroAddress, zeroHash, type Address } from 'viem'; +import { MarketNamespaceImpl } from '../market'; +import { JobNamespaceImpl } from '../../job/job'; +import { mockRuntime } from '../../agentic/__tests__/mock-runtime'; +import { fakeResponse } from '../../agentic/__tests__/mock-runtime'; +import { FakeChain } from '../../agentic/__tests__/fake-chain'; +import type { StackRuntime } from '../runtime'; +import type { JobPlan } from '../types'; +const owner = '0x1111111111111111111111111111111111111111'; +const provider = '0x2222222222222222222222222222222222222222'; +function runtime(signer: Address = owner) { + const base = mockRuntime(new FakeChain(), { signer }); + const read = jest.fn(async (args: { functionName: string }) => { + switch (args.functionName) { + case 'getJob': + return { + client: owner, + provider, + hook: zeroAddress, + evaluator: provider, + status: 0, + expiredAt: 2000000000, + budget: BigInt(5), + description: '0x', + }; + case 'agentCardCount': + return BigInt(1); + case 'getAgentCard': + return { + card: { + provider, + chainNamespace: 'eip155:11155111', + jobType: zeroHash, + metadataURI: 'invalid template example', + metadataHash: zeroHash, + active: true, + providerFee: BigInt(5), + }, + version: BigInt(1), + verified: false, + disabled: false, + rules: '0xdead', + jobSpecTemplate: '0xdead', + }; + case 'getAgentRating': + return BigInt(0); + case 'cardOfJob': + return BigInt(0); + case 'isWallet': + return false; + default: + throw new Error(`unexpected read ${args.functionName}`); + } + }); + const rt: StackRuntime = { + agw: { + ...base, + reader: { + ...base.reader, + readContract: read, + getBlockNumber: async () => BigInt(10), + }, + }, + account: () => owner as Address, + wallet: () => undefined, + signTypedData: jest.fn(), + send: jest.fn(), + }; + return { rt, read, execute: base.executeMock }; +} +it('a provider’s malformed unverified template cannot break registry summary enumeration', async () => { + const { rt } = runtime(); + const market = new MarketNamespaceImpl(rt); + expect((await market.cards()).cards[0]).toMatchObject({ + provider, + cardId: BigInt(1), + verified: false, + }); + await expect(market.card(BigInt(1))).rejects.toMatchObject({ + code: 'INVALID_AGENT_CARD_RULES', + }); +}); +it('lets a provider deactivate a malformed card without decoding its broken template', async () => { + const { rt, execute } = runtime(provider); + await new MarketNamespaceImpl(rt).agent.setActive(BigInt(1), false); + expect(execute).toHaveBeenCalledTimes(1); +}); +it('delivers the correct terminal family to the per-call hook once when waiting repeatedly', async () => { + const { rt } = runtime(provider); + const events: string[] = []; + const tx = await new JobNamespaceImpl(rt).setBudget(BigInt(1), BigInt(5), { + progressHook: (e) => events.push(e.id), + }); + await tx.wait(); + await tx.wait(); + expect(events.filter((id) => id === 'JOB-TX-199-01')).toHaveLength(1); + expect(events).not.toContain('MARKET-TX-199-01'); +}); +it('plain jobs require an explicitly reviewed budget rather than silently adopting a re-quote', async () => { + const { rt, execute } = runtime(); + const job = new JobNamespaceImpl(rt); + await expect(job.fund(BigInt(1))).rejects.toMatchObject({ + code: 'EXPECTED_BUDGET_REQUIRED', + }); + await expect( + job.fund(BigInt(1), { expectedBudget: BigInt(4) }) + ).rejects.toMatchObject({ code: 'BUDGET_MISMATCH' }); + expect(execute).not.toHaveBeenCalled(); +}); +it('placeholder methods and replacement proposals fail before any reads or signatures', async () => { + const { rt, read } = runtime(); + const job = new JobNamespaceImpl(rt); + const market = new MarketNamespaceImpl(rt); + await expect(job.verify(BigInt(1))).rejects.toMatchObject({ + code: 'CAPABILITY_UNAVAILABLE', + }); + await expect( + job.setBudget(BigInt(1), BigInt(5), { jobSpec: '0x00' }) + ).rejects.toMatchObject({ code: 'CAPABILITY_UNAVAILABLE' }); + await expect( + market.openJob({} as never, { relay: true }) + ).rejects.toMatchObject({ code: 'CAPABILITY_UNAVAILABLE' }); + expect(read).not.toHaveBeenCalled(); + expect(rt.signTypedData).not.toHaveBeenCalled(); +}); +it('checks typed-data capability and external owner deployment before asking for a signature', async () => { + const { rt, read } = runtime(); + rt.canSignTypedData = () => false; + await expect( + new MarketNamespaceImpl(rt).openJob({} as JobPlan) + ).rejects.toMatchObject({ code: 'CAPABILITY_UNAVAILABLE' }); + expect(read).not.toHaveBeenCalled(); + rt.canSignTypedData = () => true; + rt.agw.reader.call = jest.fn(); + rt.agw.reader.getCode = async () => '0x'; + rt.agw.signerIsPushNative = () => false; + await expect( + new MarketNamespaceImpl(rt).openJob({} as JobPlan) + ).rejects.toMatchObject({ code: 'OWNER_NOT_DEPLOYED' }); + expect(rt.signTypedData).not.toHaveBeenCalled(); +}); +it('compares the factory signing domain before prompting the owner', async () => { + const { rt, read } = runtime(); + rt.agw.reader.call = jest.fn(); + rt.agw.reader.getCode = async () => '0x'; + read.mockResolvedValue(zeroHash as never); + const market = new MarketNamespaceImpl(rt); + const plan = { + cardId: BigInt(1), + intent: { + owner, + index: BigInt(0), + signerChainId: BigInt(42101), + deadline: 1800000000, + }, + label: 'test', + job: {}, + } as unknown as JobPlan; + jest.spyOn(market, 'preview').mockResolvedValue(plan); + await expect(market.openJob(plan)).rejects.toMatchObject({ + code: 'INTENT_MISMATCH', + }); + expect(rt.signTypedData).not.toHaveBeenCalled(); + expect(rt.agw.reader.call).not.toHaveBeenCalled(); +}); +it('retains the submitted opening hash when receipt recovery fails, without another signature or write', async () => { + const { rt, read, execute } = runtime(); + const tx = fakeResponse({ + wait: async () => { + throw new Error('Receipt unavailable'); + }, + }); + await expect( + new MarketNamespaceImpl(rt).openJob({} as JobPlan, { resume: tx }) + ).rejects.toMatchObject({ + code: 'OPEN_JOB_RECOVERY_REQUIRED', + details: { txHash: tx.hash }, + }); + expect(rt.signTypedData).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(read).not.toHaveBeenCalled(); +}); diff --git a/packages/core/src/lib/market/__tests__/rules.spec.ts b/packages/core/src/lib/market/__tests__/rules.spec.ts new file mode 100644 index 000000000..6510768a3 --- /dev/null +++ b/packages/core/src/lib/market/__tests__/rules.spec.ts @@ -0,0 +1,115 @@ +import { decodeAbiParameters, encodeAbiParameters } from 'viem'; +import { PRINCIPAL } from '../../job-spec/constants'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { SYNTHETIC_PUSH_ERC20 } from '../../constants/chain'; +import { + CARD_RULES_PARAM, + MARKER_PRINCIPAL, + decodeAgentCardRules, + encodeAgentCardRules, + validateAgentCardRules, + type AgentCardRules, +} from '../rules'; +const token = '0x1111111111111111111111111111111111111111'; +const spender = '0x2222222222222222222222222222222222222222'; +const options = { chain: 'eip155:11155111' }; +function rules(): AgentCardRules { + return { + assets: [ + { token: { prc20: token }, maxPerCall: PRINCIPAL, maxTotal: PRINCIPAL }, + ], + principalAsset: 0, + maxGasPerCall: BigInt(1), + allowedCalls: [ + { + target: token, + selector: 'approve(address,uint256)', + pins: [ + { arg: 0, expected: spender }, + { arg: 1, max: PRINCIPAL }, + ], + }, + { + target: token, + selector: 'transfer(address,uint256)', + pins: [ + { arg: 0, expected: 'agentic:wallet' }, + { arg: 1, max: PRINCIPAL }, + ], + }, + ], + }; +} +it('encodes v2’s four-field rules, accepts pinned approvals and fills published markers', () => { + const r = rules(); + validateAgentCardRules(r); + const bytes = encodeAgentCardRules(r, options); + const w = decodeAbiParameters([CARD_RULES_PARAM], bytes)[0]; + expect(w.assets[0].maxTotal).toBe(BigInt(MARKER_PRINCIPAL)); + expect(w.allowedCalls[0].pins[1].value).toBe(MARKER_PRINCIPAL); + expect(encodeAgentCardRules(decodeAgentCardRules(bytes), options)).toBe( + bytes + ); +}); +it('retains legacy beneficiary fields during an unchanged decode/encode', () => { + const w = decodeAbiParameters( + [CARD_RULES_PARAM], + encodeAgentCardRules(rules(), options) + )[0]; + const bytes = encodeAbiParameters( + [CARD_RULES_PARAM], + [ + { + ...w, + allowedCalls: [ + { + ...w.allowedCalls[1], + hasBeneficiary: true, + beneficiaryOffset: 4, + pins: [], + }, + ], + }, + ] + ); + expect(encodeAgentCardRules(decodeAgentCardRules(bytes), options)).toBe( + bytes + ); +}); +it('uses the explicit execution chain for native-token objects shared by several networks', () => { + const r = rules(); + r.assets[0].token = { + symbol: 'ETH', + decimals: 18, + mechanism: 'native', + address: '0x0000000000000000000000000000000000000000', + }; + const w = decodeAbiParameters( + [CARD_RULES_PARAM], + encodeAgentCardRules(r, options) + )[0]; + expect(w.assets[0].token.toLowerCase()).toBe( + SYNTHETIC_PUSH_ERC20[PUSH_NETWORK.TESTNET_DONUT].pETH.toLowerCase() + ); +}); +it.each(['spender', 'amount', 'recipient', 'increaseAllowance', 'oldArray'])( + 'refuses unsupported approval or transfer authority: %s', + (which) => { + const r = rules(); + if (which === 'spender') r.allowedCalls[0].pins!.shift(); + if (which === 'amount') r.allowedCalls[0].pins!.pop(); + if (which === 'recipient') r.allowedCalls[1].pins!.shift(); + if (which === 'increaseAllowance') + r.allowedCalls[0].selector = 'increaseAllowance(address,uint256)'; + if (which === 'oldArray') Object.assign(r, { approvals: [] }); + expect(() => encodeAgentCardRules(r, options)).toThrow(); + } +); +it('does not silently drop duplicate alternatives or caps off the principal asset', () => { + const r = rules(); + r.allowedCalls.push(r.allowedCalls[0]); + expect(() => encodeAgentCardRules(r, options)).toThrow(/Duplicate/); + r.allowedCalls.pop(); + r.assets.push({ token: { prc20: spender }, maxPerCall: PRINCIPAL }); + expect(() => encodeAgentCardRules(r, options)).toThrow(/principal asset/); +}); diff --git a/packages/core/src/lib/market/contracts/AgenticCommerce.ts b/packages/core/src/lib/market/contracts/AgenticCommerce.ts new file mode 100644 index 000000000..261909e6e --- /dev/null +++ b/packages/core/src/lib/market/contracts/AgenticCommerce.ts @@ -0,0 +1,1313 @@ +// Generated from sdk-iteration-v1@443b4e2; Donut marketplace v2. +export const JOB_KERNEL_ABI = [ + { + type: 'constructor', + inputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'ADMIN_ROLE', + inputs: [], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'BPS_DENOMINATOR', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'DEFAULT_ADMIN_ROLE', + inputs: [], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'EVALUATOR_GRACE_PERIOD', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'MIN_EXPIRY_WINDOW', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'batchDetachHook', + inputs: [ + { + name: 'jobIds', + type: 'uint256[]', + internalType: 'uint256[]', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'claimRefund', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'complete', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'reason', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'optParams', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'createJob', + inputs: [ + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + { + name: 'evaluator', + type: 'address', + internalType: 'address', + }, + { + name: 'expiredAt', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'description', + type: 'string', + internalType: 'string', + }, + { + name: 'hook', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'emergencyWithdraw', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'to', + type: 'address', + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'evaluatorFeeBP', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'fund', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'expectedBudget', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'optParams', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'getJob', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'tuple', + internalType: 'struct IAgenticCommerce.Job', + components: [ + { + name: 'client', + type: 'address', + internalType: 'address', + }, + { + name: 'status', + type: 'uint8', + internalType: 'enum IAgenticCommerce.JobStatus', + }, + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + { + name: 'expiredAt', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'evaluator', + type: 'address', + internalType: 'address', + }, + { + name: 'hook', + type: 'address', + internalType: 'address', + }, + { + name: 'budget', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'description', + type: 'string', + internalType: 'string', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getRoleAdmin', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'grantRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'hasRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'initialize', + inputs: [ + { + name: 'paymentToken_', + type: 'address', + internalType: 'address', + }, + { + name: 'treasury_', + type: 'address', + internalType: 'address', + }, + { + name: 'admin_', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'jobCounter', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'pause', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'paused', + inputs: [], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'paymentToken', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IERC20', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'platformFeeBP', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'platformTreasury', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'reject', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'reason', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'optParams', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'renounceRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'callerConfirmation', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'revokeRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setBudget', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'amount', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'optParams', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setEvaluatorFee', + inputs: [ + { + name: 'feeBP', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setHookWhitelist', + inputs: [ + { + name: 'hook', + type: 'address', + internalType: 'address', + }, + { + name: 'status', + type: 'bool', + internalType: 'bool', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setPlatformFee', + inputs: [ + { + name: 'feeBP', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'treasury', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setProvider', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + { + name: 'optParams', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'submit', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'deliverable', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'optParams', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [ + { + name: 'interfaceId', + type: 'bytes4', + internalType: 'bytes4', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'totalEscrowed', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'unpause', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'whitelistedHooks', + inputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'event', + name: 'BudgetSet', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'EmergencyWithdraw', + inputs: [ + { + name: 'token', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'to', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'EvaluatorFeePaid', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'evaluator', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'EvaluatorFeeUpdated', + inputs: [ + { + name: 'feeBP', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'HookDetached', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'hook', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'HookWhitelistUpdated', + inputs: [ + { + name: 'hook', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'status', + type: 'bool', + indexed: false, + internalType: 'bool', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Initialized', + inputs: [ + { + name: 'version', + type: 'uint64', + indexed: false, + internalType: 'uint64', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'JobCompleted', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'evaluator', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'reason', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'JobCreated', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'client', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'provider', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'evaluator', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'expiredAt', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'hook', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'JobExpired', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'JobFunded', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'client', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'JobRejected', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'rejector', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'reason', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'JobSubmitted', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'provider', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'deliverable', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Paused', + inputs: [ + { + name: 'account', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'PaymentReleased', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'provider', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'PlatformFeePaid', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'treasury', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'PlatformFeeUpdated', + inputs: [ + { + name: 'feeBP', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'treasury', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'ProviderSet', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'provider', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Refunded', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'client', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleAdminChanged', + inputs: [ + { + name: 'role', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'previousAdminRole', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'newAdminRole', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleGranted', + inputs: [ + { + name: 'role', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'sender', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleRevoked', + inputs: [ + { + name: 'role', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'sender', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Unpaused', + inputs: [ + { + name: 'account', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'AccessControlBadConfirmation', + inputs: [], + }, + { + type: 'error', + name: 'AccessControlUnauthorizedAccount', + inputs: [ + { + name: 'account', + type: 'address', + internalType: 'address', + }, + { + name: 'neededRole', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'BudgetMismatch', + inputs: [], + }, + { + type: 'error', + name: 'ClientIsProvider', + inputs: [], + }, + { + type: 'error', + name: 'EnforcedPause', + inputs: [], + }, + { + type: 'error', + name: 'EvaluatorIsProvider', + inputs: [], + }, + { + type: 'error', + name: 'ExpectedPause', + inputs: [], + }, + { + type: 'error', + name: 'ExpiryTooShort', + inputs: [], + }, + { + type: 'error', + name: 'FeesTooHigh', + inputs: [], + }, + { + type: 'error', + name: 'GracePeriodActive', + inputs: [], + }, + { + type: 'error', + name: 'HookNotWhitelisted', + inputs: [], + }, + { + type: 'error', + name: 'InsufficientUnattributedBalance', + inputs: [ + { + name: 'requested', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'available', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'InvalidHook', + inputs: [], + }, + { + type: 'error', + name: 'InvalidInitialization', + inputs: [], + }, + { + type: 'error', + name: 'InvalidJob', + inputs: [], + }, + { + type: 'error', + name: 'NotInitializing', + inputs: [], + }, + { + type: 'error', + name: 'ProviderNotSet', + inputs: [], + }, + { + type: 'error', + name: 'ReentrancyGuardReentrantCall', + inputs: [], + }, + { + type: 'error', + name: 'SafeERC20FailedOperation', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'Unauthorized', + inputs: [], + }, + { + type: 'error', + name: 'WrongStatus', + inputs: [], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, +] as const; diff --git a/packages/core/src/lib/market/contracts/RulesBindingHook.ts b/packages/core/src/lib/market/contracts/RulesBindingHook.ts new file mode 100644 index 000000000..6a8fc5d8c --- /dev/null +++ b/packages/core/src/lib/market/contracts/RulesBindingHook.ts @@ -0,0 +1,310 @@ +// Generated from sdk-iteration-v1@443b4e2; Donut marketplace v2. +export const RULES_HOOK_ABI = [ + { + type: 'constructor', + inputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'AGW_FACTORY', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'KERNEL', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'SESSION_ENGINE', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'activeJobOf', + inputs: [ + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'afterAction', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'selector', + type: 'bytes4', + internalType: 'bytes4', + }, + { + name: 'data', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'agwOfJob', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'beforeAction', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'selector', + type: 'bytes4', + internalType: 'bytes4', + }, + { + name: 'data', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'initialize', + inputs: [ + { + name: 'kernel_', + type: 'address', + internalType: 'address', + }, + { + name: 'agwFactory_', + type: 'address', + internalType: 'address', + }, + { + name: 'sessionEngine_', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'rulesOfJob', + inputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [ + { + name: 'interfaceId', + type: 'bytes4', + internalType: 'bytes4', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'event', + name: 'Initialized', + inputs: [ + { + name: 'version', + type: 'uint64', + indexed: false, + internalType: 'uint64', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RulesBound', + inputs: [ + { + name: 'jobId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'agw', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'rulesId', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'AGWHasLiveJob', + inputs: [ + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + { + name: 'liveJobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'CallerIsNotAGW', + inputs: [ + { + name: 'caller', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'CallerIsNotKernel', + inputs: [ + { + name: 'caller', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'InvalidInitialization', + inputs: [], + }, + { + type: 'error', + name: 'InvalidOptParams', + inputs: [ + { + name: 'length', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'NotInitializing', + inputs: [], + }, + { + type: 'error', + name: 'RulesNotLive', + inputs: [ + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + { + name: 'rulesId', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, +] as const; diff --git a/packages/core/src/lib/market/contracts/UniversalMarketplace.ts b/packages/core/src/lib/market/contracts/UniversalMarketplace.ts new file mode 100644 index 000000000..6cb8b3a37 --- /dev/null +++ b/packages/core/src/lib/market/contracts/UniversalMarketplace.ts @@ -0,0 +1,2291 @@ +// Generated from sdk-iteration-v1@443b4e2; Donut marketplace v2. +export const MARKETPLACE_ABI = [ + { + type: 'constructor', + inputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'ADMIN_ROLE', + inputs: [], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'AGW_FACTORY', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IAGWFactory', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'DEFAULT_ADMIN_ROLE', + inputs: [], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'KERNEL', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IAgenticCommerce', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'MODE_SINGLE', + inputs: [], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'OPENJOB_LANE', + inputs: [], + outputs: [ + { + name: '', + type: 'uint192', + internalType: 'uint192', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'PUSH_CHAIN_HASH', + inputs: [], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'RULES', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IUniversalMarketplaceRules', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'activeJobOf', + inputs: [ + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'agentCardCount', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'agentCardOf', + inputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'agentCardVersion', + inputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'agwOfJob', + inputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'buildCreateJobCalldata', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'owner', + type: 'address', + internalType: 'address', + }, + { + name: 'index', + type: 'uint96', + internalType: 'uint96', + }, + { + name: 'job', + type: 'tuple', + internalType: 'struct JobInputs', + components: [ + { + name: 'principal', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'expiredAt', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'executeBy', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'params', + type: 'int256[]', + internalType: 'int256[]', + }, + ], + }, + ], + outputs: [ + { + name: 'mode', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'executionCalldata', + type: 'bytes', + internalType: 'bytes', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'cardOfJob', + inputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'cardVersionOfJob', + inputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'ceaDeployment', + inputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [ + { + name: 'ceaFactory', + type: 'address', + internalType: 'address', + }, + { + name: 'ceaProxyImpl', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'computeCEA', + inputs: [ + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + { + name: 'chainHash', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'disableAgentCard', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'evaluator', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getAgentCard', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: 'v', + type: 'tuple', + internalType: 'struct AgentCardView', + components: [ + { + name: 'card', + type: 'tuple', + internalType: 'struct AgentCard', + components: [ + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + { + name: 'jobType', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'metadataURI', + type: 'string', + internalType: 'string', + }, + { + name: 'metadataHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'chainNamespace', + type: 'string', + internalType: 'string', + }, + { + name: 'providerFee', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'paymentToken', + type: 'address', + internalType: 'address', + }, + { + name: 'principalMin', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'principalMax', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'minDuration', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'maxDuration', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'minExecuteWindow', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'settleWindow', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'active', + type: 'bool', + internalType: 'bool', + }, + ], + }, + { + name: 'rules', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'jobSpecTemplate', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'version', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'verified', + type: 'bool', + internalType: 'bool', + }, + { + name: 'disabled', + type: 'bool', + internalType: 'bool', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getAgentRating', + inputs: [ + { + name: 'agent', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getRoleAdmin', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'grantRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'hasRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'hook', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'initialize', + inputs: [ + { + name: 'p', + type: 'tuple', + internalType: 'struct InitParams', + components: [ + { + name: 'agwFactory', + type: 'address', + internalType: 'address', + }, + { + name: 'kernel', + type: 'address', + internalType: 'address', + }, + { + name: 'hook', + type: 'address', + internalType: 'address', + }, + { + name: 'evaluator', + type: 'address', + internalType: 'address', + }, + { + name: 'rules', + type: 'address', + internalType: 'address', + }, + { + name: 'admin', + type: 'address', + internalType: 'address', + }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'isAgentCardDisabled', + inputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isAgentCardVerified', + inputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'openJob', + inputs: [ + { + name: 'p', + type: 'tuple', + internalType: 'struct OpenJobParams', + components: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'cardVersion', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'job', + type: 'tuple', + internalType: 'struct JobInputs', + components: [ + { + name: 'principal', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'expiredAt', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'executeBy', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'params', + type: 'int256[]', + internalType: 'int256[]', + }, + ], + }, + { + name: 'session', + type: 'tuple', + internalType: 'struct Session', + components: [ + { + name: 'sessionValidator', + type: 'address', + internalType: 'address', + }, + { + name: 'sessionValidatorInitData', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'salt', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'userOpPolicies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + { + name: 'erc7739Policies', + type: 'tuple', + internalType: 'struct ERC7739Data', + components: [ + { + name: 'allowedERC7739Content', + type: 'tuple[]', + internalType: 'struct ERC7739Context[]', + components: [ + { + name: 'appDomainSeparator', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'contentNames', + type: 'string[]', + internalType: 'string[]', + }, + ], + }, + { + name: 'erc1271Policies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + ], + }, + { + name: 'actions', + type: 'tuple[]', + internalType: 'struct ActionData[]', + components: [ + { + name: 'actionTargetSelector', + type: 'bytes4', + internalType: 'bytes4', + }, + { + name: 'actionTarget', + type: 'address', + internalType: 'address', + }, + { + name: 'actionPolicies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + ], + }, + { + name: 'permitERC4337Paymaster', + type: 'bool', + internalType: 'bool', + }, + ], + }, + { + name: 'intent', + type: 'tuple', + internalType: 'struct OwnerIntent', + components: [ + { + name: 'owner', + type: 'address', + internalType: 'address', + }, + { + name: 'wallet', + type: 'address', + internalType: 'address', + }, + { + name: 'executor', + type: 'address', + internalType: 'address', + }, + { + name: 'index', + type: 'uint96', + internalType: 'uint96', + }, + { + name: 'sessionHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'mode', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'execCalldataHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'nonceKey', + type: 'uint192', + internalType: 'uint192', + }, + { + name: 'nonceSeq', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'grantNonce', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'deadline', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'signerChainId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + name: 'sig', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'label', + type: 'string', + internalType: 'string', + }, + ], + }, + ], + outputs: [ + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + { + name: 'rulesId', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'pause', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'paused', + inputs: [], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'previewIntent', + inputs: [ + { + name: 'r', + type: 'tuple', + internalType: 'struct IntentRequest', + components: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'owner', + type: 'address', + internalType: 'address', + }, + { + name: 'index', + type: 'uint96', + internalType: 'uint96', + }, + { + name: 'job', + type: 'tuple', + internalType: 'struct JobInputs', + components: [ + { + name: 'principal', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'expiredAt', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'executeBy', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'params', + type: 'int256[]', + internalType: 'int256[]', + }, + ], + }, + { + name: 'deadline', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'signerChainId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + name: 's', + type: 'tuple', + internalType: 'struct Session', + components: [ + { + name: 'sessionValidator', + type: 'address', + internalType: 'address', + }, + { + name: 'sessionValidatorInitData', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'salt', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'userOpPolicies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + { + name: 'erc7739Policies', + type: 'tuple', + internalType: 'struct ERC7739Data', + components: [ + { + name: 'allowedERC7739Content', + type: 'tuple[]', + internalType: 'struct ERC7739Context[]', + components: [ + { + name: 'appDomainSeparator', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'contentNames', + type: 'string[]', + internalType: 'string[]', + }, + ], + }, + { + name: 'erc1271Policies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + ], + }, + { + name: 'actions', + type: 'tuple[]', + internalType: 'struct ActionData[]', + components: [ + { + name: 'actionTargetSelector', + type: 'bytes4', + internalType: 'bytes4', + }, + { + name: 'actionTarget', + type: 'address', + internalType: 'address', + }, + { + name: 'actionPolicies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + ], + }, + { + name: 'permitERC4337Paymaster', + type: 'bool', + internalType: 'bool', + }, + ], + }, + ], + outputs: [ + { + name: 'i', + type: 'tuple', + internalType: 'struct OwnerIntent', + components: [ + { + name: 'owner', + type: 'address', + internalType: 'address', + }, + { + name: 'wallet', + type: 'address', + internalType: 'address', + }, + { + name: 'executor', + type: 'address', + internalType: 'address', + }, + { + name: 'index', + type: 'uint96', + internalType: 'uint96', + }, + { + name: 'sessionHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'mode', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'execCalldataHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'nonceKey', + type: 'uint192', + internalType: 'uint192', + }, + { + name: 'nonceSeq', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'grantNonce', + type: 'uint64', + internalType: 'uint64', + }, + { + name: 'deadline', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'signerChainId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'registerAgentCard', + inputs: [ + { + name: 'c', + type: 'tuple', + internalType: 'struct AgentCard', + components: [ + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + { + name: 'jobType', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'metadataURI', + type: 'string', + internalType: 'string', + }, + { + name: 'metadataHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'chainNamespace', + type: 'string', + internalType: 'string', + }, + { + name: 'providerFee', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'paymentToken', + type: 'address', + internalType: 'address', + }, + { + name: 'principalMin', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'principalMax', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'minDuration', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'maxDuration', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'minExecuteWindow', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'settleWindow', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'active', + type: 'bool', + internalType: 'bool', + }, + ], + }, + { + name: 'rules', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'jobSpecTemplate', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'renounceRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'callerConfirmation', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'revokeAgentCardVerification', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'revokeRole', + inputs: [ + { + name: 'role', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'rulesOfJob', + inputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'setAgentCardActive', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'active', + type: 'bool', + internalType: 'bool', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setAgentRating', + inputs: [ + { + name: 'agent', + type: 'address', + internalType: 'address', + }, + { + name: 'rating', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setCEADeployment', + inputs: [ + { + name: 'chainHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'ceaFactory', + type: 'address', + internalType: 'address', + }, + { + name: 'ceaProxyImpl', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setEvaluator', + inputs: [ + { + name: 'evaluator_', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setHook', + inputs: [ + { + name: 'hook_', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [ + { + name: 'interfaceId', + type: 'bytes4', + internalType: 'bytes4', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'unpause', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'updateAgentCard', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'c', + type: 'tuple', + internalType: 'struct AgentCard', + components: [ + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + { + name: 'jobType', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'metadataURI', + type: 'string', + internalType: 'string', + }, + { + name: 'metadataHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'chainNamespace', + type: 'string', + internalType: 'string', + }, + { + name: 'providerFee', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'paymentToken', + type: 'address', + internalType: 'address', + }, + { + name: 'principalMin', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'principalMax', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'minDuration', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'maxDuration', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'minExecuteWindow', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'settleWindow', + type: 'uint32', + internalType: 'uint32', + }, + { + name: 'active', + type: 'bool', + internalType: 'bool', + }, + ], + }, + { + name: 'rules', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'jobSpecTemplate', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'verifyAgentCard', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'cardVersion', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'version', + inputs: [], + outputs: [ + { + name: '', + type: 'string', + internalType: 'string', + }, + ], + stateMutability: 'pure', + }, + { + type: 'event', + name: 'AgentCardDisabled', + inputs: [ + { + name: 'cardId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'AgentCardRegistered', + inputs: [ + { + name: 'cardId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'provider', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'jobType', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'chainNamespace', + type: 'string', + indexed: false, + internalType: 'string', + }, + { + name: 'rulesHash', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + { + name: 'jobSpecHash', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + { + name: 'metadataHash', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + { + name: 'providerFee', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'AgentCardStatusChanged', + inputs: [ + { + name: 'cardId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'active', + type: 'bool', + indexed: false, + internalType: 'bool', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'AgentCardUpdated', + inputs: [ + { + name: 'cardId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'version', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'rulesHash', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + { + name: 'jobSpecHash', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + { + name: 'metadataHash', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + { + name: 'providerFee', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'AgentCardVerificationRevoked', + inputs: [ + { + name: 'cardId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'AgentCardVerified', + inputs: [ + { + name: 'cardId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'version', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'AgentRatingSet', + inputs: [ + { + name: 'agent', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'rating', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'CEADeploymentSet', + inputs: [ + { + name: 'chainHash', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'ceaFactory', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'ceaProxyImpl', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'EvaluatorUpdated', + inputs: [ + { + name: 'evaluator', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'HookUpdated', + inputs: [ + { + name: 'hook', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Initialized', + inputs: [ + { + name: 'version', + type: 'uint64', + indexed: false, + internalType: 'uint64', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'JobOpened', + inputs: [ + { + name: 'cardId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'owner', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'agw', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'jobId', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'rulesId', + type: 'bytes32', + indexed: false, + internalType: 'bytes32', + }, + { + name: 'principal', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'cardVersion', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Paused', + inputs: [ + { + name: 'account', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleAdminChanged', + inputs: [ + { + name: 'role', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'previousAdminRole', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'newAdminRole', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleGranted', + inputs: [ + { + name: 'role', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'sender', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleRevoked', + inputs: [ + { + name: 'role', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'account', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'sender', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Unpaused', + inputs: [ + { + name: 'account', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'AGWMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'AccessControlBadConfirmation', + inputs: [], + }, + { + type: 'error', + name: 'AccessControlUnauthorizedAccount', + inputs: [ + { + name: 'account', + type: 'address', + internalType: 'address', + }, + { + name: 'neededRole', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'AgentCardAlreadyRegistered', + inputs: [ + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'AgentCardDisabled', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'AgentCardIdentityImmutable', + inputs: [], + }, + { + type: 'error', + name: 'AgentCardInactive', + inputs: [], + }, + { + type: 'error', + name: 'AgentCardVersionMismatch', + inputs: [ + { + name: 'provided', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'current', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'CallerIsNotProvider', + inputs: [], + }, + { + type: 'error', + name: 'ChainNotSupported', + inputs: [ + { + name: 'chainHash', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'EnforcedPause', + inputs: [], + }, + { + type: 'error', + name: 'ExecuteByOutOfRange', + inputs: [], + }, + { + type: 'error', + name: 'ExecutorMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'ExpectedPause', + inputs: [], + }, + { + type: 'error', + name: 'ExpiryOutOfRange', + inputs: [], + }, + { + type: 'error', + name: 'HookNotWhitelisted', + inputs: [], + }, + { + type: 'error', + name: 'IntentExecMismatch', + inputs: [ + { + name: 'actualCalldataHash', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'IntentSessionMismatch', + inputs: [ + { + name: 'actual', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'IntentWalletMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'provided', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'InvalidAgentCard', + inputs: [ + { + name: 'reason', + type: 'string', + internalType: 'string', + }, + ], + }, + { + type: 'error', + name: 'InvalidInitialization', + inputs: [], + }, + { + type: 'error', + name: 'JobMismatch', + inputs: [], + }, + { + type: 'error', + name: 'NotInitializing', + inputs: [], + }, + { + type: 'error', + name: 'PrincipalOutOfRange', + inputs: [], + }, + { + type: 'error', + name: 'ProviderIsEvaluator', + inputs: [], + }, + { + type: 'error', + name: 'ReentrancyGuardReentrantCall', + inputs: [], + }, + { + type: 'error', + name: 'UnexpectedJobCount', + inputs: [ + { + name: 'before', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'afterCount', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'WalletHasActiveJob', + inputs: [ + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'WalletOwnerMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, +] as const; diff --git a/packages/core/src/lib/market/contracts/UniversalMarketplaceErrors.ts b/packages/core/src/lib/market/contracts/UniversalMarketplaceErrors.ts new file mode 100644 index 000000000..0d302d29e --- /dev/null +++ b/packages/core/src/lib/market/contracts/UniversalMarketplaceErrors.ts @@ -0,0 +1,412 @@ +// Generated from sdk-iteration-v1@443b4e2; Donut marketplace v2. +export const MARKET_ERRORS_ABI = [ + { + type: 'error', + name: 'AGWMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'ActionCount', + inputs: [], + }, + { + type: 'error', + name: 'ActionsMismatch', + inputs: [], + }, + { + type: 'error', + name: 'AgentCardAlreadyRegistered', + inputs: [ + { + name: 'provider', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'AgentCardDisabled', + inputs: [ + { + name: 'cardId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'AgentCardIdentityImmutable', + inputs: [], + }, + { + type: 'error', + name: 'AgentCardInactive', + inputs: [], + }, + { + type: 'error', + name: 'AgentCardVersionMismatch', + inputs: [ + { + name: 'provided', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'current', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'AgentMismatch', + inputs: [], + }, + { + type: 'error', + name: 'AssetCountMismatch', + inputs: [ + { + name: 'expected', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'actual', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'AssetMismatch', + inputs: [ + { + name: 'index', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'CallerIsNotProvider', + inputs: [], + }, + { + type: 'error', + name: 'ChainMismatch', + inputs: [ + { + name: 'i', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'ChainNotSupported', + inputs: [ + { + name: 'chainHash', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'EnvelopeVersionMismatch', + inputs: [ + { + name: 'actual', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'ExecuteByOutOfRange', + inputs: [], + }, + { + type: 'error', + name: 'ExecutorMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'ExpectedCEAMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'ExpiryMismatch', + inputs: [], + }, + { + type: 'error', + name: 'ExpiryOutOfRange', + inputs: [], + }, + { + type: 'error', + name: 'FillOutOfBounds', + inputs: [ + { + name: 'read', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'fill', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'GasCapMismatch', + inputs: [], + }, + { + type: 'error', + name: 'HookNotWhitelisted', + inputs: [], + }, + { + type: 'error', + name: 'IntentExecMismatch', + inputs: [ + { + name: 'actualCalldataHash', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'IntentSessionMismatch', + inputs: [ + { + name: 'actual', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + }, + { + type: 'error', + name: 'IntentWalletMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'provided', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'InvalidAgentCard', + inputs: [ + { + name: 'reason', + type: 'string', + internalType: 'string', + }, + ], + }, + { + type: 'error', + name: 'JobMismatch', + inputs: [], + }, + { + type: 'error', + name: 'ParamCountMismatch', + inputs: [ + { + name: 'expected', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'actual', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'ParamOutOfRange', + inputs: [ + { + name: 'index', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'value', + type: 'int256', + internalType: 'int256', + }, + ], + }, + { + type: 'error', + name: 'PinMismatch', + inputs: [ + { + name: 'call', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pin', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'PolicyShape', + inputs: [ + { + name: 'i', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'PrincipalCapMismatch', + inputs: [], + }, + { + type: 'error', + name: 'PrincipalOutOfRange', + inputs: [], + }, + { + type: 'error', + name: 'ProviderIsEvaluator', + inputs: [], + }, + { + type: 'error', + name: 'TargetOverflow', + inputs: [ + { + name: 'check', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'UnexpectedJobCount', + inputs: [ + { + name: 'before', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'afterCount', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'WalletHasActiveJob', + inputs: [ + { + name: 'agw', + type: 'address', + internalType: 'address', + }, + { + name: 'jobId', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'WalletOwnerMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'ZeroAddress', + inputs: [], + }, +] as const; diff --git a/packages/core/src/lib/market/contracts/UniversalMarketplaceRules.ts b/packages/core/src/lib/market/contracts/UniversalMarketplaceRules.ts new file mode 100644 index 000000000..e8a669bd4 --- /dev/null +++ b/packages/core/src/lib/market/contracts/UniversalMarketplaceRules.ts @@ -0,0 +1,319 @@ +// Generated from sdk-iteration-v1@443b4e2; Donut marketplace v2. +export const MARKET_RULES_ABI = [ + { + type: 'function', + name: 'checkRulesMatchCard', + inputs: [ + { + name: 'cardRules', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'session', + type: 'tuple', + internalType: 'struct Session', + components: [ + { + name: 'sessionValidator', + type: 'address', + internalType: 'address', + }, + { + name: 'sessionValidatorInitData', + type: 'bytes', + internalType: 'bytes', + }, + { + name: 'salt', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'userOpPolicies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + { + name: 'erc7739Policies', + type: 'tuple', + internalType: 'struct ERC7739Data', + components: [ + { + name: 'allowedERC7739Content', + type: 'tuple[]', + internalType: 'struct ERC7739Context[]', + components: [ + { + name: 'appDomainSeparator', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'contentNames', + type: 'string[]', + internalType: 'string[]', + }, + ], + }, + { + name: 'erc1271Policies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + ], + }, + { + name: 'actions', + type: 'tuple[]', + internalType: 'struct ActionData[]', + components: [ + { + name: 'actionTargetSelector', + type: 'bytes4', + internalType: 'bytes4', + }, + { + name: 'actionTarget', + type: 'address', + internalType: 'address', + }, + { + name: 'actionPolicies', + type: 'tuple[]', + internalType: 'struct PolicyData[]', + components: [ + { + name: 'policy', + type: 'address', + internalType: 'address', + }, + { + name: 'initData', + type: 'bytes', + internalType: 'bytes', + }, + ], + }, + ], + }, + { + name: 'permitERC4337Paymaster', + type: 'bool', + internalType: 'bool', + }, + ], + }, + { + name: 'ctx', + type: 'tuple', + internalType: 'struct SessionContext', + components: [ + { + name: 'agent', + type: 'address', + internalType: 'address', + }, + { + name: 'chainHash', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'principal', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'expiredAt', + type: 'uint48', + internalType: 'uint48', + }, + { + name: 'expectedCEA', + type: 'address', + internalType: 'address', + }, + ], + }, + ], + outputs: [], + stateMutability: 'pure', + }, + { + type: 'function', + name: 'validateAgentCardRules', + inputs: [ + { + name: 'rules', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [ + { + name: 'tokens', + type: 'address[]', + internalType: 'address[]', + }, + ], + stateMutability: 'pure', + }, + { + type: 'error', + name: 'ActionCount', + inputs: [], + }, + { + type: 'error', + name: 'ActionsMismatch', + inputs: [], + }, + { + type: 'error', + name: 'AgentMismatch', + inputs: [], + }, + { + type: 'error', + name: 'AssetCountMismatch', + inputs: [ + { + name: 'expected', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'actual', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'AssetMismatch', + inputs: [ + { + name: 'index', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'ChainMismatch', + inputs: [ + { + name: 'i', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'EnvelopeVersionMismatch', + inputs: [ + { + name: 'actual', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'ExpectedCEAMismatch', + inputs: [ + { + name: 'expected', + type: 'address', + internalType: 'address', + }, + { + name: 'actual', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'ExpiryMismatch', + inputs: [], + }, + { + type: 'error', + name: 'GasCapMismatch', + inputs: [], + }, + { + type: 'error', + name: 'InvalidAgentCard', + inputs: [ + { + name: 'reason', + type: 'string', + internalType: 'string', + }, + ], + }, + { + type: 'error', + name: 'PinMismatch', + inputs: [ + { + name: 'call', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'pin', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'PolicyShape', + inputs: [ + { + name: 'i', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'PrincipalCapMismatch', + inputs: [], + }, +] as const; diff --git a/packages/core/src/lib/market/data.ts b/packages/core/src/lib/market/data.ts new file mode 100644 index 000000000..f39391454 --- /dev/null +++ b/packages/core/src/lib/market/data.ts @@ -0,0 +1,68 @@ +import { + decodeFunctionData, + encodeFunctionData, + getAbiItem, + toFunctionSelector, + type Abi, + type Hex, +} from 'viem'; + +/** Copy public data before awaiting RPCs or a wallet; BigInts and array order are retained. */ +export function copyData(value: T): T { + if (Array.isArray(value)) return value.map(copyData) as T; + if (value && typeof value === 'object') + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, copyData(v)]) + ) as T; + return value; +} +export function fingerprint(value: unknown): string { + const sorted = (v: unknown): unknown => + Array.isArray(v) + ? v.map(sorted) + : v && typeof v === 'object' + ? Object.fromEntries( + Object.entries(v) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, x]) => [k, sorted(x)]) + ) + : typeof v === 'bigint' + ? `${v}n` + : v; + return JSON.stringify(sorted(value)); +} + +/** Locate canonical ABI calls inside ordinary, UEA, AGW or multisig transaction inputs. + * Candidates are data, not proof of execution: callers must bind them to authenticated receipt events. + */ +export function embeddedCalls( + input: Hex, + abi: Abi, + name: string +): readonly unknown[][] { + const item = getAbiItem({ abi, name }); + if (!item || item.type !== 'function') return []; + const selector = toFunctionSelector(item).slice(2).toLowerCase(); + const hex = input.toLowerCase(); + const calls: unknown[][] = []; + let offset = 2; + while ((offset = hex.indexOf(selector, offset)) !== -1) { + const at = offset; + offset += 2; + if (at % 2 !== 0) continue; + try { + const data = ('0x' + input.slice(at)) as Hex; + const decoded = decodeFunctionData({ abi: [item], data }); + const encoded = encodeFunctionData({ + abi: [item], + functionName: name, + args: decoded.args, + }); + if (data.slice(0, encoded.length).toLowerCase() === encoded.toLowerCase()) + calls.push([...(decoded.args ?? [])]); + } catch { + /* A selector-shaped value inside unrelated calldata is not a candidate. */ + } + } + return calls; +} diff --git a/packages/core/src/lib/market/deployment.ts b/packages/core/src/lib/market/deployment.ts new file mode 100644 index 000000000..3c96131fc --- /dev/null +++ b/packages/core/src/lib/market/deployment.ts @@ -0,0 +1,26 @@ +import { PUSH_NETWORK } from '../constants/enums'; +import { unavailable } from './errors'; +export const DONUT_MARKETPLACE = Object.freeze({ + sourceCommit: '443b4e2dd9ca5dd9ffc8efebdfcecd619dd66c47', + version: '2.0.0', + startBlock: BigInt(24443653), + marketplace: '0x9071ebE01B75123A91e88eB6ec4BF163F3fE9BF4', + kernel: '0x977cf1AC973E9eC4332791d531507575006157Ed', + hook: '0xDe7A94e510b6C571DE7880d441349689F73A32b3', + rules: '0x5b98A8E0df5b918DF76cD2BE84bA7995B39dfd44', + evaluator: '0x1afC81396f1bb36F91f74506284952b81e4bd47c', + paymentToken: '0x7A58048036206bB898008b5bBDA85697DB1e5d66', +} as const); +export type MarketDeployment = Omit< + typeof DONUT_MARKETPLACE, + 'marketplace' | 'kernel' | 'hook' | 'rules' | 'evaluator' | 'paymentToken' +> & + Record< + 'marketplace' | 'kernel' | 'hook' | 'rules' | 'evaluator' | 'paymentToken', + `0x${string}` + >; +export function marketplaceDeployment(network: PUSH_NETWORK): MarketDeployment { + if (network !== PUSH_NETWORK.TESTNET_DONUT) + unavailable('Marketplace on this Push network'); + return DONUT_MARKETPLACE; +} diff --git a/packages/core/src/lib/market/deposit.ts b/packages/core/src/lib/market/deposit.ts new file mode 100644 index 000000000..17c7fda50 --- /dev/null +++ b/packages/core/src/lib/market/deposit.ts @@ -0,0 +1,455 @@ +import { + decodeEventLog, + encodeFunctionData, + erc20Abi, + getAbiItem, + getAddress, + zeroAddress, + type Address, + type Hex, +} from 'viem'; +import { MARKETPLACE_ABI as ABI } from './contracts/UniversalMarketplace'; +import { JOB_KERNEL_ABI } from './contracts/AgenticCommerce'; +import { KERNEL_READ_ABI } from './kernel-wire'; +import { decodeJobSpec } from '../job-spec/codec'; +import { specOrigin } from '../job-spec/helpers'; +import { currentGeneration } from '../agentic/deployments'; +import { configId } from '../agentic/codec/ids'; +import { scanLogs } from '../agentic/reads/snapshot'; +import { confirmedLogs } from '../agentic/management/common'; +import { decodeCardRulesWire } from './rules'; +import { historicalCard } from './history'; +import { MOVEABLE_TOKENS, type MoveableToken } from '../constants/tokens'; +import { getPRC20Address } from '../universal/prc20-address'; +import { MarketError } from './errors'; +import { + deployment, + progress, + snapshot, + write, + type MarketSnapshot, + type StackRuntime, +} from './runtime'; +import type { + AgentCard, + MarketDepositOptions, + MarketDepositResult, + MarketDepositStep, +} from './types'; + +type LoadCard = (cardId: bigint, at: MarketSnapshot) => Promise; +export interface DepositCheckpoint { + txs: MarketDepositStep[]; + pending?: MarketDepositResult['pending']; +} +interface Asset { + step: MarketDepositStep['step']; + token?: Address; + required: bigint; + originToken?: MoveableToken; +} +const uint256 = (n: bigint): void => { + if (typeof n !== 'bigint' || n < BigInt(0) || n >= BigInt(1) << BigInt(256)) + throw new MarketError( + 'INVALID_AMOUNT', + 'Deposit amounts must fit uint256.' + ); +}; +function submittedHash( + cause: unknown, + seen = new Set() +): string | undefined { + if (!cause || typeof cause !== 'object' || seen.has(cause) || seen.size > 16) + return; + seen.add(cause); + const value = cause as Record; + for (const key of ['gatewayTxHash', 'pendingTransactionHash', 'txHash']) + if (typeof value[key] === 'string' && value[key]) + return value[key] as string; + for (const key of ['cause', 'error', 'details']) { + const found = submittedHash(value[key], seen); + if (found) return found; + } + return; +} +const sameAsset = (a?: Address, b?: Address): boolean => + a === undefined || b === undefined + ? a === b + : getAddress(a) === getAddress(b); + +/** Recover amounts from the block that opened the job, never today's mutable card. */ +export async function openedDeposit( + rt: StackRuntime, + jobId: bigint, + loadCard: LoadCard +): Promise<{ result: MarketDepositResult; assets: Asset[]; rulesId: Hex }> { + if (typeof jobId !== 'bigint' || jobId < BigInt(1)) + throw new MarketError( + 'INVALID_JOB_ID', + 'Job IDs are positive bigint values.' + ); + const s = await snapshot(rt); + const d = deployment(rt); + const [cardId, version, wallet, rulesId, kernel] = await Promise.all([ + s.read(d.marketplace, ABI, 'cardOfJob', [jobId]), + s.read(d.marketplace, ABI, 'cardVersionOfJob', [jobId]), + s.read
(d.marketplace, ABI, 'agwOfJob', [jobId]), + s.read(d.marketplace, ABI, 'rulesOfJob', [jobId]), + s.read<{ + client: Address; + status: number; + expiredAt: number; + provider: Address; + description: Hex; + }>(d.kernel, KERNEL_READ_ABI, 'getJob', [jobId]), + ]); + if (!cardId || getAddress(wallet) === zeroAddress) + throw new MarketError( + 'NOT_MARKETPLACE_JOB', + 'This job was not opened through this marketplace.' + ); + if (kernel.status !== 0 || kernel.expiredAt <= s.timestamp) + throw new MarketError( + 'WRONG_STATUS', + 'Deposit only while the job is Open and unexpired.' + ); + if (getAddress(kernel.client) !== getAddress(wallet)) + throw new MarketError( + 'INCONSISTENT_READ', + 'The marketplace wallet differs from the kernel client.' + ); + + let block = rt.jobBlocks?.get(jobId); + if (block === undefined) { + const created = await scanLogs( + rt.agw.reader, + { + address: d.kernel, + event: getAbiItem({ abi: JOB_KERNEL_ABI, name: 'JobCreated' }), + args: { jobId }, + }, + d.startBlock, + s.blockNumber + ); + if (created.length !== 1 || created[0].blockNumber === null) + throw new MarketError( + 'HISTORY_UNAVAILABLE', + 'Could not recover one creation block for this job.' + ); + block = created[0].blockNumber; + } + const logs = await scanLogs( + rt.agw.reader, + { + address: d.marketplace, + event: getAbiItem({ abi: ABI, name: 'JobOpened' }), + args: { cardId, agw: wallet }, + }, + block, + block + ); + const events = logs + .map( + (l) => + decodeEventLog({ + abi: ABI, + eventName: 'JobOpened', + data: l.data, + topics: l.topics, + strict: true, + }).args + ) + .filter((e) => e.jobId === jobId); + if (events.length !== 1) + throw new MarketError( + 'HISTORY_UNAVAILABLE', + 'Could not recover one marketplace opening for this job.' + ); + const opened = events[0]; + const card = await historicalCard(rt, cardId, version, block, loadCard); + if (opened.cardVersion !== version) + throw new MarketError( + 'HISTORY_UNAVAILABLE', + 'The opening event does not match the recorded card version.' + ); + const spec = decodeJobSpec(kernel.description, { format: 'spec' }); + if ( + !('origin' in spec) || + spec.origin !== + specOrigin(d.marketplace, cardId, version, opened.principal) || + opened.rulesId !== rulesId || + getAddress(opened.agw) !== getAddress(wallet) || + getAddress(card.provider) !== getAddress(kernel.provider) + ) + throw new MarketError( + 'INCONSISTENT_READ', + 'The historical opening does not match the stored job.' + ); + const token = await s.read
(d.kernel, JOB_KERNEL_ABI, 'paymentToken'); + if (getAddress(card.paymentToken) !== getAddress(token)) + throw new MarketError( + 'TOKEN_MISMATCH', + 'The opened card and kernel use different payment tokens.' + ); + rt.jobBlocks?.set(jobId, block); + const rules = decodeCardRulesWire(card.rulesBytes); + return { + result: { + jobId, + wallet: getAddress(wallet), + cardId, + cardVersion: version, + txs: [], + }, + assets: [ + { + step: 'PRINCIPAL', + token: getAddress(rules.assets[rules.principalAsset].token), + required: opened.principal, + }, + { + step: 'PROVIDER_FEE', + token: getAddress(token), + required: card.providerFee, + }, + { step: 'PC', required: card.rules.maxGasPerCall }, + ], + rulesId, + }; +} + +/** Separate, confirmed top-ups from the signer's Push identity. Does not fund kernel escrow. */ +export async function depositJob( + rt: StackRuntime, + jobId: bigint, + options: MarketDepositOptions, + loadCard: LoadCard, + checkpoints: Map +): Promise { + if (rt.agw.isReadOnly) + throw new MarketError('READ_ONLY', 'Connect a signing wallet to deposit.'); + if ('skipApprovals' in options) + throw new MarketError( + 'INVALID_DEPOSIT_OPTIONS', + 'V2 cards grant pinned approvals to the agent; deposit has no owner approval step.' + ); + if ( + options.funding !== undefined && + !['push', 'origin'].includes(options.funding) + ) + throw new MarketError( + 'INVALID_DEPOSIT_OPTIONS', + 'Choose push or origin deposit funding.' + ); + if (options.funding === 'origin' && rt.agw.signerIsPushNative()) + throw new MarketError( + 'INVALID_DEPOSIT_OPTIONS', + 'Use push funding for a Push-native signer.' + ); + if (options.pcAmount !== undefined) uint256(options.pcAmount); + if (options.providerFee !== undefined) uint256(options.providerFee); + const { result, assets, rulesId } = await openedDeposit(rt, jobId, loadCard); + const previous = checkpoints.get(jobId); + result.txs = [...(options.resume ?? previous?.txs ?? [])]; + if ( + previous?.pending && + !result.txs.some((t) => sameAsset(t.token, previous.pending?.token)) + ) + throw new MarketError( + 'DEPOSIT_INCOMPLETE', + 'A broadcast deposit still needs tracking. Rehydrate its response and pass it as a resume step before sending more.', + { ...result, pending: previous.pending } + ); + const fee = assets[1]; + fee.required = options.providerFee ?? fee.required; + assets[2].required = options.pcAmount ?? assets[2].required; + if (assets[0].token === fee.token) { + assets[0].step = 'PRINCIPAL_AND_FEE'; + assets[0].required += fee.required; + uint256(assets[0].required); + assets.splice(1, 1); + } + const balance = async (asset: Asset, account: Address): Promise => + asset.token + ? (await snapshot(rt)).read(asset.token, erc20Abi, 'balanceOf', [ + account, + ]) + : rt.agw.reader.getBalance({ address: account }); + const available = async (): Promise => { + const s = await snapshot(rt); + const d = deployment(rt); + const job = await s.read<{ status: number; expiredAt: number }>( + d.kernel, + KERNEL_READ_ABI, + 'getJob', + [jobId] + ); + if (job.status !== 0 || job.expiredAt <= s.timestamp) + throw new MarketError( + 'WRONG_STATUS', + 'The job is no longer Open and unexpired.' + ); + const gen = currentGeneration(rt.agw.network); + if ( + !(await s.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'isPermissionEnabled', + [rulesId, result.wallet] + )) + ) + throw new MarketError( + 'RULE_NOT_FOUND', + 'The job permission is no longer enabled.' + ); + const actions = await s.read( + gen.addresses.sessionEngine, + gen.contracts.abis.engine, + 'getEnabledActions', + [result.wallet, rulesId] + ); + if (actions.length !== 1) + throw new MarketError( + 'INCONSISTENT_READ', + 'The job permission has an unexpected action count.' + ); + const config = await s.read<{ + initialized: boolean; + assets: readonly { spent: bigint }[]; + }>(gen.addresses.rulesPolicy, gen.contracts.abis.policy, 'getConfig', [ + configId(result.wallet, rulesId, actions[0]), + result.wallet, + ]); + if (!config.initialized || config.assets.some((a) => a.spent > BigInt(0))) + throw new MarketError( + 'DEPOSIT_ALREADY_USED', + 'The job permission has already moved assets. Review funding manually before adding more.' + ); + }; + const checkpoint: DepositCheckpoint = { + txs: result.txs, + pending: previous?.pending, + }; + checkpoints.set(jobId, checkpoint); + let submitting: Omit | undefined; + try { + // Uncertain prior receipts must settle before balances can authorize another transfer. + for (const step of result.txs) await confirmedLogs(rt.agw, step.tx); + checkpoint.pending = undefined; + await available(); + for (const asset of assets) { + const held = await balance(asset, result.wallet); + if (held >= asset.required) continue; + if (result.txs.some((t) => sameAsset(t.token, asset.token))) + throw new MarketError( + 'DEPOSIT_BALANCE_CHANGED', + 'A confirmed deposit no longer covers its target balance. Review the wallet before sending more.' + ); + const needed = asset.required - held; + if (asset.token && options.funding === 'origin') { + const chain = rt.agw.signerOrigin().chain; + asset.originToken = (MOVEABLE_TOKENS[chain] ?? []).find((candidate) => { + try { + return ( + getAddress( + getPRC20Address( + { chain, address: candidate.address }, + { network: rt.agw.network } + ).address + ) === asset.token + ); + } catch { + return false; + } + }); + if (!asset.originToken) + throw new MarketError( + 'TOKEN_ROUTE_UNAVAILABLE', + 'This deposit token has no registered route from the connected origin chain. Prefund it on Push or connect its origin.', + { chain, token: asset.token } + ); + continue; + } + const source = getAddress(rt.agw.signerPushAccount()); + if (source === result.wallet || (await balance(asset, source)) < needed) + throw new MarketError( + 'INSUFFICIENT_FUNDS', + 'The connected signer’s Push account cannot cover this deposit.', + { token: asset.token, required: needed, account: source } + ); + } + for (const asset of assets) { + await available(); + const held = await balance(asset, result.wallet); + if (held >= asset.required) continue; + const amount = asset.required - held; + if (result.txs.some((t) => sameAsset(t.token, asset.token))) + throw new MarketError( + 'DEPOSIT_BALANCE_CHANGED', + 'A confirmed deposit balance changed before the next step. Review the wallet before sending more.' + ); + submitting = { step: asset.step, token: asset.token, amount }; + const tx = asset.originToken + ? await rt.agw.execute( + { to: result.wallet, funds: { token: asset.originToken, amount } }, + { progressHook: options.progressHook } + ) + : await write( + rt, + asset.token + ? { + target: asset.token, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [result.wallet, amount], + }), + } + : { target: result.wallet, value: amount, data: '0x' }, + options.progressHook, + 'MARKET', + true + ); + result.txs.push({ step: asset.step, token: asset.token, amount, tx }); + submitting = undefined; + progress( + rt, + options.progressHook, + 'MARKET-TX-111', + 'Deposit step submitted', + { step: asset.step, token: asset.token, amount, txHash: tx.hash } + ); + await confirmedLogs(rt.agw, tx); + if ((await balance(asset, result.wallet)) < asset.required) + throw new MarketError( + 'DEPOSIT_BALANCE_CHANGED', + 'The transfer did not establish the required wallet balance. Review the receipt before retrying.' + ); + } + checkpoints.delete(jobId); + return result; + } catch (cause) { + const txHash = submitting && submittedHash(cause); + if (submitting && txHash) { + checkpoint.pending = { + ...submitting, + txHash, + chain: + options.funding === 'origin' && submitting.token + ? rt.agw.signerOrigin().chain + : rt.agw.pushChainNamespace, + }; + } + throw new MarketError( + 'DEPOSIT_INCOMPLETE', + 'The deposit did not finish. Resume with the recorded transactions after resolving the cause.', + { + ...result, + ...(checkpoint.pending ? { pending: checkpoint.pending } : {}), + causeCode: cause instanceof MarketError ? cause.code : undefined, + }, + cause + ); + } +} diff --git a/packages/core/src/lib/market/errors.ts b/packages/core/src/lib/market/errors.ts new file mode 100644 index 000000000..3e329d334 --- /dev/null +++ b/packages/core/src/lib/market/errors.ts @@ -0,0 +1,103 @@ +import { decodeErrorResult, type Hex } from 'viem'; +import { AgenticError } from '../agentic/errors'; +import { MARKET_ERRORS_ABI } from './contracts/UniversalMarketplaceErrors'; +import { JOB_KERNEL_ABI } from './contracts/AgenticCommerce'; +import { RULES_HOOK_ABI } from './contracts/RulesBindingHook'; + +export class MarketError extends AgenticError { + constructor( + code: string, + message: string, + details?: Record, + cause?: unknown + ) { + super(code, message, { userMessage: message, details, cause }); + this.name = 'MarketError'; + } +} +export class JobError extends MarketError { + constructor( + code: string, + message: string, + details?: Record, + cause?: unknown + ) { + super(code, message, details, cause); + this.name = 'JobError'; + } +} +export class JobSpecError extends MarketError { + constructor( + code: string, + message: string, + details?: Record, + cause?: unknown + ) { + super(code, message, details, cause); + this.name = 'JobSpecError'; + } +} +export function unavailable(feature: string): never { + throw new MarketError( + 'CAPABILITY_UNAVAILABLE', + `${feature} is not available in the deployed marketplace v2 stack.`, + { feature } + ); +} +/** Inspect provider error chains without parsing human-readable revert messages. */ +export function stackError(error: unknown): unknown { + if (error instanceof AgenticError) return error; + const seen = new Set(); + const submitted = error as { + txHash?: string; + pendingTransactionHash?: string; + gatewayTxHash?: string; + } | null; + const recovery = { + ...(submitted?.txHash ? { txHash: submitted.txHash } : {}), + ...(submitted?.pendingTransactionHash + ? { pendingTransactionHash: submitted.pendingTransactionHash } + : {}), + ...(submitted?.gatewayTxHash + ? { gatewayTxHash: submitted.gatewayTxHash } + : {}), + }; + function visit(value: unknown, depth: number): MarketError | undefined { + if (!value || typeof value !== 'object' || seen.has(value) || depth > 8) + return; + seen.add(value); + const o = value as Record; + if (o['code'] === 4001 || o['name'] === 'UserRejectedRequestError') + return new MarketError( + 'REQUEST_REJECTED', + 'The wallet request was cancelled.', + undefined, + error + ); + if (typeof o['data'] === 'string' && /^0x[0-9a-f]+$/i.test(o['data'])) { + try { + const decoded = decodeErrorResult({ + abi: [...MARKET_ERRORS_ABI, ...JOB_KERNEL_ABI, ...RULES_HOOK_ABI], + data: o['data'] as Hex, + }); + const code = decoded.errorName + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toUpperCase(); + return new MarketError( + code, + `The contract rejected the request: ${decoded.errorName}.`, + { decodedError: decoded, ...recovery }, + error + ); + } catch { + /* This value is not a known stack revert; retain the provider cause. */ + } + } + for (const key of ['cause', 'error', 'data']) { + const found = visit(o[key], depth + 1); + if (found) return found; + } + return; + } + return visit(error, 0) ?? error; +} diff --git a/packages/core/src/lib/market/history.ts b/packages/core/src/lib/market/history.ts new file mode 100644 index 000000000..2acc17617 --- /dev/null +++ b/packages/core/src/lib/market/history.ts @@ -0,0 +1,123 @@ +import { + decodeEventLog, + getAbiItem, + getAddress, + keccak256, + type Address, + type Hex, +} from 'viem'; +import { scanLogs } from '../agentic/reads/snapshot'; +import { MARKETPLACE_ABI as ABI } from './contracts/UniversalMarketplace'; +import { decodeAgentCardRules } from './rules'; +import { embeddedCalls } from './data'; +import { MarketError } from './errors'; +import { + deployment, + snapshot, + type MarketSnapshot, + type StackRuntime, +} from './runtime'; +import type { AgentCard } from './types'; + +/** Exact deposit-critical fields from an emitted version, including same-block updates. + * Event content hashes, immutable provider/chain and the kernel token bind the recovered input. + */ +export async function historicalCard( + rt: StackRuntime, + cardId: bigint, + version: bigint, + openedAt: bigint, + load: (cardId: bigint, at: MarketSnapshot) => Promise +): Promise< + Pick< + AgentCard, + | 'version' + | 'provider' + | 'paymentToken' + | 'providerFee' + | 'rulesBytes' + | 'rules' + > +> { + let originalCause: unknown; + try { + const card = await load(cardId, await snapshot(rt, openedAt)); + if (card.version === version) return card; + } catch (cause) { + originalCause = cause; + } + const d = deployment(rt); + const fail = (cause?: unknown): never => { + throw new MarketError( + 'HISTORY_UNAVAILABLE', + 'Could not authenticate the exact opened card version from historical state or transaction input.', + { cardId, cardVersion: version, openedAt }, + cause ?? originalCause + ); + }; + if (!rt.agw.reader.getTransaction) return fail(); + const name = + version === BigInt(1) ? 'AgentCardRegistered' : 'AgentCardUpdated'; + const records = await scanLogs( + rt.agw.reader, + { + address: d.marketplace, + event: getAbiItem({ abi: ABI, name }), + args: { cardId }, + }, + d.startBlock, + openedAt + ); + const matches = records + .map((log) => ({ + log, + event: decodeEventLog({ + abi: ABI, + eventName: name, + data: log.data, + topics: log.topics, + strict: true, + }).args, + })) + .filter(({ event }) => !('version' in event) || event.version === version); + if (matches.length !== 1) return fail(); + const { log, event } = matches[0]; + if (!log.transactionHash || log.blockNumber === null) return fail(); + const tx = await rt.agw.reader.getTransaction({ hash: log.transactionHash }); + if (tx.blockNumber !== log.blockNumber) return fail(); + // The chain and provider cannot change on update. Do not decode today's potentially malformed template. + const current = await ( + await snapshot(rt) + ).read<{ + card: { provider: Address; chainNamespace: string; paymentToken: Address }; + }>(d.marketplace, ABI, 'getAgentCard', [cardId]); + const fn = version === BigInt(1) ? 'registerAgentCard' : 'updateAgentCard'; + const candidates = embeddedCalls(tx.input, ABI, fn).filter((args) => { + const i = version === BigInt(1) ? 0 : 1; + const c = args[i] as { + chainNamespace: string; + paymentToken: Address; + providerFee: bigint; + }; + return ( + (i === 0 || args[0] === cardId) && + c.chainNamespace === current.card.chainNamespace && + getAddress(c.paymentToken) === getAddress(current.card.paymentToken) && + c.providerFee === event.providerFee && + keccak256(args[i + 1] as Hex) === event.rulesHash && + keccak256(args[i + 2] as Hex) === event.jobSpecHash + ); + }); + // Identical byte sequences may appear more than once in a wrapper. Hash-bound content is identical. + if (!candidates.length) return fail(); + const i = version === BigInt(1) ? 0 : 1; + const rulesBytes = candidates[0][i + 1] as Hex; + return { + version, + provider: getAddress(current.card.provider), + paymentToken: getAddress(current.card.paymentToken), + providerFee: event.providerFee, + rulesBytes, + rules: decodeAgentCardRules(rulesBytes), + }; +} diff --git a/packages/core/src/lib/market/index.ts b/packages/core/src/lib/market/index.ts new file mode 100644 index 000000000..aeba7a97a --- /dev/null +++ b/packages/core/src/lib/market/index.ts @@ -0,0 +1,8 @@ +export { MarketError, JobError, JobSpecError } from './errors'; +export type * from './types'; +export type { AgentCardRules, MarketAssetToken, MarketPin } from './rules'; +export { + validateAgentCardRules, + encodeAgentCardRules, + decodeAgentCardRules, +} from './rules'; diff --git a/packages/core/src/lib/market/kernel-wire.ts b/packages/core/src/lib/market/kernel-wire.ts new file mode 100644 index 000000000..65494e0c1 --- /dev/null +++ b/packages/core/src/lib/market/kernel-wire.ts @@ -0,0 +1,73 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + getAbiItem, + stringToHex, + toFunctionSelector, + type Abi, + type AbiParameter, + type Address, + type Hex, +} from 'viem'; +import { JOB_KERNEL_ABI } from './contracts/AgenticCommerce'; +import { JobError } from './errors'; +const create = getAbiItem({ abi: JOB_KERNEL_ABI, name: 'createJob' }); +export const CREATE_JOB_SELECTOR = toFunctionSelector(create); +const inputs = create.inputs.map((p) => + p.name === 'description' ? { ...p, type: 'bytes' } : p +) as readonly AbiParameter[]; +/** Solidity string(description) carries arbitrary ABI bytes, not UTF-8 text. */ +export const KERNEL_READ_ABI: Abi = JOB_KERNEL_ABI.map((item) => + item.type === 'function' && item.name === 'getJob' + ? { + ...item, + outputs: item.outputs.map((p) => ({ + ...p, + components: p.components.map((c) => + c.name === 'description' ? { ...c, type: 'bytes' as const } : c + ), + })), + } + : item +); +export function encodeCreateJob( + provider: Address, + evaluator: Address, + expiredAt: number, + payload: { jobSpec?: Hex; description?: string }, + hook: Address +): Hex { + if (payload.jobSpec && payload.description !== undefined) + throw new JobError('INVALID_JOB', 'Use jobSpec or description, not both.'); + const bytes = payload.jobSpec ?? stringToHex(payload.description ?? ''); + return (CREATE_JOB_SELECTOR + + encodeAbiParameters(inputs, [ + provider, + evaluator, + BigInt(expiredAt), + bytes, + hook, + ]).slice(2)) as Hex; +} +export function decodeCreateJob(data: Hex): { + provider: Address; + evaluator: Address; + expiredAt: bigint; + description: Hex; + hook: Address; +} { + if (data.slice(0, 10).toLowerCase() !== CREATE_JOB_SELECTOR) + throw new JobError( + 'INVALID_JOB', + 'Expected the deployed kernel createJob selector.' + ); + const [provider, evaluator, expiredAt, description, hook] = + decodeAbiParameters(inputs, ('0x' + data.slice(10)) as Hex) as [ + Address, + Address, + bigint, + Hex, + Address + ]; + return { provider, evaluator, expiredAt, description, hook }; +} diff --git a/packages/core/src/lib/market/market.ts b/packages/core/src/lib/market/market.ts new file mode 100644 index 000000000..71d619197 --- /dev/null +++ b/packages/core/src/lib/market/market.ts @@ -0,0 +1,979 @@ +import { + encodeFunctionData, + decodeAbiParameters, + getAddress, + domainSeparator, + keccak256, + stringToHex, + toHex, + zeroAddress, + type Address, + type Hex, +} from 'viem'; +import { MARKETPLACE_ABI as ABI } from './contracts/UniversalMarketplace'; +import { JOB_KERNEL_ABI } from './contracts/AgenticCommerce'; +import { MARKET_RULES_ABI } from './contracts/UniversalMarketplaceRules'; +import { currentGeneration } from '../agentic/deployments'; +import { rulesId as permissionId } from '../agentic/codec/ids'; +import { + buildSession, + encodeEnvelope, + encodeSession, +} from '../agentic/codec/session'; +import { encodeUniversalTerms } from '../agentic/codec/universal-terms'; +import { assertLabel } from '../agentic/management/label'; +import { decodeJobSpec, encodeJobSpec } from '../job-spec/codec'; +import { + resolveJobSpec, + describeJobSpec, + specOrigin, +} from '../job-spec/helpers'; +import type { JobSpecTemplate } from '../job-spec/types'; +import { + decodeAgentCardRules, + decodeCardRulesWire, + encodeAgentCardRules, + fillCardRules, +} from './rules'; +import { decodeCreateJob, KERNEL_READ_ABI } from './kernel-wire'; +import { copyData, embeddedCalls, fingerprint as fp } from './data'; +import { depositJob, type DepositCheckpoint } from './deposit'; +import { + deployment, + eventArgs, + progress, + snapshot, + write, + type MarketSnapshot, + type StackRuntime, +} from './runtime'; +import { MarketError, stackError, unavailable } from './errors'; +import type { + AgentCard, + AgentCardInput, + AgentCardSummary, + JobPlan, + MarketJobInputs, + MarketNamespace, + MarketPreviewOptions, + MarketWriteOptions, + MarketDepositOptions, + MarketOpenOptions, + OwnerIntent, +} from './types'; +import type { CHAIN } from '../constants/enums'; + +interface WireCard extends Omit { + provider: Address; + chainNamespace: string; + active: boolean; +} +interface WireView { + card: WireCard; + rules: Hex; + jobSpecTemplate: Hex; + version: bigint; + verified: boolean; + disabled: boolean; +} +const ownerIntentFields = [ + ['owner', 'address'], + ['wallet', 'address'], + ['executor', 'address'], + ['index', 'uint96'], + ['sessionHash', 'bytes32'], + ['mode', 'bytes32'], + ['execCalldataHash', 'bytes32'], + ['nonceKey', 'uint192'], + ['nonceSeq', 'uint64'], + ['grantNonce', 'uint64'], + ['deadline', 'uint48'], + ['signerChainId', 'uint256'], +].map(([name, type]) => ({ name, type })); +function id(n: bigint): void { + if (typeof n !== 'bigint' || n < BigInt(1)) + throw new MarketError( + 'INVALID_CARD_ID', + 'Card IDs are positive bigint values.' + ); +} + +export class MarketNamespaceImpl implements MarketNamespace { + constructor(private readonly rt: StackRuntime) {} + private readonly deposits = new Set(); + private readonly depositCheckpoints = new Map(); + private async viewAt(cardId: bigint, s: MarketSnapshot): Promise { + id(cardId); + const d = deployment(this.rt); + const v = await s.read(d.marketplace, ABI, 'getAgentCard', [ + cardId, + ]); + if (getAddress(v.card.provider) === zeroAddress) + throw new MarketError( + 'CARD_NOT_FOUND', + 'This agent card does not exist.' + ); + return v; + } + private async summaryAt( + cardId: bigint, + s: MarketSnapshot, + view?: WireView + ): Promise { + const v = view ?? (await this.viewAt(cardId, s)); + const d = deployment(this.rt); + const { chainNamespace, ...card } = v.card; + return { + ...card, + cardId, + chain: chainNamespace, + version: v.version, + verified: v.verified, + disabled: v.disabled, + rating: await s.read(d.marketplace, ABI, 'getAgentRating', [ + v.card.provider, + ]), + }; + } + private async cardAt(cardId: bigint, s: MarketSnapshot): Promise { + const v = await this.viewAt(cardId, s); + return { + ...(await this.summaryAt(cardId, s, v)), + rules: decodeAgentCardRules(v.rules), + jobSpecTemplate: decodeJobSpec(v.jobSpecTemplate, { + format: 'template', + }) as JobSpecTemplate, + rulesBytes: v.rules, + jobSpecTemplateBytes: v.jobSpecTemplate, + }; + } + async card(cardId: bigint): Promise { + return this.cardAt(cardId, await snapshot(this.rt)); + } + async cards( + options: { provider?: Address; chain?: string; active?: boolean } = {} + ): Promise<{ cards: AgentCardSummary[] }> { + const s = await snapshot(this.rt); + const d = deployment(this.rt); + const count = options.provider + ? await s.read(d.marketplace, ABI, 'agentCardOf', [ + getAddress(options.provider), + ]) + : await s.read(d.marketplace, ABI, 'agentCardCount'); + const cards: AgentCardSummary[] = []; + // Bounded requests. An indexer can replace the walk without changing the public result. + for ( + let start = options.provider ? count : BigInt(1); + start > BigInt(0) && start <= count; + + ) { + const batch = options.provider + ? [start] + : Array.from( + { + length: Number( + count - start + BigInt(1) > BigInt(8) + ? BigInt(8) + : count - start + BigInt(1) + ), + }, + (_, i) => start + BigInt(i) + ); + const rows = await Promise.all(batch.map((n) => this.summaryAt(n, s))); + for (const card of rows) + if ( + (!options.chain || card.chain === options.chain) && + (options.active === undefined || card.active === options.active) + ) { + cards.push(card); + } + if (options.provider) break; + start += BigInt(batch.length); + } + return { cards }; + } + async job(jobId: bigint): Promise<{ + cardId: bigint; + cardVersion: bigint; + wallet: Address; + rulesId: Hex; + }> { + const s = await snapshot(this.rt); + const d = deployment(this.rt); + const [cardId, cardVersion, wallet, rulesId] = await Promise.all([ + s.read(d.marketplace, ABI, 'cardOfJob', [jobId]), + s.read(d.marketplace, ABI, 'cardVersionOfJob', [jobId]), + s.read
(d.marketplace, ABI, 'agwOfJob', [jobId]), + s.read(d.marketplace, ABI, 'rulesOfJob', [jobId]), + ]); + return { cardId, cardVersion, wallet, rulesId }; + } + async activeJob(wallet: Address): Promise<{ jobId: bigint }> { + const d = deployment(this.rt); + return { + jobId: await ( + await snapshot(this.rt) + ).read(d.marketplace, ABI, 'activeJobOf', [getAddress(wallet)]), + }; + } + + async preview( + cardId: bigint, + options: MarketPreviewOptions + ): Promise { + options = { ...options, params: options.params && [...options.params] }; + const rt = this.rt; + const d = deployment(rt); + const s = await snapshot(rt); + const card = await this.cardAt(cardId, s); + const gen = currentGeneration(rt.agw.network); + const now = s.timestamp; + if (card.disabled || !card.active) + throw new MarketError( + 'AGENT_CARD_INACTIVE', + 'This agent card is inactive or disabled.' + ); + if (card.jobSpecTemplate.allowedMutability !== 'NONE') + unavailable('Mutable marketplace criteria'); + const [ + factory, + kernel, + paused, + evaluator, + hook, + rulesHelper, + kernelPaused, + ] = await Promise.all([ + s.read
(d.marketplace, ABI, 'AGW_FACTORY'), + s.read
(d.marketplace, ABI, 'KERNEL'), + s.read(d.marketplace, ABI, 'paused'), + s.read
(d.marketplace, ABI, 'evaluator'), + s.read
(d.marketplace, ABI, 'hook'), + s.read
(d.marketplace, ABI, 'RULES'), + s.read(d.kernel, JOB_KERNEL_ABI, 'paused'), + ]); + if ( + getAddress(factory) !== getAddress(gen.addresses.factory) || + getAddress(kernel) !== getAddress(d.kernel) || + getAddress(hook) !== getAddress(d.hook) || + getAddress(rulesHelper) !== getAddress(d.rules) || + getAddress(evaluator) !== getAddress(d.evaluator) + ) + throw new MarketError( + 'CAPABILITY_UNAVAILABLE', + 'The marketplace deployment wiring has changed.' + ); + if (paused || kernelPaused) + throw new MarketError('PAUSED', 'The marketplace or kernel is paused.'); + if (getAddress(card.provider) === getAddress(evaluator)) + throw new MarketError( + 'PROVIDER_IS_EVALUATOR', + 'The provider cannot be the evaluator.' + ); + const job: MarketJobInputs = { + principal: options.principal, + expiredAt: options.expiredAt, + executeBy: options.executeBy, + params: options.params ?? [], + }; + if ( + typeof job.principal !== 'bigint' || + job.principal < card.principalMin || + job.principal > card.principalMax + ) + throw new MarketError( + 'PRINCIPAL_OUT_OF_RANGE', + 'The principal is outside this card’s range.' + ); + if ( + !Number.isSafeInteger(job.expiredAt) || + job.expiredAt < now + card.minDuration || + job.expiredAt > now + card.maxDuration || + job.expiredAt >= 2 ** 48 + ) + throw new MarketError( + 'EXPIRY_OUT_OF_RANGE', + 'Choose an expiry inside the card’s duration window.' + ); + if ( + !Number.isSafeInteger(job.executeBy) || + job.executeBy < now + card.minExecuteWindow || + job.executeBy + card.settleWindow > job.expiredAt + ) + throw new MarketError( + 'EXECUTE_BY_OUT_OF_RANGE', + 'Choose an execution deadline that leaves the settlement window.' + ); + const owner = getAddress(rt.agw.signerPushAccount()); + if (owner === getAddress(card.provider)) + throw new MarketError( + 'CLIENT_IS_PROVIDER', + 'The wallet owner and provider must be different.' + ); + if ( + options.walletIndex !== undefined && + (!Number.isSafeInteger(options.walletIndex) || options.walletIndex < 0) + ) + throw new MarketError( + 'INVALID_WALLET_INDEX', + 'The wallet index must be a nonnegative safe integer.' + ); + const index = + options.walletIndex === undefined + ? await s.read( + factory, + gen.contracts.abis.factory, + 'walletCount', + [owner] + ) + : BigInt(options.walletIndex); + const [wallet, deployed] = await s.read<[Address, boolean]>( + factory, + gen.contracts.abis.factory, + 'predictWallet', + [owner, index] + ); + if ( + deployed && + getAddress( + await s.read
(factory, gen.contracts.abis.factory, 'ownerOf', [ + wallet, + ]) + ) !== owner + ) + throw new MarketError( + 'NOT_OWNER', + 'The connected account does not own this wallet.' + ); + if ( + (await s.read(d.marketplace, ABI, 'activeJobOf', [wallet])) !== + BigInt(0) + ) + throw new MarketError( + 'WALLET_HAS_ACTIVE_JOB', + 'This wallet already has an active marketplace job.' + ); + const accounts: Record = {}; + const chains = new Set([ + card.chain, + ...card.jobSpecTemplate.reads + .filter( + (r, i) => + r.fills?.some((f) => f.source === 'WALLET') || + card.jobSpecTemplate.checks.some( + (c) => c.read === i && c.target === 'agentic:wallet' + ) + ) + .map((r) => `${r.chainNamespace}:${r.chainId}`), + ]); + for (const chain of chains) { + const [independent, onchain] = await Promise.all([ + rt.agw.resolveCEA(wallet, chain as CHAIN), + s.read
(d.marketplace, ABI, 'computeCEA', [ + wallet, + keccak256(stringToHex(chain)), + ]), + ]); + if (getAddress(independent.cea) !== getAddress(onchain)) + throw new MarketError( + 'CEA_MISMATCH', + 'The independently derived destination wallet differs from the marketplace.', + { chain, wallet, expectedCEA: onchain, derivedCEA: independent.cea } + ); + accounts[chain] = getAddress(onchain); + } + const rules = decodeCardRulesWire(card.rulesBytes); + const terms = fillCardRules( + rules, + job.principal, + accounts[card.chain], + job.expiredAt + ); + const session = buildSession({ + validator: gen.addresses.sessionValidator, + agent: card.provider, + rulesPolicy: gen.addresses.rulesPolicy, + actions: [ + { + target: gen.addresses.gateway, + selector: '0x77b86bec', + initData: encodeEnvelope(card.chain, encodeUniversalTerms(terms)), + }, + ], + }); + const rawChain = rt.agw.signerOrigin().chain; + if (!/^eip155:[1-9]\d*$/.test(rawChain)) + unavailable('Non-EVM OwnerIntent signing'); + const deadline = options.deadline ?? Math.min(now + 3600, job.expiredAt); + if ( + !Number.isSafeInteger(deadline) || + deadline <= now || + deadline >= 2 ** 48 + ) + throw new MarketError( + 'INVALID_DEADLINE', + 'The intent deadline must be a future uint48 timestamp.' + ); + const request = { + cardId, + owner, + index, + job, + deadline, + signerChainId: BigInt(rawChain.split(':')[1]), + }; + const [intent, [mode, calldata]] = await Promise.all([ + s.read(d.marketplace, ABI, 'previewIntent', [ + request, + session, + ]), + s.read<[Hex, Hex]>(d.marketplace, ABI, 'buildCreateJobCalldata', [ + cardId, + owner, + index, + job, + ]), + ]); + const spec = resolveJobSpec(card.jobSpecTemplate, { + principal: job.principal, + params: job.params, + accounts, + origin: specOrigin(d.marketplace, cardId, card.version, job.principal), + executeBy: job.executeBy, + settleWindow: card.settleWindow, + }); + if ( + getAddress(intent.owner) !== owner || + intent.index !== index || + intent.deadline !== deadline || + intent.signerChainId !== request.signerChainId + ) + throw new MarketError( + 'INTENT_MISMATCH', + 'The preview intent names a different owner, wallet index, chain or deadline.' + ); + const bytes = encodeJobSpec(spec); + const packedTarget = getAddress(('0x' + calldata.slice(2, 42)) as Address); + const kernelCall = decodeCreateJob(('0x' + calldata.slice(106)) as Hex); + if ( + packedTarget !== getAddress(d.kernel) || + BigInt('0x' + calldata.slice(42, 106)) !== BigInt(0) || + kernelCall.description !== bytes || + getAddress(kernelCall.provider) !== getAddress(card.provider) || + getAddress(kernelCall.evaluator) !== getAddress(evaluator) || + getAddress(kernelCall.hook) !== getAddress(hook) || + kernelCall.expiredAt !== BigInt(job.expiredAt) || + intent.execCalldataHash !== keccak256(calldata) || + intent.sessionHash !== keccak256(encodeSession(session)) || + intent.mode !== mode || + getAddress(intent.wallet) !== getAddress(wallet) || + getAddress(intent.executor) !== getAddress(d.marketplace) + ) + throw new MarketError( + 'INTENT_MISMATCH', + 'The marketplace preview disagrees with the independently built job or permissions.' + ); + await s.read(d.rules, MARKET_RULES_ABI, 'checkRulesMatchCard', [ + card.rulesBytes, + session, + { + agent: card.provider, + chainHash: keccak256(stringToHex(card.chain)), + principal: job.principal, + expiredAt: job.expiredAt, + expectedCEA: accounts[card.chain], + }, + ]); + const label = options.label ?? 'Agent job'; + assertLabel(label); + const description = describeJobSpec(spec); + return { + cardId, + cardVersion: card.version, + wallet: getAddress(wallet), + walletDeployed: deployed, + session, + intent, + calldata, + jobSpec: { spec, bytes }, + job, + label, + deposit: { + principal: { + prc20: terms.assets[rules.principalAsset].token, + amount: job.principal, + }, + providerFee: { token: card.paymentToken, amount: card.providerFee }, + pc: card.rules.maxGasPerCall, + }, + preview: { + mayDo: terms.allowedCalls.map( + (c) => + `${card.chain}: ${c.target} ${c.selector}; ${c.pins.length} argument conditions; max value ${c.maxValue}.` + ), + mayMove: terms.assets.map( + (a) => + `${a.token}: up to ${a.maxPerCall} per outbound, ${a.maxTotal} total; gas budget ${terms.maxGasPerCall} PC wei.` + ), + judgedBy: [...description.checks, description.logic], + until: new Date(job.expiredAt * 1000).toISOString(), + }, + }; + } + async openJob(plan: JobPlan, options: MarketOpenOptions = {}) { + plan = copyData(plan); + options = { ...options }; + if (options.relay) unavailable('Marketplace relaying'); + if (options.resume) return this.openedResult(plan, options.resume, true); + if (this.rt.agw.isReadOnly) + throw new MarketError( + 'READ_ONLY', + 'Connect a signing wallet to open a job.' + ); + if (this.rt.canSignTypedData?.() === false) + unavailable('EIP-712 signing with this wallet'); + if (!this.rt.agw.reader.call) + unavailable('Signed marketplace transaction simulation with this reader'); + const ownerCode = await this.rt.agw.reader.getCode({ + address: this.rt.agw.signerPushAccount(), + }); + if (!this.rt.agw.signerIsPushNative() && (!ownerCode || ownerCode === '0x')) + throw new MarketError( + 'OWNER_NOT_DEPLOYED', + 'Deploy the connected signer’s UEA on Push before signing a marketplace intent.' + ); + const fresh = await this.preview(plan.cardId, { + ...plan.job, + walletIndex: Number(plan.intent.index), + deadline: plan.intent.deadline, + label: plan.label, + }); + if (fp(fresh) !== fp(plan)) + throw new MarketError( + 'STALE_PLAN', + 'The plan changed. Preview and review it again before signing.' + ); + const d = deployment(this.rt); + const gen = currentGeneration(this.rt.agw.network); + progress(this.rt, options.progressHook, 'MARKET-TX-101', 'Plan verified', { + cardId: plan.cardId, + wallet: plan.wallet, + }); + const domain = { + name: 'AGWFactory', + version: '1', + chainId: Number(plan.intent.signerChainId), + verifyingContract: gen.addresses.factory, + salt: toHex(BigInt(this.rt.agw.pushChainNamespace.split(':')[1]), { + size: 32, + }), + }; + const separator = await ( + await snapshot(this.rt) + ).read( + gen.addresses.factory, + gen.contracts.abis.factory, + 'domainSeparator', + [plan.intent.signerChainId] + ); + if (separator.toLowerCase() !== domainSeparator({ domain }).toLowerCase()) + throw new MarketError( + 'INTENT_MISMATCH', + 'The factory EIP-712 domain differs from the signing domain.' + ); + let sig: Hex; + try { + sig = (await this.rt.signTypedData({ + domain, + types: { OwnerIntent: ownerIntentFields }, + primaryType: 'OwnerIntent', + message: { ...plan.intent }, + })) as Hex; + } catch (error) { + throw stackError(error); + } + progress( + this.rt, + options.progressHook, + 'MARKET-TX-102', + 'Owner intent signed' + ); + const call = { + target: d.marketplace, + value: BigInt(0), + data: encodeFunctionData({ + abi: ABI, + functionName: 'openJob', + args: [ + { + cardId: plan.cardId, + cardVersion: plan.cardVersion, + job: plan.job, + session: plan.session, + intent: plan.intent, + sig, + label: plan.label, + }, + ], + }), + }; + try { + // Runs the actual factory, owner verifier (UEA / ERC-1271 / EOA), grant and kernel create. + // This catches unsupported 7702 delegates without spending gas or bypassing owner verification. + await this.rt.agw.reader.call({ + to: call.target, + data: call.data, + account: this.rt.agw.signerPushAccount(), + }); + } catch (cause) { + throw new MarketError( + 'OPEN_JOB_SIMULATION_FAILED', + 'The signed marketplace request failed simulation. No transaction was submitted.', + { contractError: stackError(cause) }, + cause + ); + } + const tx = await write(this.rt, call, options.progressHook, 'MARKET', true); + return this.openedResult(plan, tx); + } + private async openedResult( + plan: JobPlan, + tx: import('../orchestrator/orchestrator.types').UniversalTxResponse, + recover = false + ) { + try { + return await this.verifyOpening(plan, tx, recover); + } catch (cause) { + if (cause instanceof MarketError && cause.details?.['txHash']) + throw cause; + throw new MarketError( + 'OPEN_JOB_RECOVERY_REQUIRED', + 'The opening was submitted but could not be fully verified. Track this transaction and resume with the original plan before opening again.', + { txHash: tx.hash }, + cause + ); + } + } + private async verifyOpening( + plan: JobPlan, + tx: import('../orchestrator/orchestrator.types').UniversalTxResponse, + recover: boolean + ) { + const d = deployment(this.rt); + const gen = currentGeneration(this.rt.agw.network); + const e = await eventArgs(this.rt, tx, d.marketplace, ABI, 'JobOpened'); + const [agent] = decodeAbiParameters( + [{ type: 'address' }], + plan.session.sessionValidatorInitData + ); + const expectedRules = permissionId({ + validator: gen.addresses.sessionValidator, + agent, + grantNonce: plan.intent.grantNonce, + }); + if ( + e['cardId'] !== plan.cardId || + e['cardVersion'] !== plan.cardVersion || + getAddress(e['agw'] as Address) !== plan.wallet || + e['principal'] !== plan.job.principal || + getAddress(e['owner'] as Address) !== getAddress(plan.intent.owner) || + e['rulesId'] !== expectedRules || + getAddress(plan.session.sessionValidator) !== + getAddress(gen.addresses.sessionValidator) || + plan.intent.sessionHash !== keccak256(encodeSession(plan.session)) || + plan.intent.execCalldataHash !== keccak256(plan.calldata) + ) + throw new MarketError( + 'RECEIPT_MISMATCH', + 'The opened job does not match the signed plan.', + { txHash: tx.hash } + ); + const jobId = e['jobId'] as bigint; + const s = await snapshot(this.rt); + const [cardId, version, wallet, rules, job, owner] = await Promise.all([ + s.read(d.marketplace, ABI, 'cardOfJob', [jobId]), + s.read(d.marketplace, ABI, 'cardVersionOfJob', [jobId]), + s.read
(d.marketplace, ABI, 'agwOfJob', [jobId]), + s.read(d.marketplace, ABI, 'rulesOfJob', [jobId]), + s.read<{ + client: Address; + provider: Address; + evaluator: Address; + hook: Address; + expiredAt: number; + description: Hex; + }>(d.kernel, KERNEL_READ_ABI, 'getJob', [jobId]), + s.read
( + gen.addresses.factory, + gen.contracts.abis.factory, + 'ownerOf', + [plan.wallet] + ), + ]); + const creation = decodeCreateJob(('0x' + plan.calldata.slice(106)) as Hex); + if ( + cardId !== plan.cardId || + version !== plan.cardVersion || + getAddress(wallet) !== getAddress(plan.wallet) || + rules !== expectedRules || + getAddress(owner) !== getAddress(plan.intent.owner) || + getAddress(job.client) !== getAddress(plan.wallet) || + getAddress(job.provider) !== getAddress(agent) || + getAddress(job.evaluator) !== getAddress(creation.evaluator) || + getAddress(job.hook) !== getAddress(creation.hook) || + job.expiredAt !== plan.job.expiredAt || + job.description !== plan.jobSpec.bytes + ) + throw new MarketError( + 'RECEIPT_MISMATCH', + 'The kernel, wallet and marketplace mappings do not match the opening.', + { txHash: tx.hash, jobId } + ); + if (recover) { + if (!this.rt.agw.reader.getTransaction) + throw new MarketError( + 'HISTORY_UNAVAILABLE', + 'This reader cannot load the submitted opening input.', + { txHash: tx.hash } + ); + const transaction = await this.rt.agw.reader.getTransaction({ + hash: tx.hash as Hex, + }); + const matched = embeddedCalls(transaction.input, ABI, 'openJob').some( + ([value]) => { + const p = value as { + cardId: bigint; + cardVersion: bigint; + job: MarketJobInputs; + intent: OwnerIntent; + session: JobPlan['session']; + }; + return ( + p.cardId === plan.cardId && + p.cardVersion === plan.cardVersion && + fp(p.job) === fp(plan.job) && + fp(p.intent) === fp(plan.intent) && + encodeSession(p.session) === encodeSession(plan.session) + ); + } + ); + if (!matched) + throw new MarketError( + 'RECEIPT_MISMATCH', + 'The submitted input does not contain this reviewed opening.', + { txHash: tx.hash, jobId } + ); + } + return { + jobId, + wallet: getAddress(e['agw'] as Address), + rulesId: e['rulesId'] as Hex, + tx, + }; + } + async deposit(jobId: bigint, options: MarketDepositOptions = {}) { + options = { ...options, resume: options.resume && [...options.resume] }; + if (this.deposits.has(jobId)) + throw new MarketError( + 'DEPOSIT_IN_PROGRESS', + 'A deposit for this job is already running on this client.' + ); + this.deposits.add(jobId); + try { + return await depositJob( + this.rt, + jobId, + options, + (id, s) => this.cardAt(id, s), + this.depositCheckpoints + ); + } finally { + this.deposits.delete(jobId); + } + } + readonly agent = { + card: async (): Promise => { + const d = deployment(this.rt); + const s = await snapshot(this.rt); + const n = await s.read(d.marketplace, ABI, 'agentCardOf', [ + this.rt.agw.signerPushAccount(), + ]); + return n ? this.cardAt(n, s) : undefined; + }, + register: async ( + card: AgentCardInput, + options: Parameters[1] + ) => this.cardWrite(undefined, card, options), + update: async ( + cardId: bigint, + card: AgentCardInput, + options: Parameters[2] + ) => this.cardWrite(cardId, card, options), + setActive: async ( + cardId: bigint, + active: boolean, + options: MarketWriteOptions = {} + ) => { + const card = await this.summaryAt(cardId, await snapshot(this.rt)); + this.assertProvider(card); + const d = deployment(this.rt); + return write( + this.rt, + { + target: d.marketplace, + value: BigInt(0), + data: encodeFunctionData({ + abi: ABI, + functionName: 'setAgentCardActive', + args: [cardId, active], + }), + }, + options.progressHook, + 'MARKET', + true + ); + }, + }; + private assertProvider(card: Pick): void { + if ( + getAddress(card.provider) !== getAddress(this.rt.agw.signerPushAccount()) + ) + throw new MarketError( + 'NOT_CARD_PROVIDER', + 'Connect the provider that registered this card.' + ); + } + private async cardWrite( + cardId: bigint | undefined, + card: AgentCardInput, + options: Parameters[1] + ) { + const d = deployment(this.rt); + card = { ...card }; + options = { ...options }; + const rules = encodeAgentCardRules(options.rules, { + chain: card.chain, + network: this.rt.agw.network, + }); + const template = encodeJobSpec(options.jobSpecTemplate); + const mutability = options.jobSpecTemplate.allowedMutability; + const s = await snapshot(this.rt); + if ( + !card.jobType || + !/^eip155:[1-9]\d*$/.test(card.chain) || + !card.metadataURI || + !/^0x[\da-f]{64}$/i.test(card.metadataHash) || + /^0x0{64}$/i.test(card.metadataHash) + ) + throw new MarketError( + 'INVALID_AGENT_CARD', + 'Supply a job type, EVM execution chain, metadata URI and nonzero metadata hash.' + ); + for (const v of [card.providerFee, card.principalMin, card.principalMax]) + if ( + typeof v !== 'bigint' || + v < BigInt(0) || + v >= BigInt(1) << BigInt(256) + ) + throw new MarketError( + 'INVALID_AGENT_CARD', + 'Card amounts must fit uint256.' + ); + if ( + card.principalMax === BigInt(0) || + card.principalMin > card.principalMax || + [ + card.minDuration, + card.maxDuration, + card.minExecuteWindow, + card.settleWindow, + ].some((n) => !Number.isInteger(n) || n < 0 || n > 0xffffffff) || + card.minDuration < 3600 || + card.minDuration > card.maxDuration || + card.settleWindow < 1 || + card.minExecuteWindow + card.settleWindow > card.maxDuration + ) + throw new MarketError( + 'INVALID_AGENT_CARD', + 'The principal or duration ranges are invalid.' + ); + if (cardId !== undefined) { + const current = await this.summaryAt(cardId, s); + this.assertProvider(current); + if (current.chain !== card.chain) + throw new MarketError( + 'CARD_IDENTITY_IMMUTABLE', + 'An existing card cannot change its execution chain.' + ); + } + if (mutability !== 'NONE') unavailable('Mutable marketplace criteria'); + const token = await s.read
( + d.kernel, + JOB_KERNEL_ABI, + 'paymentToken' + ); + if (getAddress(card.paymentToken) !== getAddress(token)) + throw new MarketError( + 'TOKEN_MISMATCH', + 'This deployment accepts only the kernel payment token.' + ); + const { chain, ...input } = card; + const jobType = /^0x[\da-f]{64}$/i.test(card.jobType) + ? (card.jobType as Hex) + : keccak256(stringToHex(card.jobType)); + const c = { + ...input, + jobType, + chainNamespace: chain, + provider: this.rt.agw.signerPushAccount(), + active: true, + }; + const data = + cardId === undefined + ? encodeFunctionData({ + abi: ABI, + functionName: 'registerAgentCard', + args: [c, rules, template], + }) + : encodeFunctionData({ + abi: ABI, + functionName: 'updateAgentCard', + args: [cardId, c, rules, template], + }); + if (this.rt.agw.reader.call) { + try { + await this.rt.agw.reader.call({ + to: d.marketplace, + data, + account: this.rt.agw.signerPushAccount(), + }); + } catch (cause) { + throw new MarketError( + 'CARD_SIMULATION_FAILED', + 'The card write failed contract simulation. No transaction was submitted.', + { contractError: stackError(cause) }, + cause + ); + } + } + const tx = await write( + this.rt, + { target: d.marketplace, value: BigInt(0), data }, + options.progressHook, + 'MARKET', + true + ); + const e = await eventArgs( + this.rt, + tx, + d.marketplace, + ABI, + cardId === undefined ? 'AgentCardRegistered' : 'AgentCardUpdated' + ); + return { + cardId: e['cardId'] as bigint, + version: cardId === undefined ? BigInt(1) : (e['version'] as bigint), + tx, + }; + } +} diff --git a/packages/core/src/lib/market/rules.ts b/packages/core/src/lib/market/rules.ts new file mode 100644 index 000000000..8f0ae9398 --- /dev/null +++ b/packages/core/src/lib/market/rules.ts @@ -0,0 +1,417 @@ +import { + decodeAbiParameters, + encodeAbiParameters, + getAddress, + isAddress, + keccak256, + stringToHex, + toHex, + type Address, + type Hex, +} from 'viem'; +import type { MoveableToken } from '../constants/tokens'; +import { CHAIN, PUSH_NETWORK } from '../constants/enums'; +import { getPRC20Address } from '../universal/prc20-address'; +import { argumentLocation, encodeArgWord } from '../agentic/codec/abi-layout'; +import { parseSelector } from '../agentic/codec/selectors'; +import { validatePins } from '../agentic/codec/pins'; +import { + UNIVERSAL_TERMS_PARAM, + type AssetCapWire, + type AllowedCallWire, + type UniversalTermsWire, +} from '../agentic/codec/universal-terms'; +import type { + ArgumentPath, + WalletMarker, + Selector, +} from '../agentic/agentic.types'; +import type { PrincipalMarker } from '../job-spec/types'; +import { PRINCIPAL } from '../job-spec/constants'; +import { MarketError } from './errors'; + +export type MarketAssetToken = MoveableToken | string | { prc20: Address }; +export type MarketPin = + | { + arg: ArgumentPath; + expected: + | Hex + | Address + | bigint + | boolean + | WalletMarker + | PrincipalMarker; + max?: never; + } + | { arg: ArgumentPath; max: bigint | PrincipalMarker; expected?: never }; +export interface AgentCardRules { + assets: { + token: MarketAssetToken; + maxPerCall: bigint | PrincipalMarker; + maxTotal?: bigint | PrincipalMarker; + }[]; + principalAsset: number; + maxGasPerCall: bigint; + allowedCalls: { + target: Address; + selector: string; + pins?: MarketPin[]; + maxValue?: bigint; + }[]; +} +export interface CardRulesWire { + assets: readonly AssetCapWire[]; + principalAsset: number; + maxGasPerCall: bigint; + allowedCalls: readonly AllowedCallWire[]; +} +export const MARKER_WALLET = keccak256( + stringToHex('push.marketplace.marker.WALLET') +); +export const MARKER_PRINCIPAL = keccak256( + stringToHex('push.marketplace.marker.PRINCIPAL') +); +const PRINCIPAL_WORD = BigInt(MARKER_PRINCIPAL); +const decodedRules = new WeakMap< + AgentCardRules, + { fingerprint: string; wire: CardRulesWire } +>(); +const fingerprint = (r: AgentCardRules) => + JSON.stringify(r, (_, v) => (typeof v === 'bigint' ? v.toString() : v)); +const fields = UNIVERSAL_TERMS_PARAM.components; +export const CARD_RULES_PARAM = { + type: 'tuple', + components: [ + fields[2], + { name: 'principalAsset', type: 'uint8' }, + fields[3], + fields[4], + ], +} as const; +const bad = (message: string): never => { + throw new MarketError('INVALID_AGENT_CARD_RULES', message); +}; +function cap(n: bigint | PrincipalMarker | undefined): bigint { + const v = n === PRINCIPAL ? PRINCIPAL_WORD : n ?? BigInt(0); + if (typeof v !== 'bigint' || v < BigInt(0) || v >= BigInt(1) << BigInt(256)) + bad('Spending caps must fit uint256.'); + return v; +} +function toWire( + input: AgentCardRules, + options?: { chain?: string; network?: PUSH_NETWORK; structural?: boolean } +): CardRulesWire { + if ( + !input || + !Array.isArray(input.assets) || + !Array.isArray(input.allowedCalls) + ) + bad('Card rules need assets and allowedCalls arrays.'); + const saved = decodedRules.get(input); + if (saved?.fingerprint === fingerprint(input)) return saved.wire; + if ('approvals' in input) + bad( + 'V2 approvals belong in allowedCalls with spender and amount pins; remove the retired approvals array.' + ); + if ( + input.assets.some( + (a) => + typeof a.token === 'object' && + 'prc20' in a.token && + /^0x0{40}$/i.test(a.token.prc20) + ) + ) + bad('An explicit PRC20 reference cannot be zero.'); + const assets = input.assets.map((a) => ({ + token: + typeof a.token === 'object' && 'prc20' in a.token + ? getAddress(a.token.prc20) + : (() => { + if (options?.structural) + return getAddress( + typeof a.token === 'string' ? a.token : a.token.address + ); + if (!options?.chain) + return bad( + 'Encoding destination tokens needs the card chain, or an explicit { prc20 } reference.' + ); + try { + return getPRC20Address( + { + chain: options.chain as CHAIN, + address: + typeof a.token === 'string' ? a.token : a.token.address, + }, + { network: options.network ?? PUSH_NETWORK.TESTNET_DONUT } + ).address as Address; + } catch (cause) { + throw new MarketError( + 'TOKEN_ROUTE_UNAVAILABLE', + 'This card token has no verified Push route for the selected chain.', + { chain: options.chain, token: a.token }, + cause + ); + } + })(), + maxPerCall: cap(a.maxPerCall), + maxTotal: cap(a.maxTotal), + })); + const allowedCalls: AllowedCallWire[] = input.allowedCalls.map((call) => { + const parsed = parseSelector(call.selector as Selector); + if (parsed.valueOnly) + bad( + 'Marketplace cards support EVM function calls, not Push value-only rules.' + ); + const pins = (call.pins ?? []).map((p) => { + const location = parsed.inputs + ? argumentLocation(parsed.inputs, p.arg, 'Card pin') + : { + offset: + typeof p.arg === 'number' && Number.isInteger(p.arg) && p.arg >= 0 + ? 4 + p.arg * 32 + : bad( + 'Raw selector pins need a nonnegative integer word index.' + ), + param: { type: 'uint256' } as const, + }; + const max = 'max' in p && p.max !== undefined; + const value = 'max' in p && p.max !== undefined ? p.max : p.expected; + if (value === undefined) return bad('A card pin needs expected or max.'); + if (value === 'agentic:wallet' && max) + bad('WALLET is supported only in equality pins.'); + const encoded = + value === 'agentic:wallet' + ? MARKER_WALLET + : value === PRINCIPAL + ? MARKER_PRINCIPAL + : max + ? toHex(cap(value as bigint), { size: 32 }) + : encodeArgWord(location.param, value, 'Card pin'); + return { + offset: location.offset, + op: max ? (1 as const) : (0 as const), + value: encoded, + }; + }); + return { + target: getAddress(call.target), + selector: parsed.selector, + beneficiaryOffset: 0, + hasBeneficiary: false, + maxValue: cap(call.maxValue), + pins, + }; + }); + const wire = { + assets, + principalAsset: input.principalAsset, + maxGasPerCall: cap(input.maxGasPerCall), + allowedCalls, + }; + validateWire(wire, !!options?.structural); + return wire; +} +function validateWire(r: CardRulesWire, structural = false): void { + if (!Array.isArray(r.assets) || r.assets.length < 1 || r.assets.length > 8) + bad('Cards need 1..8 assets.'); + if ( + !Number.isInteger(r.principalAsset) || + r.principalAsset < 0 || + r.principalAsset >= r.assets.length + ) + bad('principalAsset is outside the asset list.'); + const tokens = new Set(); + r.assets.forEach((a, i) => { + if ( + !isAddress(a.token) || + (!structural && /^0x0{40}$/i.test(a.token)) || + tokens.has(a.token.toLowerCase()) + ) + bad('Assets need distinct nonzero PRC20 addresses.'); + tokens.add(a.token.toLowerCase()); + cap(a.maxPerCall); + cap(a.maxTotal); + if (i === r.principalAsset) { + if (a.maxTotal !== PRINCIPAL_WORD || a.maxPerCall === BigInt(0)) + bad( + 'The principal total must be PRINCIPAL and its per-call cap must be positive or PRINCIPAL.' + ); + } else if (a.maxPerCall === PRINCIPAL_WORD || a.maxTotal === PRINCIPAL_WORD) + bad('PRINCIPAL asset-cap markers belong only on the principal asset.'); + }); + cap(r.maxGasPerCall); + if ( + !Array.isArray(r.allowedCalls) || + r.allowedCalls.length < 1 || + r.allowedCalls.length > 32 + ) + bad('Cards need 1..32 allowed calls.'); + if (r.allowedCalls.reduce((n, c) => n + c.pins.length, 0) > 32) + bad('A card rule supports at most 32 conditions.'); + const entries = new Set(); + const pairs = new Map(); + for (const c of r.allowedCalls) { + if ( + !isAddress(c.target) || + /^0x0{40}$/i.test(c.target) || + !/^0x[\da-f]{8}$/i.test(c.selector) + ) + bad('An allowed app or selector is invalid.'); + cap(c.maxValue); + try { + validatePins(c.pins); + } catch (cause) { + throw new MarketError( + 'INVALID_AGENT_CARD_RULES', + 'An allowed call has invalid argument conditions.', + undefined, + cause + ); + } + if ( + c.hasBeneficiary && + (c.beneficiaryOffset < 4 || (c.beneficiaryOffset - 4) % 32 !== 0) + ) + bad('Invalid recipient offset.'); + for (const p of c.pins) + if (p.value === MARKER_WALLET && p.op !== 0) + bad('WALLET pins must use equality.'); + const encoded = encodeAbiParameters([{ ...fields[4], type: 'tuple' }], [c]); + if (entries.has(encoded)) bad('Duplicate allowed call.'); + entries.add(encoded); + const key = `${c.target.toLowerCase()}:${c.selector.toLowerCase()}`; + if (pairs.has(key) && (!pairs.get(key) || !c.pins.length)) + bad('An unpinned alternative would bypass the pinned call.'); + pairs.set(key, c.pins.length > 0); + const pin = (offset: number, op: number) => + c.pins.find((p) => p.offset === offset && p.op === op); + const recipient = (offset: number) => + (c.hasBeneficiary && c.beneficiaryOffset === offset) || + pin(offset, 0)?.value === MARKER_WALLET; + if (c.selector === '0x39509351') + bad('increaseAllowance is not supported in cards.'); + if (c.selector === '0x095ea7b3') { + const spender = pin(4, 0)?.value; + if ( + !spender || + BigInt(spender) === BigInt(0) || + BigInt(spender) >> BigInt(160) !== BigInt(0) + ) + bad('approve needs an EQ pin to a nonzero literal spender.'); + if (!pin(36, 1)) bad('approve needs a MAX amount pin.'); + } + if (c.selector === '0xa9059cbb' && !recipient(4)) + bad('transfer must pin its recipient to WALLET.'); + if (c.selector === '0x23b872dd' && !recipient(36)) + bad('transferFrom must pin its recipient to WALLET.'); + } +} +export function validateAgentCardRules(rules: AgentCardRules): void { + toWire(rules, { structural: true }); +} +export function encodeAgentCardRules( + rules: AgentCardRules, + options: { chain: string; network?: PUSH_NETWORK } +): Hex { + return encodeAbiParameters([CARD_RULES_PARAM], [toWire(rules, options)]); +} +export function decodeCardRulesWire(bytes: Hex): CardRulesWire { + try { + const wire = decodeAbiParameters( + [CARD_RULES_PARAM], + bytes + )[0] as unknown as CardRulesWire; + validateWire(wire); + if ( + encodeAbiParameters([CARD_RULES_PARAM], [wire]).toLowerCase() !== + bytes.toLowerCase() + ) + bad('Noncanonical card rules.'); + return wire; + } catch (cause) { + if (cause instanceof MarketError) throw cause; + throw new MarketError( + 'INVALID_AGENT_CARD_RULES', + 'Unsupported or malformed v2 card-rule encoding.', + undefined, + cause + ); + } +} +export function decodeAgentCardRules( + bytes: Hex, + _options?: { chain?: string } +): AgentCardRules { + void _options; + const r = decodeCardRulesWire(bytes); + // Explicit PRC20 references make unknown/custom registry tokens lossless and unambiguous. + const result: AgentCardRules = { + ...r, + assets: r.assets.map((a) => ({ + token: { prc20: a.token }, + maxPerCall: a.maxPerCall === PRINCIPAL_WORD ? PRINCIPAL : a.maxPerCall, + maxTotal: a.maxTotal === PRINCIPAL_WORD ? PRINCIPAL : a.maxTotal, + })), + allowedCalls: r.allowedCalls.map((c) => ({ + target: c.target, + selector: c.selector, + maxValue: c.maxValue, + pins: [ + ...c.pins.map((p) => { + const value = + p.value === MARKER_WALLET + ? ('agentic:wallet' as const) + : p.value === MARKER_PRINCIPAL + ? PRINCIPAL + : p.value; + return p.op === 1 + ? { + arg: (p.offset - 4) / 32, + max: value === PRINCIPAL ? PRINCIPAL : BigInt(p.value), + } + : { arg: (p.offset - 4) / 32, expected: value }; + }), + ...(c.hasBeneficiary + ? [ + { + arg: (c.beneficiaryOffset - 4) / 32, + expected: 'agentic:wallet' as const, + }, + ] + : []), + ], + })), + }; + decodedRules.set(result, { fingerprint: fingerprint(result), wire: r }); + return result; +} +export function fillCardRules( + r: CardRulesWire, + principal: bigint, + wallet: Address, + expiredAt: number +): UniversalTermsWire { + const walletWord = toHex(BigInt(wallet), { size: 32 }); + return { + validUntil: expiredAt, + expectedCEA: wallet, + assets: r.assets.map((a, i) => ({ + ...a, + maxPerCall: a.maxPerCall === PRINCIPAL_WORD ? principal : a.maxPerCall, + maxTotal: i === r.principalAsset ? principal : a.maxTotal, + })), + maxGasPerCall: r.maxGasPerCall, + allowedCalls: r.allowedCalls.map((c) => ({ + ...c, + pins: c.pins.map((p) => ({ + ...p, + value: + p.value === MARKER_WALLET + ? walletWord + : p.value === MARKER_PRINCIPAL + ? toHex(principal, { size: 32 }) + : p.value, + })), + })), + }; +} diff --git a/packages/core/src/lib/market/runtime.ts b/packages/core/src/lib/market/runtime.ts new file mode 100644 index 000000000..4f429dd25 --- /dev/null +++ b/packages/core/src/lib/market/runtime.ts @@ -0,0 +1,236 @@ +import { + decodeEventLog, + getAddress, + type Abi, + type Address, + type Log, + type TypedData, + type TypedDataDomain, +} from 'viem'; +import type { AgenticRuntime } from '../agentic/runtime'; +import { confirmedLogs } from '../agentic/management/common'; +import { currentGeneration } from '../agentic/deployments'; +import type { Call } from '../agentic/contracts/v6'; +import type { + ExecuteParams, + TransactionExecutionOptions, + UniversalExecuteParams, + UniversalTxResponse, +} from '../orchestrator/orchestrator.types'; +import type { ProgressEvent } from '../progress-hook/progress-hook.types'; +import { MarketError, stackError } from './errors'; +import { marketplaceDeployment, type MarketDeployment } from './deployment'; + +export type StackProgressHook = (event: ProgressEvent) => void; +export interface StackRuntime { + agw: AgenticRuntime; + account(): Address; + wallet(): Address | undefined; + send( + params: ExecuteParams | UniversalExecuteParams, + options?: TransactionExecutionOptions + ): Promise; + signTypedData(data: { + domain: TypedDataDomain; + types: TypedData; + primaryType: string; + message: Record; + }): Promise; + canSignTypedData?(): boolean; + /** Internal fixture override; never a public initialization option. */ + deployment?: MarketDeployment; + /** Receipt-confirmed creation blocks accelerate bounded event recovery. */ + jobBlocks?: Map; +} +export function deployment(rt: StackRuntime): MarketDeployment { + return rt.deployment ?? marketplaceDeployment(rt.agw.network); +} +export interface MarketSnapshot { + blockNumber: bigint; + timestamp: number; + read( + address: Address, + abi: Abi, + name: string, + args?: readonly unknown[] + ): Promise; +} +export async function snapshot( + rt: StackRuntime, + atBlock?: bigint +): Promise { + const blockNumber = atBlock ?? (await rt.agw.reader.getBlockNumber()); + const timestamp = rt.agw.reader.getBlock + ? Number((await rt.agw.reader.getBlock({ blockNumber })).timestamp) + : rt.agw.nowSeconds(); + return { + blockNumber, + timestamp, + read: async ( + address: Address, + abi: Abi, + name: string, + args: readonly unknown[] = [] + ) => { + try { + return (await rt.agw.reader.readContract({ + address, + abi, + functionName: name, + args, + blockNumber, + })) as T; + } catch (cause) { + throw new MarketError( + 'READ_FAILED', + `Could not load ${name} at block ${blockNumber}.`, + { address, functionName: name, blockNumber }, + cause + ); + } + }, + }; +} +export function progress( + rt: StackRuntime, + hook: StackProgressHook | undefined, + id: string, + message: string, + response: object = {} +): void { + rt.agw.emit( + { + id, + title: message, + message, + level: id.endsWith('199-02') ? 'ERROR' : 'INFO', + response, + timestamp: new Date().toISOString(), + }, + hook + ); +} +export async function write( + rt: StackRuntime, + call: Call, + hook: StackProgressHook | undefined, + family: 'MARKET' | 'JOB', + signer = false +): Promise { + if (rt.agw.isReadOnly) + throw new MarketError('READ_ONLY', 'Connect a signing wallet to continue.'); + progress(rt, hook, `${family}-TX-103`, 'Submitting transaction'); + try { + const tx = await (signer + ? rt.agw.execute( + { to: call.target, value: call.value, data: call.data }, + { progressHook: hook } + ) + : rt.send( + { to: call.target, value: call.value, data: call.data }, + { progressHook: hook } + )); + const originalWait = tx.wait.bind(tx); + let terminalReported = false; + const wait: typeof tx.wait = async (...args) => { + try { + const receipt = await originalWait(...args); + if (!terminalReported) { + terminalReported = true; + progress( + rt, + hook, + `${family}-TX-199-${receipt.status === 1 ? '01' : '02'}`, + receipt.status === 1 + ? 'Transaction confirmed' + : 'Transaction reverted', + { txHash: tx.hash } + ); + } + return receipt; + } catch (error) { + progress( + rt, + hook, + `${family}-TX-199-02`, + 'Transaction confirmation unavailable', + { txHash: tx.hash } + ); + throw error; + } + }; + return { ...tx, wait }; + } catch (error) { + const wrapped = stackError(error); + progress(rt, hook, `${family}-TX-199-02`, 'Transaction failed', { + error: wrapped, + }); + throw wrapped; + } +} +export async function ownerBatch( + rt: StackRuntime, + wallet: Address, + calls: Call[], + hook?: StackProgressHook +): Promise { + const gen = currentGeneration(rt.agw.network); + const s = await snapshot(rt); + const owner = getAddress( + await s.read
(wallet, gen.contracts.abis.wallet, 'owner') + ); + if (owner !== getAddress(rt.agw.signerPushAccount())) + throw new MarketError('NOT_CLIENT', 'Connect the owner of the job wallet.'); + return write( + rt, + { + target: wallet, + value: BigInt(0), + data: gen.contracts.encodeExecute(calls), + }, + hook, + 'JOB', + true + ); +} +export async function eventArgs( + rt: StackRuntime, + tx: UniversalTxResponse, + address: Address, + abi: Abi, + name: string +): Promise> { + const result = await confirmedLogs(rt.agw, tx); + const found: Record[] = []; + for (const log of result.logs as Log[]) { + if (log.address.toLowerCase() !== address.toLowerCase()) continue; + try { + const e = decodeEventLog({ + abi, + eventName: name, + data: log.data, + topics: log.topics, + strict: true, + }); + if (e.eventName === name) { + found.push(e.args as unknown as Record); + const args = e.args as unknown as Record; + if ( + (name === 'JobCreated' || name === 'JobOpened') && + typeof args['jobId'] === 'bigint' && + log.blockNumber !== null + ) + rt.jobBlocks?.set(args['jobId'], log.blockNumber); + } + } catch { + /* Other contract events are expected in a receipt. */ + } + } + if (found.length !== 1) + throw new MarketError( + 'RECEIPT_MISMATCH', + `The submitted transaction did not produce one ${name} event.`, + { txHash: tx.hash } + ); + return found[0]; +} diff --git a/packages/core/src/lib/market/types.ts b/packages/core/src/lib/market/types.ts new file mode 100644 index 000000000..a181ee4a9 --- /dev/null +++ b/packages/core/src/lib/market/types.ts @@ -0,0 +1,177 @@ +import type { Address, Hex } from 'viem'; +import type { AgentCardRules } from './rules'; +import type { JobSpec, JobSpecTemplate } from '../job-spec/types'; +import type { SessionWire } from '../agentic/codec/session'; +import type { StackProgressHook } from './runtime'; +import type { UniversalTxResponse } from '../orchestrator/orchestrator.types'; + +export interface AgentCardInput { + /** A name is hashed on write; reads retain the canonical bytes32 jobType. */ + jobType: string; + metadataURI: string; + metadataHash: Hex; + chain: string; + providerFee: bigint; + paymentToken: Address; + principalMin: bigint; + principalMax: bigint; + minDuration: number; + maxDuration: number; + minExecuteWindow: number; + settleWindow: number; +} +export interface AgentCard extends AgentCardInput { + cardId: bigint; + provider: Address; + active: boolean; + version: bigint; + verified: boolean; + disabled: boolean; + rating: bigint; + rules: AgentCardRules; + jobSpecTemplate: JobSpecTemplate; + /** Canonical on-chain bytes retained for exact matching and unknown token metadata. */ + rulesBytes: Hex; + jobSpecTemplateBytes: Hex; +} +export type AgentCardSummary = Omit< + AgentCard, + 'rules' | 'jobSpecTemplate' | 'rulesBytes' | 'jobSpecTemplateBytes' +>; +export interface MarketJobInputs { + principal: bigint; + expiredAt: number; + executeBy: number; + params: bigint[]; +} +export interface OwnerIntent { + owner: Address; + wallet: Address; + executor: Address; + index: bigint; + sessionHash: Hex; + mode: Hex; + execCalldataHash: Hex; + nonceKey: bigint; + nonceSeq: bigint; + grantNonce: bigint; + deadline: number; + signerChainId: bigint; +} +export interface JobPlan { + cardId: bigint; + cardVersion: bigint; + wallet: Address; + walletDeployed: boolean; + session: SessionWire; + intent: OwnerIntent; + calldata: Hex; + jobSpec: { spec: JobSpec; bytes: Hex }; + job: MarketJobInputs; + label: string; + /** Reference deposit amounts; no principal or fee is moved by openJob. */ + deposit: { + principal: { prc20: Address; amount: bigint }; + providerFee: { token: Address; amount: bigint }; + pc: bigint; + }; + preview: { + mayDo: string[]; + mayMove: string[]; + judgedBy: string[]; + until: string; + }; +} +export interface MarketPreviewOptions extends Omit { + params?: bigint[]; + walletIndex?: number; + deadline?: number; + label?: string; +} +export interface MarketWriteOptions { + progressHook?: StackProgressHook; +} +export interface MarketOpenOptions extends MarketWriteOptions { + relay?: boolean; + /** Recover a submitted opening, without signing or sending again. Rehydrate a hash with trackTransaction. */ + resume?: UniversalTxResponse; +} +export interface MarketDepositStep { + /** PRINCIPAL_AND_FEE combines requirements when both use the same token. */ + step: 'PRINCIPAL' | 'PROVIDER_FEE' | 'PRINCIPAL_AND_FEE' | 'PC'; + token?: Address; + amount: bigint; + tx: UniversalTxResponse; +} +export interface MarketDepositOptions extends MarketWriteOptions { + /** Token transfers use Push holdings by default. Origin explicitly bridges matching registered assets; PC always uses Push holdings. */ + funding?: 'push' | 'origin'; + /** Wallet PC balance to reach. Defaults to the opened card's per-call gas cap. */ + pcAmount?: bigint; + /** Wallet fee reserve to reach. Defaults to the opened card's reference fee; does not accept a kernel price. */ + providerFee?: bigint; + /** Previously submitted steps. Their waits must settle before any further transfer. */ + resume?: readonly MarketDepositStep[]; +} +export interface MarketDepositResult { + jobId: bigint; + wallet: Address; + cardId: bigint; + cardVersion: bigint; + /** Includes resumed transactions, in submission order. */ + txs: MarketDepositStep[]; + /** A broadcast that failed before returning a response. Track it before resuming. */ + pending?: Omit & { txHash: string; chain: string }; +} +export interface MarketNamespace { + cards(options?: { + provider?: Address; + chain?: string; + active?: boolean; + }): Promise<{ cards: AgentCardSummary[] }>; + card(cardId: bigint): Promise; + preview(cardId: bigint, options: MarketPreviewOptions): Promise; + openJob( + plan: JobPlan, + options?: MarketOpenOptions + ): Promise<{ + jobId: bigint; + wallet: Address; + rulesId: Hex; + tx: UniversalTxResponse; + }>; + deposit( + jobId: bigint, + options?: MarketDepositOptions + ): Promise; + job(jobId: bigint): Promise<{ + cardId: bigint; + cardVersion: bigint; + wallet: Address; + rulesId: Hex; + }>; + activeJob(wallet: Address): Promise<{ jobId: bigint }>; + agent: { + register( + card: AgentCardInput, + options: MarketWriteOptions & { + rules: AgentCardRules; + jobSpecTemplate: JobSpecTemplate; + } + ): Promise<{ cardId: bigint; version: bigint; tx: UniversalTxResponse }>; + update( + cardId: bigint, + card: AgentCardInput, + options: MarketWriteOptions & { + rules: AgentCardRules; + jobSpecTemplate: JobSpecTemplate; + } + ): Promise<{ cardId: bigint; version: bigint; tx: UniversalTxResponse }>; + setActive( + cardId: bigint, + active: boolean, + options?: MarketWriteOptions + ): Promise; + card(): Promise; + }; +} diff --git a/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts b/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts index c90d3082c..10f6d4e42 100644 --- a/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts +++ b/packages/core/src/lib/orchestrator/__tests__/push-chain-native-multicall.spec.ts @@ -303,4 +303,19 @@ describe('atomic-only native batches (AGW sender-preserving arrays)', () => { expect(pushClient.sendBatch7702).toHaveBeenCalledTimes(1); expect(pushClient.sendTransaction).not.toHaveBeenCalled(); }); + it.each([false, true])('retains a broadcast hash when confirmation fails (atomic=%s)', async atomic => { + const { ctx, pushClient } = await context(); + const cause = new Error('Receipt RPC unavailable'); + pushClient.publicClient.waitForTransactionReceipt.mockRejectedValueOnce(cause); + const error = await sendPushTx(ctx, atomic ? execute : directExecute, [], transformFn, atomic).catch(e => e); + expect(error).toMatchObject({ txHash: atomic ? HASH_2 : HASH_1, pendingTransactionHash: atomic ? HASH_2 : HASH_1, cause }); + expect(pushClient.sendBatch7702).toHaveBeenCalledTimes(atomic ? 1 : 0); + expect(pushClient.sendTransaction).toHaveBeenCalledTimes(atomic ? 0 : 1); + }); + it('cannot fall back after a native atomic broadcast, even if later code throws an unsupported-signing error', async () => { + const { ctx, pushClient } = await context(); + pushClient.publicClient.waitForTransactionReceipt.mockRejectedValueOnce(new EIP7702NotSupportedError()); + await expect(sendPushTx(ctx, execute, [], transformFn)).rejects.toMatchObject({ txHash: HASH_2, pendingTransactionHash: HASH_2 }); + expect(pushClient.sendTransaction).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/lib/orchestrator/internals/errors.ts b/packages/core/src/lib/orchestrator/internals/errors.ts index 871ddda3d..9137b28c8 100644 --- a/packages/core/src/lib/orchestrator/internals/errors.ts +++ b/packages/core/src/lib/orchestrator/internals/errors.ts @@ -31,8 +31,10 @@ export interface DecodedErrorPayload { * discriminator lets callers classify via `instanceof PushChainExecutionError` * instead of sniffing error messages. * - * Carries the origin-chain gateway tx hash. There is no successful Push Chain - * tx to reference on this path — by definition the pcTx committed `FAILED`. + * A failed universal execution can carry its origin-chain gateway hash. Native + * confirmation/indexing errors retain the submitted Push hash instead; those + * errors do not prove that the transaction reverted. Track a retained hash + * before retrying an operation with side effects. * * `decodedError`: optional structured payload that flows through to terminal * failure hook payloads (199-02 / 299-02 / 399-02 / 999-02). Set by the @@ -42,16 +44,21 @@ export interface DecodedErrorPayload { export class PushChainExecutionError extends Error { readonly code = 'PUSH_CHAIN_EXECUTION_FAILED' as const; readonly gatewayTxHash?: string; + /** Native Push transaction already broadcast before confirmation/indexing failed. */ + readonly txHash?: `0x${string}`; + readonly pendingTransactionHash?: `0x${string}`; readonly decodedError?: DecodedErrorPayload; /** Underlying transport/RPC error, kept so raw revert data is not lost. */ override readonly cause?: unknown; constructor( message: string, - opts: { gatewayTxHash?: string; decodedError?: DecodedErrorPayload; cause?: unknown } = {} + opts: { gatewayTxHash?: string; txHash?: `0x${string}`; pendingTransactionHash?: `0x${string}`; decodedError?: DecodedErrorPayload; cause?: unknown } = {} ) { super(normalizePublicErrorMessage(message)); this.name = 'PushChainExecutionError'; this.gatewayTxHash = opts.gatewayTxHash; + this.txHash = opts.txHash; + this.pendingTransactionHash = opts.pendingTransactionHash; this.decodedError = opts.decodedError; if (opts.cause !== undefined) this.cause = opts.cause; } @@ -68,7 +75,7 @@ export class AtomicBatchUnavailableError extends PushChainExecutionError { /** Recovery metadata for a non-atomic Push batch. A pending hash is broadcast but unconfirmed. */ export class PushChainBatchExecutionError extends PushChainExecutionError { readonly transactionHashes: `0x${string}`[]; - readonly pendingTransactionHash?: `0x${string}`; + override readonly pendingTransactionHash?: `0x${string}`; constructor(message: string, transactionHashes: `0x${string}`[], pendingTransactionHash?: `0x${string}`) { super(message); diff --git a/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts b/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts index 140054282..b8f7bc6ef 100644 --- a/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts +++ b/packages/core/src/lib/orchestrator/internals/push-chain-tx.ts @@ -76,7 +76,7 @@ async function getIndexedPushTransaction( receiptWaited = true; if (receipt.status === 'reverted') { throw new PushChainExecutionError( - `${label} — Push Chain transaction ${txHash} reverted` + `${label} — Push Chain transaction ${txHash} reverted`, { txHash } ); } } @@ -149,6 +149,7 @@ export async function sendPushTx( } } if (executor && ctx.universalSigner.signAuthorization) { + let submittedHash: `0x${string}` | undefined; try { printLog( ctx, @@ -159,6 +160,7 @@ export async function sendPushTx( calls, signer: ctx.universalSigner, }); + submittedHash = txHash; const txResponse = await getIndexedPushTransaction( ctx, txHash, @@ -166,6 +168,10 @@ export async function sendPushTx( ); return await transformFn(txResponse, eventBuffer); } catch (err) { + if (submittedHash) { + if (err instanceof PushChainExecutionError && err.txHash === submittedHash) throw err; + throw new PushChainExecutionError(normalizePublicErrorMessage(err), { txHash: submittedHash, pendingTransactionHash: submittedHash, cause: err }); + } // Only fall back when the wallet genuinely can't sign a 7702 // authorization. This is raised before any broadcast, so falling back // to sequential execution cannot double-execute. Any other error @@ -313,12 +319,17 @@ export async function sendPushTx( value: execute.value, signer: ctx.universalSigner, }); + try { const txResponse = await getIndexedPushTransaction( ctx, txHash, 'sendPushTx' ); return await transformFn(txResponse, eventBuffer); + } catch (cause) { + if (cause instanceof PushChainExecutionError && cause.txHash === txHash) throw cause; + throw new PushChainExecutionError(normalizePublicErrorMessage(cause), { txHash, pendingTransactionHash: txHash, cause }); + } } // ============================================================================ diff --git a/packages/core/src/lib/push-chain/push-chain.ts b/packages/core/src/lib/push-chain/push-chain.ts index 5939f461e..4fe93214b 100644 --- a/packages/core/src/lib/push-chain/push-chain.ts +++ b/packages/core/src/lib/push-chain/push-chain.ts @@ -55,6 +55,14 @@ import { notAllowedInAgenticMode } from '../agentic/execution/guards'; import { adaptTrackedResponse } from '../agentic/response'; import { createAgenticRuntime, type AgenticRuntime } from '../agentic/runtime'; import type { AgenticNamespace } from '../agentic/agentic.types'; +import { MarketNamespaceImpl } from '../market/market'; +import { JobNamespaceImpl } from '../job/job'; +import type { MarketNamespace } from '../market/types'; +import type { JobNamespace } from '../job/types'; +import type { StackRuntime } from '../market/runtime'; +import { bindJobPreviewAccounts } from '../job-spec/preview'; +import { jobReadOptions, prepareJobRead } from '../job-spec/read'; +import type { ExecuteJobReadOptions, PreparedJobRead } from '../job-spec/types'; /** Options accepted by `PushChain.initialize` / `reinitialize`. */ export interface PushChainInitializeOptions { @@ -194,7 +202,8 @@ export class PushChain { * Defaults to the canonical registry on Donut. For your own application receiver, * pass callback.target, gasLimit, abi and functionName, plus an optional args mapper. */ - read: (subject: string, options: O & ValidateReadCall) => Promise>>; + read: ((subject: string, options: O & ValidateReadCall) => Promise>>) & + ((read: PreparedJobRead, options?: ExecuteJobReadOptions) => Promise>) & { prepare: typeof prepareJobRead }; /** * Build a validated `ReadSpec` for `subject` on `options.chain`: preflight (oracle * height, protocol fee, gas price), envelope encoding, budget sizing, expiry math. @@ -288,6 +297,8 @@ export class PushChain { * handles. Ownership is the connected signer's Push identity. */ agentic: AgenticNamespace; + market: MarketNamespace; + job: JobNamespace; /** @internal AGW execution context when initialized with `agenticWallet`. */ private agenticContext?: AgenticExecutionContext; @@ -330,6 +341,17 @@ export class PushChain { this.agenticRuntime = createAgenticRuntime(orchestrator, isReadMode); this.agentic = new AgenticNamespaceImpl(this.agenticRuntime); + const stackRuntime: StackRuntime = { + agw: this.agenticRuntime, + jobBlocks: new Map(), + account: () => this.universal.account as `0x${string}`, + wallet: () => this.agenticContext?.wallet, + send: (params, options) => this.universal.sendTransaction(params, options), + signTypedData: (data) => this.universal.signTypedData(data), + canSignTypedData: () => typeof this.universalSigner.signTypedData === 'function', + }; + this.market = new MarketNamespaceImpl(stackRuntime); + this.job = new JobNamespaceImpl(stackRuntime); // eslint-disable-next-line @typescript-eslint/no-this-alias -- getters below need the instance const self = this; @@ -411,14 +433,16 @@ export class PushChain { return orchestrator.rescueFunds.bind(orchestrator)(params); }, // Only read()/executeReads() send transactions; prepare/simulate/track remain read-only. - read: async (subject: string, options: O) => { - const params = await resolveReadSpecParams(subject, withReadRefund(options), orchestrator.getNetwork(), orchestrator.getRpcUrls()); + read: Object.assign(async (subject: string | PreparedJobRead, input?: ReadOptions | ExecuteJobReadOptions) => { + const options = typeof subject === 'string' ? input as ReadOptions : jobReadOptions(subject, input as ExecuteJobReadOptions); + const target = typeof subject === 'string' ? subject : subject.target; + const params = await resolveReadSpecParams(target, withReadRefund(options), orchestrator.getNetwork(), orchestrator.getRpcUrls()); assertRequestEntrypoint(params.callback, 'read'); if (this.isReadMode) throw new Error('Read only mode cannot call read function'); const prepared = await orchestrator.prepareRead(params, options.progressHook); const { reads: [response] } = await executePreparedReads(readDeps(), [prepared] as const, options); - return response as UniversalReadResponse>; - }, + return response; + }, { prepare: prepareJobRead }) as PushChain['universal']['read'], prepareRead: async (subject: string, options: O & ValidateReadCall) => { const params = await resolveReadSpecParams(subject, withReadRefund(options), orchestrator.getNetwork(), orchestrator.getRpcUrls()); return orchestrator.prepareRead(params) as Promise>>; @@ -456,6 +480,8 @@ export class PushChain { }, }; + bindJobPreviewAccounts(this.universal, async (wallet, chain) => (await this.agenticRuntime.resolveCEA(wallet, chain)).cea); + const network = orchestrator.getNetwork() === PUSH_NETWORK.MAINNET ? 'mainnet' diff --git a/packages/core/src/lib/read-state/__tests__/read-params.spec.ts b/packages/core/src/lib/read-state/__tests__/read-params.spec.ts index 6342edc1e..05659ec30 100644 --- a/packages/core/src/lib/read-state/__tests__/read-params.spec.ts +++ b/packages/core/src/lib/read-state/__tests__/read-params.spec.ts @@ -37,6 +37,12 @@ describe('read(subject, options) grammar → ReadQuery', () => { it('EVM: storageSlot', () => { expect(toReadQuery(TOKEN, { chain: CHAIN.ETHEREUM_SEPOLIA, storageSlot: 2n })).toEqual({ type: 'storageSlot', target: TOKEN, slot: 2n }); }); + it('EVM: raw callData retains the exact selector and argument bytes', () => { + expect(toReadQuery(TOKEN, { chain: CHAIN.ETHEREUM_SEPOLIA, callData: '0x18160ddd' })).toEqual({ type: 'contractCall', target: TOKEN, callData: '0x18160ddd' }); + expect(() => toReadQuery(TOKEN, { chain: CHAIN.ETHEREUM_SEPOLIA, callData: '0x123' })).toThrow(InvalidReadQueryError); + expect(() => toReadQuery(TOKEN, { chain: CHAIN.ETHEREUM_SEPOLIA, callData: '0x18160ddd', abi: ABI, functionName: 'totalSupply' })).toThrow(InvalidReadQueryError); + expect(() => toReadQuery(SOL_OWNER, { chain: CHAIN.SOLANA_DEVNET, callData: '0x18160ddd' })).toThrow(InvalidReadQueryError); + }); it('SVM: native → lamports of subject; token → the ATA derived offline', () => { expect(toReadQuery(SOL_OWNER, { chain: CHAIN.SOLANA_DEVNET })).toEqual({ type: 'lamportBalance', account: SOL_OWNER }); const q = toReadQuery(SOL_OWNER, { chain: CHAIN.SOLANA_DEVNET, token: SOL_MINT }, TOKEN_PROGRAM_ID); diff --git a/packages/core/src/lib/read-state/__tests__/read-types.spec.ts b/packages/core/src/lib/read-state/__tests__/read-types.spec.ts index 4fcbfa1fc..726ae72f8 100644 --- a/packages/core/src/lib/read-state/__tests__/read-types.spec.ts +++ b/packages/core/src/lib/read-state/__tests__/read-types.spec.ts @@ -26,6 +26,15 @@ declare const counterIdl: CounterIdl; // Compiled by ts-jest; never executed or connected to a network. async function checkTypes(client: PushChain) { + const authored = client.universal.read.prepare({ chain: CHAIN.ETHEREUM_SEPOLIA, target: address, abi, functionName: 'balanceOf', args: [address] }); + const authoredResult: UniversalReadResponse<`0x${string}`> = await client.universal.read(authored, { maxFee: 100n }); + void authoredResult; + const raw: UniversalReadResponse<`0x${string}`> = await client.universal.read(address, { chain: CHAIN.ETHEREUM_SEPOLIA, callData: '0x18160ddd' }); + // @ts-expect-error raw calls and typed calls are mutually exclusive + const mixedRaw: ReadOptions = { chain: CHAIN.ETHEREUM_SEPOLIA, callData: '0x18160ddd', abi, functionName: 'quote' }; + // @ts-expect-error raw EVM calldata cannot be used on Solana + const svmRaw: ReadOptions = { chain: CHAIN.SOLANA_DEVNET, callData: '0x18160ddd' }; + void [raw, mixedRaw, svmRaw]; // @ts-expect-error token program is inferred from the mint const removed: ReadOptions = { chain: CHAIN.SOLANA_DEVNET, token: address, tokenProgram: 'token-2022' }; const accountIdl = { address, metadata: { name: 'counter', version: '1', spec: '0.1.0' }, instructions: [], accounts: [{ name: 'counter', discriminator: [1,2,3,4,5,6,7,8] }], types: [{ name: 'counter', type: { kind: 'struct', fields: [{ name: 'count', type: 'u64' }] } }] } satisfies import('@coral-xyz/anchor').Idl; diff --git a/packages/core/src/lib/read-state/read-params.ts b/packages/core/src/lib/read-state/read-params.ts index 65b18ab3f..e1e6fca43 100644 --- a/packages/core/src/lib/read-state/read-params.ts +++ b/packages/core/src/lib/read-state/read-params.ts @@ -56,6 +56,8 @@ interface ReadOptionsInput { idl?: Idl; /** Typed contract call — encodes the call and decodes the result. */ abi?: Abi; + /** Encoded EVM view call. The result is returned as raw consensus bytes. */ + callData?: Hex; /** * With `abi`: the function to call (required). With `idl`: the account layout * (optional — inferred from the discriminator; snake_case or camelCase). @@ -94,7 +96,7 @@ interface ReadOptionsInput { }; } -type NoQuery = { token?: never; tokenProgram?: never; idl?: never; accountName?: never; abi?: never; functionName?: never; args?: never; storageSlot?: never; web2?: never }; +type NoQuery = { token?: never; tokenProgram?: never; idl?: never; accountName?: never; abi?: never; callData?: never; functionName?: never; args?: never; storageSlot?: never; web2?: never }; type Query = Omit & T; type EvmChain = Extract; type SvmChain = Extract; @@ -105,6 +107,7 @@ export type ReadQueryOptions = | ({ chain: SvmChain } & Query<'token', { token: string }>) | ({ chain: SvmChain } & Query<'idl' | 'functionName' | 'args', { idl: Idl; functionName?: string; args?: readonly unknown[] }>) | ({ chain: EvmChain } & Query<'abi' | 'functionName' | 'args', { abi: Abi; functionName: string; args?: readonly unknown[] }>) + | ({ chain: EvmChain } & Query<'callData', { callData: Hex }>) | ({ chain: EvmChain } & Query<'storageSlot', { storageSlot: Hex | bigint }>) | ({ chain: Web2ReadChain } & Query<'web2', { web2: ReadWeb2Options }>); @@ -154,7 +157,7 @@ export type ReadValue = O extends { abi: infer A extends Abi; functionName: i ? number extends A['length'] ? unknown : ReadCallValue> : O extends { web2: { extract: infer E extends readonly Web2Extract[] } } ? { readonly [K in keyof E]: Web2Value } - : O extends { storageSlot: unknown } ? Hex + : O extends { storageSlot: unknown } | { callData: unknown } ? Hex : O extends { idl: infer I extends Idl } ? IdlReadValue : bigint; @@ -180,7 +183,7 @@ export const ERC20_BALANCE_OF_ABI = [ }, ] as const satisfies Abi; -const QUERY_KEYS = ['token', 'abi', 'idl', 'storageSlot', 'web2'] as const; +const QUERY_KEYS = ['token', 'abi', 'callData', 'idl', 'storageSlot', 'web2'] as const; function queryKind(o: ReadOptionsInput): (typeof QUERY_KEYS)[number] | 'native' { if ('tokenProgram' in o) throw new InvalidReadQueryError('tokenProgram is detected from the mint; remove options.tokenProgram'); @@ -225,6 +228,10 @@ export function toReadQuery(subject: string, o: ReadOptionsInput, resolvedTokenP case 'abi': if (!o.functionName) throw new InvalidReadQueryError('abi needs functionName'); return { type: 'contractCall', target: subject, abi: o.abi as Abi, functionName: o.functionName, args: o.args }; + case 'callData': + if (!/^0x(?:[\da-f]{2}){4,}$/i.test(o.callData ?? '')) + throw new InvalidReadQueryError('callData must contain a function selector and complete bytes'); + return { type: 'contractCall', target: subject, callData: o.callData as Hex }; case 'storageSlot': return { type: 'storageSlot', target: subject, slot: o.storageSlot as Hex | bigint }; default: diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts index ca82e7baf..2707d1d7a 100644 --- a/packages/core/src/lib/utils.ts +++ b/packages/core/src/lib/utils.ts @@ -1,4 +1,6 @@ import { agenticPresets } from './agentic/presets'; +import { jobSpecUtils } from './job-spec'; +import { validateAgentCardRules, encodeAgentCardRules, decodeAgentCardRules } from './market/rules'; import { convertOriginToExecutor, fromChainAgnostic, @@ -874,6 +876,8 @@ export class Utils { /** Pure preset discovery/templates; internal encoders and identifiers stay private. */ static readonly agentic = Object.freeze({ presets: agenticPresets }); + static readonly job = jobSpecUtils; + static readonly market = Object.freeze({ validateAgentCardRules, encodeAgentCardRules, decodeAgentCardRules }); /** * Internal: resolves a CHAIN enum from either a CHAIN value or a PushChain client instance. diff --git a/packages/core/tsconfig.marketplace-local.json b/packages/core/tsconfig.marketplace-local.json new file mode 100644 index 000000000..023b9ecdb --- /dev/null +++ b/packages/core/tsconfig.marketplace-local.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "module": "commonjs", "types": ["jest", "node"] }, + "include": ["__market-local__/**/*.ts", "src/**/*.d.ts"] +} From 561b743ed85730ca4f4c57047e36aaafcf6df868 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Tue, 20 Oct 2026 19:31:05 +0530 Subject: [PATCH 20/27] fix(core): remove preset funding mode and preserve asset limits --- packages/core/AGW.md | 15 +++--- .../core/__agw-local__/v5-multiasset.spec.ts | 9 ++-- .../core/__e2e__/agw/presets-live.spec.ts | 16 +++--- packages/core/scripts/check-agw-package.js | 29 ++++++++--- .../core/scripts/check-agw-preset-routes.js | 19 ++++--- .../agentic/__tests__/creation-errors.spec.ts | 4 +- .../__tests__/preset-operation-limits.spec.ts | 3 +- .../agentic/__tests__/preset-routes.spec.ts | 49 +++++++------------ .../core/src/lib/agentic/error-messages.ts | 2 +- packages/core/src/lib/agentic/errors.ts | 5 +- .../core/src/lib/agentic/presets/index.ts | 13 +++-- .../core/src/lib/agentic/presets/routes.ts | 14 +++--- .../core/src/lib/agentic/presets/types.ts | 7 +-- 13 files changed, 86 insertions(+), 99 deletions(-) diff --git a/packages/core/AGW.md b/packages/core/AGW.md index e5a0e18bc..afc1550a7 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -132,11 +132,11 @@ function walletErrorText(error: unknown): string { Creation and rule-preparation errors include a zero-based `details.ruleIndex` when a rule can be identified, with `assetIndex`, `callIndex` and `field` where available. The UI message uses a one-based rule number. Use these details to show the error on the relevant rule instead of only in a footer. Read/receipt failures preserve their causes and transaction hashes; do not automatically retry a submitted transaction whose outcome is unknown. -`TOKEN_ROUTE_UNAVAILABLE` means an asset cannot be resolved for transfers from Push. It is not a token-symbol formatting request. Choose a supported source token, or regenerate the preset with `funding: 'destination'` when using assets already held by the destination account. The wallet can be created before funding; destination balances are needed before execution. Pass returned `rules` unchanged. **Do not copy `readiness.chains[].selectedAssets` back into `rules[].assets` in destination-funded mode**: selectedAssets is review metadata and doing so reintroduces unsupported bridge requirements. +`TOKEN_ROUTE_UNAVAILABLE` means an asset cannot be resolved for transfers from Push. Choose a token with a supported route on this network, or wait for a verified PRC20 route for that exact token address. Changing a symbol does not create a route. Display `error.userMessage` and check preset readiness before creation. A DEX condition-limit failure reports `details.reason = 'RULE_CONSTRAINT_LIMIT'`, `actual`, `limit` and `callCount`. Conditions here are generated input checks (for example token, recipient and amount checks), rather than the number of visible function names. A stock two-token, single-router preset contains six allowed-call entries and twenty conditions. Appending the same call list again makes forty conditions. The SDK rejects this; it does not deduplicate, remove conditions or split authority automatically. Preserve per-chain rules and regenerate after edits rather than appending generated calls. -For the review screen, show the actual network (including testnet name) and funding mode. In destination-funded mode, selectedAssets.maxTotal is not an enforced destination lifetime limit; the grant has no Push token-export budget. Label maxGasPerCall as a maximum PC gas budget, not a guaranteed fee or execution gas-unit count. +For the review screen, show the actual network (including testnet name), selected token addresses and limits. Label maxGasPerCall as a maximum PC gas budget, not a guaranteed fee or execution gas-unit count. maxTotal meters cumulative Push outbounds; it is not a lifetime destination-spend limit. ## Solana destinations @@ -207,7 +207,7 @@ const { rules: reviewTemplates } = PushChain.utils.agentic.presets.get( // Supply the agent to get complete Rule[] configuration. const configuredPreset = PushChain.utils.agentic.presets.get( PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.LENDING, - { chainNamespace: 'eip155:11155111', agent: '0x0000000000000000000000000000000000001234', funding: 'destination' }, + { chainNamespace: 'eip155:11155111', agent: '0x0000000000000000000000000000000000001234' }, ); // Destination assets must already be funded; no Push token exports are granted. // Show configuredPreset.readiness.chains[0].selectedAssets in the review UI. @@ -219,7 +219,6 @@ function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, { chainNamespace: 'eip155:11155111', - funding: 'destination', agent, validUntil: Math.floor(Date.now() / 1000) + 3600, maxGasPerCall: BigInt(10) ** BigInt(18), @@ -257,13 +256,13 @@ These are small, protocol-specific subsets chosen to fit the 32-call/32-pin ceil | Arbitrum Sepolia | WETH / Circle USDC | Aave USDC | | BSC testnet | WBNB / Venus USDT | Venus USDT, USDC | -Staking defaults are ETH/stETH/wstETH on Ethereum and the reviewed EigenLayer strategy's WETH on Sepolia. Testnet tokens with similar names can have different addresses; the returned addresses are authoritative. These tokens may lack Push PRC20 routes and can instead be used with destination-held balances. Defaults do not guarantee active reserves, liquidity, a Push PRC20 route or execution. In particular, an unmodified preset can fail rule preparation if its default assets lack PRC20 mappings. For destination-only use, pass `funding: 'destination'`: the SDK generates the operation constraints, returns empty rule bridge assets and retains selected token limits in readiness metadata. This grants no token export budget from Push and requires already-funded destination balances. Token addresses are returned as before; metadata was verified when defining the policy. No runtime metadata/price fetch is added to get. +Staking defaults are ETH/stETH/wstETH on Ethereum and the reviewed EigenLayer strategy's WETH on Sepolia. Testnet tokens with similar names can have different addresses; the returned addresses are authoritative. Defaults do not guarantee active reserves, liquidity or a Push PRC20 route. An unmodified preset cannot be granted when its assets lack matching PRC20 routes. The SDK preserves the selected tokens and caps and reports unavailable routes; it never removes assets to bypass this check. No runtime metadata/price fetch is added to get. ### Funding and release-network checks -`funding` defaults to `'push'`. Missing PRC20 registry entries produce `BRIDGE_ASSET_UNAVAILABLE`, and readiness is false while the returned rule retains all requested assets. No address or cap is silently replaced. `network` defaults to `TESTNET_DONUT` (`TESTNET` is accepted as an alias); use the connected client's Push network when supplying it. Networks without a verified AGW deployment report `AGW_NETWORK_UNAVAILABLE`. +Missing PRC20 registry entries produce `BRIDGE_ASSET_UNAVAILABLE`, and readiness is false while the returned rule retains all requested assets. No address or cap is silently replaced. `network` defaults to `TESTNET_DONUT` (`TESTNET` is accepted as an alias). Networks without a verified AGW deployment report `AGW_NETWORK_UNAVAILABLE`. -Use `funding: 'destination'` for the prefunded CEA workflow. Selected tokens still generate the same target/function/amount/recipient/spender restrictions, exposed alongside their caps in `readiness.chains[].selectedAssets`. Returned `rule.assets` is intentionally empty, which grants zero Push token-export authority. It is safe to pass these returned rules directly to create/update. The mode does not fund the destination, wrap ETH, mint testnet tokens or enable an unsupported network. Do not relabel it as bridge funding in the UI. +The public preset API has no `funding` option. Remove it from existing integrations; supplying it now produces `INVALID_RULE` rather than silently changing permissions. Internal prefunded CEA test fixtures explicitly clear bridge assets to isolate protocol constraints. Those fixtures do not demonstrate that an unsupported preset route is executable. Existing mappings distinguish token identity by chain and address, including address casing for EVM. WETH/stETH Sepolia mappings and string/address-word source metadata are supported; mainnet or other-chain WETH is never aliased to them. Protocol mock LINK, Moonwell/Venus mocks and Circle testnet USDC can differ from the supported source tokens; they remain unavailable for bridge funding until real matching routes exist. @@ -288,7 +287,7 @@ Explicit `validUntil` and `maxGasPerCall` override policy values; zero gas is pr ### Configure, inspect and regenerate -`readiness.ready` requires complete configuration plus offline SDK deployment/destination/bridge-registry support for the selected funding mode. Per-chain `configurationReady` reports input completeness separately. These checks do not verify live balances, reserve flags, liquidity, current deployed bytecode or source identity; grant preparation still verifies wiring and token metadata on-chain before signing. `readiness.chains` gives per-chain `issues`, `disabledCalls`, `excludedCalls`, catalog `knownTokens`, `selectedAssets`, `bridgeAssets`, `funding` and `zeroBridgeLimits`. Display `selectedAssets` as the reviewed token/amount permissions in either mode; `rule.assets` describes Push bridge permissions only. `bridgeAssets` reports registry presence, not verified live deployment. Mainnet templates remain discoverable but have `DESTINATION_UNAVAILABLE` on Donut. These are additive result fields; `rules` remains accepted by the existing create/update methods. +`readiness.ready` requires complete configuration plus offline SDK deployment, destination and bridge-registry support. Per-chain `configurationReady` reports input completeness separately. These checks do not verify live balances, reserve flags, liquidity or deployed bytecode. Grant preparation verifies wiring and token metadata on-chain before signing. Per-chain `selectedAssets` mirrors the selected rule assets; `bridgeAssets` reports registry presence, not verified live deployment. Mainnet templates remain discoverable but report `DESTINATION_UNAVAILABLE` on Donut. Check readiness before passing `rules` to create/update. Tokens and permission defaults come directly from the SDK: omitted `assets` populates the curated subset and its caps in every returned `rule.assets`. Omitted gas and expiry use the policy below. The caller supplies the agent and can filter chains; no per-network token selection is required. Show the generated permissions for owner review and allow explicit overrides. Regenerate via get after an edit so operation pins/approvals match the new caps. Show explicitly zero-cap calls as disabled and present excluded calls clearly. A zero bridge limit can be intentional when assets are already at the destination; it is reported separately from operation readiness. diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 394301edd..193a7e89f 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -358,19 +358,18 @@ describe('v6 multi-asset SDK management and execution', () => { ); it.each(['DEXES', 'LENDING', 'STAKING'] as const)( - 'public %s destination-funded defaults create directly without rewriting returned rules', + '%s constraints work in an explicit prefunded contract fixture', async (category) => { const generated = agenticPresets.get(category, { chainNamespace: CHAIN_NS, agent: h.wallets[1].account!.address, - funding: 'destination', }); - expect(generated.readiness.ready).toBe(true); + expect(generated.readiness.chains[0].configurationReady).toBe(true); expect( generated.readiness.chains[0].selectedAssets.length ).toBeGreaterThan(0); - expect(generated.rules[0].assets).toEqual([]); - const { wallet, id } = await setup(generated.rules[0]); + expect(generated.rules[0].assets!.length).toBeGreaterThan(0); + const { wallet, id } = await setup({ ...generated.rules[0], assets: [] }); const actual = await stored(wallet, id); expect(actual.assets).toEqual([ { diff --git a/packages/core/__e2e__/agw/presets-live.spec.ts b/packages/core/__e2e__/agw/presets-live.spec.ts index 48fab87ed..ae685f1ff 100644 --- a/packages/core/__e2e__/agw/presets-live.spec.ts +++ b/packages/core/__e2e__/agw/presets-live.spec.ts @@ -76,9 +76,8 @@ d('agw presets live', () => { const defaults = PushChain.utils.agentic.presets.get('DEXES', { chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: f.agentAddress, - funding: 'destination', }); - expect(defaults.readiness.ready).toBe(true); + expect(defaults.readiness.chains[0].configurationReady).toBe(true); expect(defaults.rules[0].maxGasPerCall).toBe(MAX_PC); expect(defaults.rules[0].validUntil).toBeGreaterThan(inSeconds(6 * 86400)); expect( @@ -92,7 +91,6 @@ d('agw presets live', () => { ) ).toBe(true); const { rules } = PushChain.utils.agentic.presets.get('DEXES', { - funding: 'destination', chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: f.agentAddress, validUntil: inSeconds(3600), @@ -108,9 +106,10 @@ d('agw presets live', () => { }); // assets[] budgets bridge burns on Push. This test spends already-held CEA // tokens, so omit bridge assets without changing any generated call or pin. + // Prefunded CEA fixture: bypass bridge budgets only in this protocol test. const { wallet, rulesIds } = await f.owner.agentic.create( 'e2e-preset-uniswap', - { rules } + { rules: rules.map((rule) => ({ ...rule, assets: [] })) } ); const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) .cea as Address; @@ -290,9 +289,8 @@ d('agw presets live', () => { const defaults = PushChain.utils.agentic.presets.get('LENDING', { chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: f.agentAddress, - funding: 'destination', }); - expect(defaults.readiness.ready).toBe(true); + expect(defaults.readiness.chains[0].configurationReady).toBe(true); expect(defaults.readiness.chains[0].selectedAssets[0].maxPerCall).toBe( parseEther('1') ); @@ -303,7 +301,6 @@ d('agw presets live', () => { ) ).toEqual([mockLINK.toLowerCase()]); const { rules } = PushChain.utils.agentic.presets.get('LENDING', { - funding: 'destination', chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: f.agentAddress, validUntil: inSeconds(3600), @@ -314,9 +311,10 @@ d('agw presets live', () => { maxTotal: amount, })), }); + // Prefunded CEA fixture: bypass bridge budgets only in this protocol test. const { wallet, rulesIds } = await f.owner.agentic.create( 'e2e-preset-aave', - { rules } + { rules: rules.map((rule) => ({ ...rule, assets: [] })) } ); const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) .cea as Address; @@ -455,7 +453,7 @@ d('agw presets live', () => { maxGasPerCall: MAX_PC, assets: [{ token: mockLINK, maxPerCall: half, maxTotal: amount }], }); - expect(regenerated.readiness.ready).toBe(true); + expect(regenerated.readiness.chains[0].configurationReady).toBe(true); const changed = await f.owner.agentic.wallet(wallet).rules.update({ rules: [ { diff --git a/packages/core/scripts/check-agw-package.js b/packages/core/scripts/check-agw-package.js index 453042a38..99267d60d 100644 --- a/packages/core/scripts/check-agw-package.js +++ b/packages/core/scripts/check-agw-package.js @@ -24,20 +24,27 @@ async function main() { assert(pack.files.some((f) => f.path === 'AGW.md')); assert(pack.files.some((f) => f.path === 'MARKETPLACE.md')); assert.equal(typeof cjs.PushChain.utils.job.buildJobSpec, 'function'); - assert.equal(typeof cjs.PushChain.utils.market.encodeAgentCardRules, 'function'); + assert.equal( + typeof cjs.PushChain.utils.market.encodeAgentCardRules, + 'function' + ); assert(!pack.files.some((f) => /(^|\/)\.env($|\.)/.test(f.path))); assert( !pack.files.some((f) => - /(^|\/)(plan|__e2e__|__agw-local__|__market-local__|scripts)(\/|$)/.test(f.path) + /(^|\/)(plan|__e2e__|__agw-local__|__market-local__|scripts)(\/|$)/.test( + f.path + ) ) ); const preset = cjs.PushChain.utils.agentic.presets.get('LENDING', { agent: '0x0000000000000000000000000000000000005678', chainNamespace: 'eip155:11155111', - funding: 'destination', }); - assert.equal(preset.readiness.ready, true); - assert.deepEqual(preset.rules[0].assets, []); + assert.equal(preset.readiness.ready, false); + assert.deepEqual( + preset.rules[0].assets, + preset.readiness.chains[0].selectedAssets + ); assert(preset.readiness.chains[0].selectedAssets.length > 0); assert.equal( cjs.PushChain.utils.agentic.presets.get('LENDING', { @@ -57,8 +64,16 @@ async function main() { const file = path.join(tmp, 'consumer.ts'); fs.writeFileSync(file, source); const marketFile = path.join(tmp, 'market-consumer.ts'); - const marketGuide = fs.readFileSync(path.join(built, 'MARKETPLACE.md'), 'utf8'); - fs.writeFileSync(marketFile, [...marketGuide.matchAll(/```ts\n([\s\S]*?)```/g)].map(m => m[1]).join('\n\n')); + const marketGuide = fs.readFileSync( + path.join(built, 'MARKETPLACE.md'), + 'utf8' + ); + fs.writeFileSync( + marketFile, + [...marketGuide.matchAll(/```ts\n([\s\S]*?)```/g)] + .map((m) => m[1]) + .join('\n\n') + ); const config = ts.readConfigFile( path.join(root, 'packages/core/tsconfig.lib.json'), ts.sys.readFile diff --git a/packages/core/scripts/check-agw-preset-routes.js b/packages/core/scripts/check-agw-preset-routes.js index ff524ea3c..53bf5ba2c 100644 --- a/packages/core/scripts/check-agw-preset-routes.js +++ b/packages/core/scripts/check-agw-preset-routes.js @@ -124,19 +124,18 @@ async function main() { chainNamespace, }); result.pushReadiness = push.readiness; - const destination = PushChain.utils.agentic.presets.get(category, { - agent, - chainNamespace, - funding: 'destination', - }); - assert.equal(destination.readiness.ready, true); - assert.deepEqual(destination.rules[0].assets, []); + // Explicit prefunded fixture, not public preset acceptance or bridge proof. + assert.equal(push.readiness.chains[0].configurationReady, true); + const fixtureRules = push.rules.map((rule) => ({ + ...rule, + assets: [], + })); const prepared = await prepareWalletRules( runtime, gen, owner, wallet, - publicRules(destination.rules), + publicRules(fixtureRules), snap ); const data = gen.contracts.encodeCreateWalletAt( @@ -154,8 +153,8 @@ async function main() { gas: BigInt(20000000), }); assert(simulated.data && simulated.data !== '0x'); - result.destinationFactorySimulation = 'passed'; - result.selectedAssets = destination.readiness.chains[0].selectedAssets; + result.prefundedFixtureFactorySimulation = 'passed'; + result.selectedAssets = push.readiness.chains[0].selectedAssets; // If static registry support is advertised, verify preparation against real metadata too. if (push.readiness.ready) { await prepareWalletRules( diff --git a/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts index 4a80316b5..1f4538dc6 100644 --- a/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts @@ -55,7 +55,7 @@ it.each(['LENDING', 'STAKING'] as const)( userMessage: expect.stringContaining( 'Rule 1: This token cannot be sent from Push to Ethereum Sepolia' ), - hint: expect.stringContaining("funding: 'destination'"), + hint: expect.stringContaining('verified PRC20 route'), }); expect(rt.executeMock).not.toHaveBeenCalled(); expect(rt.atomicMock).not.toHaveBeenCalled(); @@ -67,7 +67,6 @@ it('reports the complete 40-condition count for duplicated DEX entries, without const sdk = new AgenticNamespaceImpl(rt); const { rules } = agenticPresets.get('DEXES', { ...options, - funding: 'destination', }); const calls = rules[0].allowedCalls; const duplicated = { ...rules[0], allowedCalls: [...calls, ...calls] }; @@ -96,7 +95,6 @@ it('validates stock DEX rules independently: two 20-condition rules do not becom const { rules } = agenticPresets.get('DEXES', { ...options, chainNamespace: chain, - funding: 'destination', }); const normalized = (await normalizePublicRule( publicRules(rules)[0], diff --git a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts index caa42aa34..d10a4c0a6 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts @@ -68,8 +68,7 @@ it('default lending policy needs only the agent to become locally ready', () => agenticPresets.get('LENDING', { chainNamespace, agent: ADDR.agent, - funding: 'destination', - }).readiness.ready + }).readiness.chains[0].configurationReady ).toBe(true); }); it('zero operation caps remain valid disabled configurations, while zero bridge totals are separately reported', () => { diff --git a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts index 077b23511..5b8ca40b5 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts @@ -9,7 +9,6 @@ it('reports unmapped source tokens without silently changing the default Push gr expect(res.readiness.ready).toBe(false); const state = res.readiness.chains[0]; expect(state.configurationReady).toBe(true); - expect(state.funding).toBe('push'); expect(state.issues).toContainEqual( expect.objectContaining({ code: 'BRIDGE_ASSET_UNAVAILABLE', @@ -19,26 +18,23 @@ it('reports unmapped source tokens without silently changing the default Push gr expect(res.rules[0].assets).toEqual(state.selectedAssets); expect(res.rules[0].assets).toHaveLength(1); }); -it('supports the explicit destination-funded grant with preserved token/amount pins and review metadata', () => { - const push = agenticPresets.get('DEXES', options); - const dest = agenticPresets.get('DEXES', { - ...options, - funding: 'destination', - }); - expect(push.readiness.ready).toBe(false); - expect(dest.readiness.ready).toBe(true); - expect(dest.rules[0].assets).toEqual([]); - expect(dest.readiness.chains[0].selectedAssets).toEqual(push.rules[0].assets); - expect(dest.rules[0].allowedCalls).toEqual(push.rules[0].allowedCalls); - expect(dest.readiness.chains[0].bridgeAssets.map((a) => a.status)).toEqual([ - 'registered', - 'unavailable', - ]); - const returned = dest.readiness.chains[0].selectedAssets[0]; - returned.maxPerCall = BigInt(0); - expect( - agenticPresets.get('DEXES', options).rules[0].assets?.[0].maxPerCall - ).toBeGreaterThan(BigInt(0)); +it('preserves assets and rejects the removed funding option instead of silently changing permissions', () => { + const result = agenticPresets.get('DEXES', options); + expect(result.readiness.ready).toBe(false); + expect(result.rules[0].assets).toEqual( + result.readiness.chains[0].selectedAssets + ); + result.readiness.chains[0].selectedAssets[0].maxPerCall = BigInt(0); + expect(result.rules[0].assets?.[0].maxPerCall).toBeGreaterThan(BigInt(0)); + for (const funding of ['push', 'destination', 'auto']) { + expect(() => + agenticPresets.get('DEXES', { + ...options, + // @ts-expect-error funding is not part of the public preset API + funding, + }) + ).toThrow(/funding option is no longer supported/); + } }); it('an explicitly selected mapped source token can be ready for Push funding', () => { const token = MOVEABLE_TOKEN_CONSTANTS.BNB_TESTNET.USDT; @@ -63,7 +59,6 @@ it('never treats a mainnet template or undeployed Push generation as executable' const mainnet = agenticPresets.get('DEXES', { agent: ADDR.agent, chainNamespace: CHAIN.ETHEREUM_MAINNET, - funding: 'destination', }); expect(mainnet.readiness.ready).toBe(false); expect(mainnet.readiness.chains[0].issues).toContainEqual( @@ -71,7 +66,6 @@ it('never treats a mainnet template or undeployed Push generation as executable' ); const missing = agenticPresets.get('LENDING', { ...options, - funding: 'destination', network: PUSH_NETWORK.MAINNET, }); expect(missing.readiness.ready).toBe(false); @@ -83,19 +77,14 @@ it('supports the TESTNET alias, rejects invalid modes and preserves empty select expect( agenticPresets.get('LENDING', { ...options, - funding: 'destination', network: PUSH_NETWORK.TESTNET, - }).readiness.ready - ).toBe(true); - expect(() => - agenticPresets.get('LENDING', { ...options, funding: 'auto' as never }) - ).toThrow(/funding/); + }).readiness + ).toEqual(agenticPresets.get('LENDING', options).readiness); expect(() => agenticPresets.get('LENDING', { ...options, network: 'OTHER' as never }) ).toThrow(/network/); const empty = agenticPresets.get('LENDING', { ...options, - funding: 'destination', assets: [], }); expect(empty.readiness.chains[0].selectedAssets).toEqual([]); diff --git a/packages/core/src/lib/agentic/error-messages.ts b/packages/core/src/lib/agentic/error-messages.ts index 88722c604..6202aae7b 100644 --- a/packages/core/src/lib/agentic/error-messages.ts +++ b/packages/core/src/lib/agentic/error-messages.ts @@ -12,7 +12,7 @@ const MESSAGES: Record = { ASSET_CHAIN_MISMATCH: 'The selected token belongs to a different network. Choose a token on this rule’s network.', TOKEN_ROUTE_UNAVAILABLE: - 'This token cannot currently be transferred from Push to the selected network. Choose a supported token or use funds already on the destination network.', + 'This token cannot currently be transferred from Push to the selected network. Choose a token with a supported Push route.', REQUEST_REJECTED: 'The wallet request was cancelled. Check wallet activity before trying again.', NOT_OWNER_OR_AGENT: diff --git a/packages/core/src/lib/agentic/errors.ts b/packages/core/src/lib/agentic/errors.ts index 5aac40335..3a739c224 100644 --- a/packages/core/src/lib/agentic/errors.ts +++ b/packages/core/src/lib/agentic/errors.ts @@ -178,16 +178,15 @@ export function tokenRouteUnavailable( (chainNamespace.startsWith('solana:') ? 'the selected Solana network' : 'the selected network'); - const message = `This token cannot be sent from Push to ${network}. Choose a supported token, or use destination funding for balances held on that network.`; + const message = `This token cannot be sent from Push to ${network}. Choose a token with a supported Push route.`; return new AgenticError(AGENTIC_ERROR_CODE.TOKEN_ROUTE_UNAVAILABLE, message, { userMessage: message, - hint: "For prefunded preset destinations, use funding: 'destination' and pass the returned rules unchanged. selectedAssets is review metadata; do not copy it back into rule.assets.", + hint: 'Choose a supported token on this network, or wait for a verified PRC20 route for this exact token address. Token symbols alone do not identify a route.', details: { token, chainNamespace, assetIndex, field: `assets[${assetIndex}].token`, - funding: 'push', }, cause, }); diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index 84b7f84d3..160dd9b17 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -119,11 +119,12 @@ export function buildPresetApi( throw invalid('preset options must be an object'); const now = Math.floor(Date.now() / 1000); const network = options.network ?? PUSH_NETWORK.TESTNET_DONUT; - const funding = options.funding ?? 'push'; if (!Object.values(PUSH_NETWORK).includes(network)) throw invalid('unknown preset Push network'); - if (funding !== 'push' && funding !== 'destination') - throw invalid('preset funding must be push or destination'); + if ('funding' in options) + throw invalid( + 'The preset funding option is no longer supported. Remove it; preset assets and limits are always preserved.' + ); if ( options.agent !== undefined && (!isAddress(options.agent, { strict: false }) || @@ -426,16 +427,14 @@ export function buildPresetApi( const routes = presetRoutes( chain.chainNamespace, selectedAssets, - network, - funding + network ); issues.push(...routes.issues); - const bridgeAssets = funding === 'destination' ? [] : selectedAssets; + const bridgeAssets = selectedAssets; readiness.push({ chainNamespace: chain.chainNamespace, ready: issues.length === 0, configurationReady, - funding, selectedAssets: selectedAssets.map((a) => ({ ...a, token: typeof a.token === 'string' ? a.token : { ...a.token }, diff --git a/packages/core/src/lib/agentic/presets/routes.ts b/packages/core/src/lib/agentic/presets/routes.ts index fc0babf40..8146d138b 100644 --- a/packages/core/src/lib/agentic/presets/routes.ts +++ b/packages/core/src/lib/agentic/presets/routes.ts @@ -12,8 +12,7 @@ import { assetAddress } from './operation-limits'; export function presetRoutes( chain: string, assets: RuleAsset[], - network: PUSH_NETWORK, - funding: 'push' | 'destination' + network: PUSH_NETWORK ): { bridgeAssets: PresetChainReadiness['bridgeAssets']; issues: PresetConfigurationIssue[]; @@ -43,12 +42,11 @@ export function presetRoutes( status: 'registered' as const, }; } catch { - if (funding === 'push') - issues.push({ - code: 'BRIDGE_ASSET_UNAVAILABLE', - token, - message: `No SDK PRC20 mapping for ${chain}:${token} on ${network}. Use a supported source asset or explicitly choose destination funding for assets already at the CEA.`, - }); + issues.push({ + code: 'BRIDGE_ASSET_UNAVAILABLE', + token, + message: `No SDK PRC20 mapping for ${chain}:${token} on ${network}. Choose a supported asset. This exact token needs a verified Push PRC20 route before it can be used.`, + }); return { token, status: 'unavailable' as const }; } }); diff --git a/packages/core/src/lib/agentic/presets/types.ts b/packages/core/src/lib/agentic/presets/types.ts index f014991cf..0e2983e86 100644 --- a/packages/core/src/lib/agentic/presets/types.ts +++ b/packages/core/src/lib/agentic/presets/types.ts @@ -31,10 +31,6 @@ export interface PresetAsset { export interface PresetOptions { /** Offline route/deployment checks; defaults to TESTNET_DONUT. */ network?: PUSH_NETWORK; - /** Push funding requires PRC20 routes. Destination funding explicitly emits - * empty bridge assets while preserving selected token/call limits in metadata. - * The CEA must already hold the required destination assets. Default: push. */ - funding?: 'push' | 'destination'; /** Push-PC budget ceiling per outbound; omitted uses the catalog policy (20 PC). * Explicit zero is preserved. This is not the fee charged. */ maxGasPerCall?: bigint; @@ -84,8 +80,7 @@ export interface PresetChainReadiness { chainNamespace: ForeignChainNamespace; ready: boolean; configurationReady: boolean; - funding: 'push' | 'destination'; - /** Selected destination permissions, retained even when bridge assets is empty. */ + /** Selected assets and caps, matching the returned rule assets. */ selectedAssets: RuleAsset[]; /** Registry presence only; grant preparation verifies exact on-chain identity. */ bridgeAssets: { From 4600566192bbe2b6673b6b3d503ae69ee184f1b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 20 Oct 2026 14:14:05 +0000 Subject: [PATCH 21/27] release(core): bump to 6.0.27-alpha.4 [skip ci] --- packages/core/CHANGELOG.md | 7 +++++++ packages/core/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 24c9100d9..c8395c47d 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,10 @@ +@pushchain/core@6.0.27-alpha.4 (2026-10-20) + +- fix: remove preset funding mode and preserve asset limits +- feat: sync v4 SDK changes without plan files + +--- + @pushchain/core@6.0.27-alpha.3 (2026-10-18) - feat: ship bounded preset defaults with funding checks diff --git a/packages/core/package.json b/packages/core/package.json index 24da34c1a..9586871d9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.27-alpha.3", + "version": "6.0.27-alpha.4", "homepage": "https://push.org", "keywords": [ "pushchain", From 71d556b60f987f8579ef4d639034b86e8dbbb90f Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Tue, 20 Oct 2026 20:17:20 +0530 Subject: [PATCH 22/27] fix(core): clarify agentic creation permissions and route guidance --- packages/core/AGW.md | 8 +++ .../agentic/__tests__/creation-errors.spec.ts | 22 ++++++++ .../agentic/__tests__/preset-routes.spec.ts | 54 +++++++++++++++++++ .../core/src/lib/agentic/error-messages.ts | 2 + packages/core/src/lib/agentic/errors.ts | 2 +- .../core/src/lib/agentic/presets/index.ts | 2 +- .../core/src/lib/agentic/presets/routes.ts | 2 +- packages/core/src/lib/agentic/public-rule.ts | 11 ++++ 8 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/core/AGW.md b/packages/core/AGW.md index afc1550a7..0b6d00bb7 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -353,3 +353,11 @@ Uniswap uses destination WETH/Circle USDC; Aave uses its faucet LINK. Their gene The additional opt-in group `agw-presets-expanded` covers EigenLayer deposits on Sepolia; Uniswap swaps on Base/Arbitrum Sepolia; PancakeSwap plus Venus on BNB testnet; and Aave plus Moonwell on Base Sepolia. Use the same preflight/run commands with `--group agw-presets-expanded`. Each scenario funds a fresh wallet with at most 100 PC and checks any required destination-native PRC20 before transfer. Native-wrap cases use 10^12 raw native-token units. These are deposit/swap/redemption checks, not borrow/repay or every-market coverage. AGW token resolution uses the destination chain for ordinary token objects: an explicit Base or Arbitrum ETH token must not select Sepolia's PRC20 merely because its symbol/address is identical. An explicit Push PRC20 reference must name the same source chain as the destination; a mismatch fails before signing. + +### Creation UI: apps, tokens and preset availability + +A rule requires at least one allowed function, even when tokens and limits are selected. A tokens-only rule returns `INVALID_RULE`, with `details.reason = 'ALLOWED_FUNCTION_REQUIRED'` and actionable `userMessage`. App selection can only be hidden if the application explicitly supplies the intended function permissions; the SDK does not invent transfer recipients or permissions. + +`assets: []` remains valid for a universal rule with allowed calls. It grants no token transfers from Push; it never means all tokens. Allowed calls may still interact with destination-held assets under their configured constraints. There is no all-token wildcard. List each permitted token and cap explicitly (at most eight assets). + +For presets, check `readiness.ready` before enabling Create. Display the per-chain issues and the affected token address. Empty selections explain that transfers from Push are disabled. `BRIDGE_ASSET_UNAVAILABLE` identifies an unverified SDK route, not proof that no deployment exists anywhere. Do not substitute a routed token solely because its symbol matches: the protocol must support that exact address, and swaps need compatible liquidity. A transfer-only helper and additional token routes are not included in this update. diff --git a/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts index 1f4538dc6..c7f4d0520 100644 --- a/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts @@ -202,3 +202,25 @@ it.each([ ).toContain(text); } ); + +it.each([undefined, []])( + 'rejects a tokens-only rule with actionable guidance before signing (%j)', + async (allowedCalls) => { + const rt = mockRuntime(new FakeChain(), { signer: ADDR.owner }); + const sdk = new AgenticNamespaceImpl(rt); + const { rules } = agenticPresets.get('LENDING', options); + await expect( + sdk.create('Tokens only', { + rules: [{ ...rules[0], allowedCalls } as never], + }) + ).rejects.toMatchObject({ + code: 'INVALID_RULE', + details: { reason: 'ALLOWED_FUNCTION_REQUIRED', field: 'allowedCalls' }, + userMessage: expect.stringContaining( + 'Selecting tokens and limits alone does not authorize transfers' + ), + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + expect(rt.atomicMock).not.toHaveBeenCalled(); + } +); diff --git a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts index 5b8ca40b5..789ea6ddd 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts @@ -90,3 +90,57 @@ it('supports the TESTNET alias, rejects invalid modes and preserves empty select expect(empty.readiness.chains[0].selectedAssets).toEqual([]); expect(empty.readiness.ready).toBe(false); }); + +it('explains empty tokens without expanding them into unrestricted permissions', () => { + const res = agenticPresets.get('LENDING', { ...options, assets: [] }); + expect(res.rules[0].assets).toEqual([]); + expect(res.readiness.ready).toBe(false); + expect(res.readiness.chains[0].issues).toContainEqual( + expect.objectContaining({ + code: 'ASSETS_REQUIRED', + message: expect.stringContaining( + 'transfers from Push are disabled, not unrestricted' + ), + }) + ); +}); +it('reports exact unavailable assets across all nine testnet preset combinations', () => { + const chains = [ + CHAIN.ETHEREUM_SEPOLIA, + CHAIN.BASE_SEPOLIA, + CHAIN.ARBITRUM_SEPOLIA, + CHAIN.BNB_TESTNET, + ]; + let checked = 0; + for (const category of ['DEXES', 'LENDING', 'STAKING'] as const) { + const entry = agenticPresets + .list() + .presets.find((p) => p.constant === category)!; + for (const chainNamespace of entry.chains.filter((c) => + chains.includes(c as never) + )) { + const res = agenticPresets.get(category, { + agent: ADDR.agent, + chainNamespace, + }); + expect(res.readiness.ready).toBe(false); + const state = res.readiness.chains[0]; + expect(res.rules[0].assets).toEqual(state.selectedAssets); + for (const asset of state.bridgeAssets.filter( + (a) => a.status === 'unavailable' + )) { + expect(state.issues).toContainEqual( + expect.objectContaining({ + code: 'BRIDGE_ASSET_UNAVAILABLE', + token: asset.token, + message: expect.stringContaining( + 'only if the selected app supports that exact token address' + ), + }) + ); + } + checked++; + } + } + expect(checked).toBe(9); +}); diff --git a/packages/core/src/lib/agentic/error-messages.ts b/packages/core/src/lib/agentic/error-messages.ts index 6202aae7b..8c4deb9ff 100644 --- a/packages/core/src/lib/agentic/error-messages.ts +++ b/packages/core/src/lib/agentic/error-messages.ts @@ -66,6 +66,8 @@ export function agenticUserMessage( return 'This preset is unavailable for the selected network.'; } if (code !== 'INVALID_RULE') return MESSAGES[code]; + if (details?.['reason'] === 'ALLOWED_FUNCTION_REQUIRED') + return 'Add at least one allowed function. Selecting tokens and limits alone does not authorize transfers.'; if (/validUntil|expiry|expired|future.*timestamp/i.test(diagnostic)) return 'Choose an expiry date in the future.'; if (/label.*(64|length|most)/i.test(diagnostic)) diff --git a/packages/core/src/lib/agentic/errors.ts b/packages/core/src/lib/agentic/errors.ts index 3a739c224..41f66c0b5 100644 --- a/packages/core/src/lib/agentic/errors.ts +++ b/packages/core/src/lib/agentic/errors.ts @@ -181,7 +181,7 @@ export function tokenRouteUnavailable( const message = `This token cannot be sent from Push to ${network}. Choose a token with a supported Push route.`; return new AgenticError(AGENTIC_ERROR_CODE.TOKEN_ROUTE_UNAVAILABLE, message, { userMessage: message, - hint: 'Choose a supported token on this network, or wait for a verified PRC20 route for this exact token address. Token symbols alone do not identify a route.', + hint: 'Choose a supported token on this network, or wait for a verified PRC20 route for this exact token address. The selected app must also support that exact token address; matching symbols are not sufficient.', details: { token, chainNamespace, diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index 160dd9b17..b98ba0fd0 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -411,7 +411,7 @@ export function buildPresetApi( issues.push({ code: 'ASSETS_REQUIRED', message: - 'Choose tokens and their limits, then regenerate this preset.', + 'No tokens selected: transfers from Push are disabled, not unrestricted. Choose explicit tokens and limits, then regenerate this preset.', }); if (chain.swapRouters?.length && selectedAssets.length < 2) issues.push({ diff --git a/packages/core/src/lib/agentic/presets/routes.ts b/packages/core/src/lib/agentic/presets/routes.ts index 8146d138b..c18269d83 100644 --- a/packages/core/src/lib/agentic/presets/routes.ts +++ b/packages/core/src/lib/agentic/presets/routes.ts @@ -45,7 +45,7 @@ export function presetRoutes( issues.push({ code: 'BRIDGE_ASSET_UNAVAILABLE', token, - message: `No SDK PRC20 mapping for ${chain}:${token} on ${network}. Choose a supported asset. This exact token needs a verified Push PRC20 route before it can be used.`, + message: `No SDK PRC20 mapping for ${chain}:${token} on ${network}. This preset cannot be created with this token until its Push route is verified. Choose a routed token only if the selected app supports that exact token address.`, }); return { token, status: 'unavailable' as const }; } diff --git a/packages/core/src/lib/agentic/public-rule.ts b/packages/core/src/lib/agentic/public-rule.ts index abf454047..12e49cd4a 100644 --- a/packages/core/src/lib/agentic/public-rule.ts +++ b/packages/core/src/lib/agentic/public-rule.ts @@ -148,6 +148,17 @@ export async function normalizePublicRule( r.validUntil >= 2 ** 48 ) throw invalid('validUntil must be a future uint48 timestamp'); + if ( + r.allowedCalls === undefined || + (Array.isArray(r.allowedCalls) && !r.allowedCalls.length) + ) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'Add at least one allowed function. Selecting tokens and limits alone does not authorize transfers.', + { + details: { reason: 'ALLOWED_FUNCTION_REQUIRED', field: 'allowedCalls' }, + } + ); if ( !Array.isArray(r.allowedCalls) || r.allowedCalls.length < 1 || From fa50f36779b9c68d97ac449823768cec6b34d245 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 20 Oct 2026 14:50:57 +0000 Subject: [PATCH 23/27] release(core): bump to 6.0.27-alpha.5 [skip ci] --- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index c8395c47d..1b7b4a8b1 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,9 @@ +@pushchain/core@6.0.27-alpha.5 (2026-10-20) + +- fix: clarify agentic creation permissions and route guidance + +--- + @pushchain/core@6.0.27-alpha.4 (2026-10-20) - fix: remove preset funding mode and preserve asset limits diff --git a/packages/core/package.json b/packages/core/package.json index 9586871d9..aa3ee917b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.27-alpha.4", + "version": "6.0.27-alpha.5", "homepage": "https://push.org", "keywords": [ "pushchain", From cde75cc798abb0c517ba11f2e0d019b759ce97e7 Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Wed, 21 Oct 2026 09:05:41 +0530 Subject: [PATCH 24/27] fix(core): route exact AGW tokens and size agent gas from the pool - Resolve the node-registered Circle USDC routes (Sepolia, Base, Arbitrum Sepolia) by exact address for agentic wallets; DEXES Sepolia and LENDING Arbitrum Sepolia presets become route-ready. - AGW gas budget never attaches less than the Push pool cost plus 20%, keeps the over-$10 share and excludes protocolFee; BNB testnet gas is valued at the pool rate because no BNB/USD feed exists. - Agent EVM sends default to 1,000,000 gas for token exports or multi-call payloads (the 500,000 base ran out on a Sepolia swap); omitted Solana gas uses route and finalize sizing. - Refuse an explicit gasLimit below the chain base, a Solana budget still short after one re-quote, and a zero Solana maxGasPerCall. - Correct the Base Sepolia WETH address. - Add opt-in live groups (unchanged and routed presets, gas, agent SPL, exact routes, Solana Memo) and read-only compatibility, gas-quote and route-registry drift scripts. --- packages/core/AGW.md | 22 +- .../__agw-local__/preset-acceptance.spec.ts | 105 +++++ packages/core/__e2e__/agw/_fixture.ts | 11 +- .../__e2e__/agw/exact-routes-live.spec.ts | 120 ++++++ packages/core/__e2e__/agw/gas-live.spec.ts | 173 ++++++++ .../__e2e__/agw/presets-acceptance.spec.ts | 408 ++++++++++++++++++ .../core/__e2e__/agw/svm-agent-spl.spec.ts | 307 +++++++++++++ .../core/__e2e__/agw/svm-memo-live.spec.ts | 182 ++++++++ packages/core/__e2e__/ci/preflight.ts | 19 +- packages/core/__e2e__/ci/suite.ts | 59 +++ .../core/__e2e__/shared/agw-gas-evidence.ts | 44 ++ .../__e2e__/shared/agw-preset-acceptance.ts | 108 +++++ packages/core/scripts/check-agw-gas-quotes.js | 122 ++++++ .../scripts/check-agw-preset-compatibility.js | 323 ++++++++++++++ .../core/scripts/check-agw-route-registry.js | 192 +++++++++ .../lib/agentic/__tests__/gas-budget.spec.ts | 220 ++++++++++ .../agentic/__tests__/preset-routes.spec.ts | 11 +- .../src/lib/agentic/__tests__/send.spec.ts | 34 +- .../__tests__/svm-agent-send-defaults.spec.ts | 77 ++++ .../lib/agentic/__tests__/svm-quote.spec.ts | 13 + .../__tests__/svm-send-defaults.spec.ts | 66 +++ .../agentic/__tests__/token-routes.spec.ts | 123 ++++++ .../src/lib/agentic/execution/gas-budget.ts | 69 +++ .../src/lib/agentic/execution/outbound.ts | 14 +- .../src/lib/agentic/execution/svm-public.ts | 5 +- .../src/lib/agentic/management/svm-public.ts | 3 + .../core/src/lib/agentic/presets/routes.ts | 7 +- packages/core/src/lib/agentic/runtime.ts | 68 ++- packages/core/src/lib/agentic/token-routes.ts | 93 ++++ packages/core/src/lib/constants/tokens.ts | 2 +- .../__tests__/gas-usd-sizer.spec.ts | 59 +++ .../orchestrator/internals/gas-calculator.ts | 38 +- .../orchestrator/internals/gas-pool-quote.ts | 114 +++++ .../orchestrator/internals/gas-usd-sizer.ts | 58 ++- .../lib/orchestrator/internals/svm-rent.ts | 4 + 35 files changed, 3189 insertions(+), 84 deletions(-) create mode 100644 packages/core/__agw-local__/preset-acceptance.spec.ts create mode 100644 packages/core/__e2e__/agw/exact-routes-live.spec.ts create mode 100644 packages/core/__e2e__/agw/gas-live.spec.ts create mode 100644 packages/core/__e2e__/agw/presets-acceptance.spec.ts create mode 100644 packages/core/__e2e__/agw/svm-agent-spl.spec.ts create mode 100644 packages/core/__e2e__/agw/svm-memo-live.spec.ts create mode 100644 packages/core/__e2e__/shared/agw-gas-evidence.ts create mode 100644 packages/core/__e2e__/shared/agw-preset-acceptance.ts create mode 100644 packages/core/scripts/check-agw-gas-quotes.js create mode 100644 packages/core/scripts/check-agw-preset-compatibility.js create mode 100644 packages/core/scripts/check-agw-route-registry.js create mode 100644 packages/core/src/lib/agentic/__tests__/gas-budget.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/svm-agent-send-defaults.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/svm-send-defaults.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/token-routes.spec.ts create mode 100644 packages/core/src/lib/agentic/execution/gas-budget.ts create mode 100644 packages/core/src/lib/agentic/token-routes.ts create mode 100644 packages/core/src/lib/orchestrator/internals/gas-pool-quote.ts diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 0b6d00bb7..78d4bd36e 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -136,7 +136,7 @@ Creation and rule-preparation errors include a zero-based `details.ruleIndex` wh A DEX condition-limit failure reports `details.reason = 'RULE_CONSTRAINT_LIMIT'`, `actual`, `limit` and `callCount`. Conditions here are generated input checks (for example token, recipient and amount checks), rather than the number of visible function names. A stock two-token, single-router preset contains six allowed-call entries and twenty conditions. Appending the same call list again makes forty conditions. The SDK rejects this; it does not deduplicate, remove conditions or split authority automatically. Preserve per-chain rules and regenerate after edits rather than appending generated calls. -For the review screen, show the actual network (including testnet name), selected token addresses and limits. Label maxGasPerCall as a maximum PC gas budget, not a guaranteed fee or execution gas-unit count. maxTotal meters cumulative Push outbounds; it is not a lifetime destination-spend limit. +For the review screen, show the actual network (including testnet name), selected token addresses and limits. Label maxGasPerCall as a maximum PC gas budget, not a guaranteed fee or execution gas-unit count. maxTotal meters cumulative Push outbounds, including outbounds whose destination execution later fails; it is not a lifetime destination-spend limit. Destination pins and maxValue limit each call separately. ## Solana destinations @@ -256,7 +256,7 @@ These are small, protocol-specific subsets chosen to fit the 32-call/32-pin ceil | Arbitrum Sepolia | WETH / Circle USDC | Aave USDC | | BSC testnet | WBNB / Venus USDT | Venus USDT, USDC | -Staking defaults are ETH/stETH/wstETH on Ethereum and the reviewed EigenLayer strategy's WETH on Sepolia. Testnet tokens with similar names can have different addresses; the returned addresses are authoritative. Defaults do not guarantee active reserves, liquidity or a Push PRC20 route. An unmodified preset cannot be granted when its assets lack matching PRC20 routes. The SDK preserves the selected tokens and caps and reports unavailable routes; it never removes assets to bypass this check. No runtime metadata/price fetch is added to get. +Staking defaults are ETH/stETH/wstETH on Ethereum and the reviewed EigenLayer strategy's WETH on Sepolia. Testnet tokens with similar names can have different addresses; the returned addresses are authoritative. Defaults do not guarantee active reserves, liquidity or a Push PRC20 route. An unmodified preset cannot be granted when its assets lack matching PRC20 routes. The SDK preserves the selected tokens and caps and reports unavailable routes; it never removes assets to bypass this check. On Donut, every default asset of DEXES Sepolia and LENDING Arbitrum Sepolia currently has a registered route; the other testnet combinations do not. No runtime metadata/price fetch is added to get. ### Funding and release-network checks @@ -264,7 +264,7 @@ Missing PRC20 registry entries produce `BRIDGE_ASSET_UNAVAILABLE`, and readiness The public preset API has no `funding` option. Remove it from existing integrations; supplying it now produces `INVALID_RULE` rather than silently changing permissions. Internal prefunded CEA test fixtures explicitly clear bridge assets to isolate protocol constraints. Those fixtures do not demonstrate that an unsupported preset route is executable. -Existing mappings distinguish token identity by chain and address, including address casing for EVM. WETH/stETH Sepolia mappings and string/address-word source metadata are supported; mainnet or other-chain WETH is never aliased to them. Protocol mock LINK, Moonwell/Venus mocks and Circle testnet USDC can differ from the supported source tokens; they remain unavailable for bridge funding until real matching routes exist. +Existing mappings distinguish token identity by chain and address, including address casing for EVM. WETH/stETH Sepolia mappings and string/address-word source metadata are supported; mainnet or other-chain WETH is never aliased to them. Exact routes enabled in the Push node registry are also resolved by address: Circle testnet USDC on Sepolia, Base Sepolia and Arbitrum Sepolia maps to its own PRC20, separate from the routed mock USDC. Grant preparation still verifies each PRC20 source on chain. Protocol mock LINK, Moonwell/Venus tokens, Base/Arbitrum WETH, BNB testnet WBNB and the EigenLayer WETH remain unavailable for bridge funding until matching routes exist. ### Default permission policy @@ -360,4 +360,18 @@ A rule requires at least one allowed function, even when tokens and limits are s `assets: []` remains valid for a universal rule with allowed calls. It grants no token transfers from Push; it never means all tokens. Allowed calls may still interact with destination-held assets under their configured constraints. There is no all-token wildcard. List each permitted token and cap explicitly (at most eight assets). -For presets, check `readiness.ready` before enabling Create. Display the per-chain issues and the affected token address. Empty selections explain that transfers from Push are disabled. `BRIDGE_ASSET_UNAVAILABLE` identifies an unverified SDK route, not proof that no deployment exists anywhere. Do not substitute a routed token solely because its symbol matches: the protocol must support that exact address, and swaps need compatible liquidity. A transfer-only helper and additional token routes are not included in this update. +For presets, check `readiness.ready` before enabling Create. Display the per-chain issues and the affected token address. Empty selections explain that transfers from Push are disabled. `BRIDGE_ASSET_UNAVAILABLE` identifies an unverified SDK route, not proof that no deployment exists anywhere. Do not substitute a routed token solely because its symbol matches: the protocol must support that exact address, and swaps need compatible liquidity. A transfer-only helper is not included. An ERC20 transfer can be authorized explicitly with a `transfer(address,uint256)` allowed call pinned to a recipient and a per-transfer maximum; it also applies to that token already held in the wallet's destination account. + +### Exact-preset acceptance and gas evidence + +The opt-in `agw-presets-acceptance` group exercises all nine shipped testnet category/network combinations. It passes the returned `rules` unchanged to `create`, reads back every asset/cap/function/pin, and performs first-use and subsequent agent operations. Missing routes, protocols or exact PRC20 balances fail before funding; they do not count as passing acceptance. The opt-in `agw-presets-routed` group runs only the cases whose default assets are all routed (DEXES Sepolia and LENDING Arbitrum Sepolia); preflight checks readiness for the selected cases only. The earlier `agw-presets` and `agw-presets-expanded` protocol fixtures use prefunded destination assets and remain separate coverage. + +Read-only compatibility checks are available with `node packages/core/scripts/check-agw-preset-compatibility.js /tmp/compatibility.json` after building core. The check verifies known routed source identities (registry and exact node routes), live Aave reserves, Moonwell/Venus underlyings, the reviewed EigenLayer strategy and pools at the preset routers' factories, including each DEX preset's own default pair. A matching symbol alone is insufficient. RPC and read errors are recorded as unknown checks, never as compatibility or as missing routes. No default assets were changed. `node packages/core/scripts/check-agw-route-registry.js /tmp/registry.json` compares the SDK's routes with the Push node's token registry (uregistry): it fails on a mismatched or SDK-only route and lists enabled node routes the SDK does not resolve. + +`maxGasPerCall` is an owner-authorized ceiling on the PC attached to each outbound. The send-time `gasLimit` is a different field: the multiplier for the destination gas price. Omitted or zero uses the network base (500,000 on the EVM testnets, 960 on Solana devnet). Agent EVM sends without an explicit gasLimit instead use 2,000,000 on first use (the destination account is deployed), and 1,000,000 when they export a non-native token or make more than one destination call. A Sepolia Circle USDC export with approve and a Uniswap swap ran out of gas at the 500,000 base. An explicit value below the base is refused before quoting with `INVALID_RULE` and `details.minimum`. When omitted on Solana, the SDK raises the limit to cover finalization rent instead of using an EVM-style million-unit fallback. If a gas-price change leaves that budget short even after one re-quote, the send is refused before signing (`INCONSISTENT_READ`). + +The attached budget is at least $1 of PC: 10 PC at the fixed $0.10 rate used for Push-native agents, and less or more for agents signing from another chain, which are priced through that origin's Donut stable pool. It is never less than the current Push pool cost of the destination gas plus 20%. BNB testnet gas is valued at that pool rate, because no BNB/USD feed exists (the BSC gateway feed is ETH/USD). The gateway refunds unused PC in the same transaction. After the destination executes, the node also refunds unused destination gas, `(gasLimit − gasUsed) × gasPrice`, to the wallet as PC in a later Push transaction; this was verified on Donut for EVM and Solana. On October 20 the gas swaps actually consumed 0.0008-2.37 PC per send. 0.1 PC can never cover the minimum budget. The 20 PC preset ceiling covered the Sepolia, Base Sepolia, Arbitrum Sepolia and Solana devnet runs; BNB testnet first sends exceed it while Donut's BNB gas price is high. There is no cumulative gas cap; the wallet's PC balance is the only total limit. `node packages/core/scripts/check-agw-gas-quotes.js /tmp/quotes.json` records current quotes for Push-native and Sepolia-origin agents without signing. Quotes change; they are not fixed fees or a validated default for custom rules. + +The separate `agw-svm-agent-spl` group verifies the known devnet test harness's `receive_spl` instruction: wallet ATA pins, an amount ceiling, exact recipient credit and first/subsequent sends with omitted gas. It uses the existing counter program and does not certify a third-party Solana protocol. The gas evidence records Push transaction fees, attached quote budgets and destination fees separately. Run both new groups with the normal `__e2e__/ci/preflight.ts --group ...` and `__e2e__/ci/run.ts --group ...` commands. + +The `agw-gas` group measures first-use and subsequent native-token agent sends on Sepolia, Base Sepolia, Arbitrum Sepolia and BNB testnet using the existing counter fixtures. Its route/gas coverage is separate from protocol preset acceptance. diff --git a/packages/core/__agw-local__/preset-acceptance.spec.ts b/packages/core/__agw-local__/preset-acceptance.spec.ts new file mode 100644 index 000000000..7e7caa527 --- /dev/null +++ b/packages/core/__agw-local__/preset-acceptance.spec.ts @@ -0,0 +1,105 @@ +/** Exact default preset output -> real AGW/URP -> exact public readback. + * Source-token PRC20 metadata and gateway are local stand-ins. This proves SDK + * preservation with provisioned routes, not live route availability or delivery. + */ +import { getAddress, parseAbi, type Address } from 'viem'; +import { startHarness, type Harness } from './harness'; +import { PushChain } from '../src'; +import type { AgenticRuntime } from '../src/lib/agentic/runtime'; +import * as accountLookup from '../src/lib/universal/account/account'; +import { configId } from '../src/lib/agentic/codec/ids'; +import { + PRESET_ACCEPTANCE_CASES, + expectStoredPreset, +} from '../__e2e__/shared/agw-preset-acceptance'; +const sourceABI = parseAbi(['function setSourceTokenAddress(string)']); +const CEA = getAddress('0x000000000000000000000000000000000000cea1'); +const runtime = (c: PushChain) => + (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; + +describe('unchanged default preset contract acceptance', () => { + let h: Harness, owner: PushChain, lookup: jest.SpyInstance; + const routes = new Map(); + beforeAll(async () => { + lookup = jest + .spyOn(accountLookup, 'convertExecutorToOrigin') + .mockResolvedValue({ account: null, exists: false }); + h = await startHarness(18555); + owner = await h.client(0); + runtime(owner).resolveCEA = async () => ({ + cea: CEA, + isDeployed: false, + }); + runtime(owner).resolvePrc20 = (token, chain) => { + const source = typeof token === 'string' ? token : token?.address; + const mapped = routes.get(`${chain}:${source?.toLowerCase()}`); + if (!mapped) throw new Error('Fixture route not provisioned'); + return mapped; + }; + }); + afterAll(async () => { + try { + await h?.stop(); + } finally { + lookup?.mockRestore(); + } + }); + it.each(PRESET_ACCEPTANCE_CASES)( + '%s %s preserves every generated asset, cap, function and pin', + async (category, chainNamespace) => { + const generated = PushChain.utils.agentic.presets.get(category, { + chainNamespace, + agent: h.wallets[1].account!.address, + }); + const before = JSON.stringify(generated.rules, (_k, v) => + typeof v === 'bigint' ? String(v) : v + ); + for (const asset of generated.rules[0].assets!) { + const source = getAddress( + typeof asset.token === 'string' ? asset.token : asset.token.address + ); + const key = `${chainNamespace}:${source.toLowerCase()}`; + if (!routes.has(key)) { + const mapped = await h.deploy( + 0, + 'HarnessPRC20', + 'HarnessFixtures.sol', + [chainNamespace, 18] + ); + await h.write(0, mapped, sourceABI, 'setSourceTokenAddress', [ + source, + ]); + routes.set(key, mapped); + } + } + const made = await owner.agentic.create('preset-exact', { + rules: generated.rules, + }); + const records = await owner.agentic.wallet(made.wallet).rules.list(); + expect(records.rules).toHaveLength(1); + const record = await owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + expectStoredPreset(generated.rules[0], record); + // Readback maps pins to agentic:wallet; check the stored destination itself. + const [action] = await h.publicClient.readContract({ + address: h.addresses.engine, + abi: h.generation.contracts.abis.engine, + functionName: 'getEnabledActions', + args: [made.wallet, made.rulesIds[0]], + }); + const config = await h.publicClient.readContract({ + address: h.addresses.rulesPolicy, + abi: h.generation.contracts.abis.policy, + functionName: 'getConfig', + args: [configId(made.wallet, made.rulesIds[0], action), made.wallet], + }); + expect(config.expectedCEA).toBe(CEA); + expect( + JSON.stringify(generated.rules, (_k, v) => + typeof v === 'bigint' ? String(v) : v + ) + ).toBe(before); + } + ); +}); diff --git a/packages/core/__e2e__/agw/_fixture.ts b/packages/core/__e2e__/agw/_fixture.ts index 649ccbb26..4ac49fbaa 100644 --- a/packages/core/__e2e__/agw/_fixture.ts +++ b/packages/core/__e2e__/agw/_fixture.ts @@ -34,6 +34,8 @@ export interface AgwFixture { agentAddress: Address; agent(wallet: Address, progressHook?: (e: unknown) => void): Promise; fundPC(to: Address, amount: bigint): Promise; + /** Funds the generated agent; already done unless setup deferred it. */ + fundAgent(): Promise; evidence(name: string, data: Record): void; teardown(): void; } @@ -46,7 +48,9 @@ const pushChainFor = (network: PUSH_NETWORK) => * the manifest, keys or wiring are missing. A selected agw run can never pass * without exercising a verified deployment. */ -export async function setupAgw(): Promise { +export async function setupAgw( + options: { deferAgentFunding?: boolean } = {} +): Promise { const manifest = loadAgwManifest(); if (manifest.network !== PUSH_NETWORK.TESTNET_DONUT) { throw new AgwPrerequisiteError('live AGW fixtures require Donut'); @@ -85,7 +89,9 @@ export async function setupAgw(): Promise { const r = await tx.wait(); if (r.status !== 1) throw new Error(`funding ${to} failed`); }; - await fundPC(agentAddress, parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5')); + const fundAgent = () => fundPC(agentAddress, parseEther(process.env['AGW_E2E_AGENT_PC'] ?? '0.5')); + // Deferred callers check their prerequisites first, so a blocked case spends nothing. + if (!options.deferAgentFunding) await fundAgent(); const logDir = join(__dirname, '..', '..', 'e2e-logs'); mkdirSync(logDir, { recursive: true }); @@ -107,6 +113,7 @@ export async function setupAgw(): Promise { agentAddress, agent: (wallet, progressHook) => evmClient(agentKey, chain, manifest.network, wallet, progressHook), fundPC, + fundAgent, evidence, teardown: () => resetAgenticGenerations(), }; diff --git a/packages/core/__e2e__/agw/exact-routes-live.spec.ts b/packages/core/__e2e__/agw/exact-routes-live.spec.ts new file mode 100644 index 000000000..e35d406bb --- /dev/null +++ b/packages/core/__e2e__/agw/exact-routes-live.spec.ts @@ -0,0 +1,120 @@ +/** Live outbound settlement of the exact node-registered Circle USDC routes. + * Owner door, funds only: the Push PRC20 is burned and the exact Circle USDC is + * delivered to the owner's own address on each chain. Route evidence only; no + * preset or agent permission is exercised here. + */ +import '@e2e/shared/setup'; +import { + createPublicClient, + http, + encodeFunctionData, + erc20Abi, + parseEther, + type Hex, +} from 'viem'; +import { CHAIN } from '../../src'; +import { PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { exactAgenticRoutes } from '../../src/lib/agentic/token-routes'; +import { AGW_E2E_ENABLED, evmClient, setupAgw } from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const AMOUNT = BigInt(10_000); // 0.01 USDC +const ENDPOINTS: Partial> = { + [CHAIN.ETHEREUM_SEPOLIA]: 'https://ethereum-sepolia-rpc.publicnode.com', + [CHAIN.BASE_SEPOLIA]: 'https://base-sepolia-rpc.publicnode.com', + [CHAIN.ARBITRUM_SEPOLIA]: 'https://arbitrum-sepolia-rpc.publicnode.com', +}; + +d('agw exact route outbound', () => { + async function run(chain: CHAIN) { + const [route] = exactAgenticRoutes(chain, PUSH_NETWORK.TESTNET_DONUT); + if (!route) throw new Error(`No exact route for ${chain}`); + const rpc = createPublicClient({ + transport: http(ENDPOINTS[chain]!, { timeout: 15000, retryCount: 1 }), + }); + if ((await rpc.getChainId()) !== Number(chain.split(':')[1])) + throw new Error('Destination RPC chain mismatch'); + const f = await setupAgw({ deferAgentFunding: true }); + try { + const prc20Balance = (holder: `0x${string}`) => + f.push.readContract({ + address: route.prc20, + abi: erc20Abi, + functionName: 'balanceOf', + args: [holder], + }); + if ((await prc20Balance(f.ownerAddress)) < AMOUNT) + throw new Error(`ROUTE_PREREQUISITE: owner lacks ${route.prc20}`); + const made = await f.owner.agentic.create('exact-route', { rules: [] }); + await f.fundPC(made.wallet, parseEther('21')); + await ( + await f.owner.universal.sendTransaction({ + to: route.prc20, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [made.wallet, AMOUNT], + }), + }) + ).wait(); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + made.wallet + ); + const recipient = f.ownerAddress; + const delivered = () => + rpc.readContract({ + address: route.source, + abi: erc20Abi, + functionName: 'balanceOf', + args: [recipient], + }); + const before = await delivered(); + const tx = await owner.universal.sendTransaction({ + to: { chain, address: recipient }, + funds: { + amount: AMOUNT, + token: { + symbol: route.symbol, + decimals: route.decimals, + address: route.source, + mechanism: 'approve', + }, + }, + }); + expect(tx.agentic?.door).toBe('owner'); + f.evidence('exact-route-submitted', { + chain, + wallet: made.wallet, + prc20: route.prc20, + source: route.source, + hash: tx.hash, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 180000 }); + expect(receipt.externalStatus).toBe('success'); + const dest = await rpc.getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + expect(dest.status).toBe('success'); + expect(await delivered()).toBe(before + AMOUNT); + expect(await prc20Balance(made.wallet)).toBe(BigInt(0)); + f.evidence('exact-route-delivered', { + chain, + hash: tx.hash, + destinationHash: receipt.externalTxHash, + destinationGasUsed: dest.gasUsed, + }); + } finally { + f.teardown(); + } + } + it('1. Sepolia Circle USDC', () => run(CHAIN.ETHEREUM_SEPOLIA), 600000); + it('2. Base Sepolia Circle USDC', () => run(CHAIN.BASE_SEPOLIA), 600000); + it( + '3. Arbitrum Sepolia Circle USDC', + () => run(CHAIN.ARBITRUM_SEPOLIA), + 600000 + ); +}); diff --git a/packages/core/__e2e__/agw/gas-live.spec.ts b/packages/core/__e2e__/agw/gas-live.spec.ts new file mode 100644 index 000000000..9e58a77a9 --- /dev/null +++ b/packages/core/__e2e__/agw/gas-live.spec.ts @@ -0,0 +1,173 @@ +/** Measure omitted destination gas on first/subsequent supported EVM routes. + * Uses the existing counter fixtures and native PRC20s, not unavailable presets. + */ +import '@e2e/shared/setup'; +import { + createPublicClient, + http, + encodeFunctionData, + erc20Abi, + parseAbi, + parseEventLogs, + parseEther, + type Hex, + type Address, +} from 'viem'; +import { CHAIN } from '../../src'; +import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import { EVM_CHAIN_FIXTURES } from '../shared/chain-fixtures'; +import { observeAgwGas, recordAgwPushGas } from '../shared/agw-gas-evidence'; +import { AGW_E2E_ENABLED, setupAgw, inSeconds } from './_fixture'; +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const AMOUNT = BigInt(10) ** BigInt(12); +const EXECUTED = parseAbi([ + 'event UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data)', +]); +const ENDPOINTS: Partial> = { + [CHAIN.ETHEREUM_SEPOLIA]: 'https://ethereum-sepolia-rpc.publicnode.com', + [CHAIN.BASE_SEPOLIA]: 'https://base-sepolia-rpc.publicnode.com', + [CHAIN.ARBITRUM_SEPOLIA]: 'https://arbitrum-sepolia-rpc.publicnode.com', + [CHAIN.BNB_TESTNET]: 'https://bsc-testnet-rpc.publicnode.com', +}; +d('agw live gas sizing', () => { + async function run(chain: CHAIN) { + const counter = EVM_CHAIN_FIXTURES.find((x) => x.chain === chain)!.contracts + .counter as Address; + const rpc = createPublicClient({ + transport: http(ENDPOINTS[chain]!, { timeout: 15000 }), + }); + if ((await rpc.getChainId()) !== Number(chain.split(':')[1])) + throw new Error('Destination RPC chain mismatch'); + if (!(await rpc.getCode({ address: counter }))) + throw new Error('GAS_PREREQUISITE: counter fixture has no code'); + const f = await setupAgw(); + try { + const token = getNativePRC20ForChain(chain, f.manifest.network); + if ( + (await f.push.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + })) < + AMOUNT * BigInt(2) + ) + throw new Error( + `GAS_PREREQUISITE: owner lacks native PRC20 for ${chain}` + ); + const made = await f.owner.agentic.create('route-gas-acceptance', { + rules: [ + { + agent: f.agentAddress, + chainNamespace: chain as `eip155:${string}`, + validUntil: inSeconds(3600), + assets: [ + { + token: '0x0000000000000000000000000000000000000000', + maxPerCall: AMOUNT, + maxTotal: AMOUNT * BigInt(2), + }, + ], + maxGasPerCall: parseEther('20'), + allowedCalls: [{ target: counter, selector: 'increment()' }], + }, + ], + }); + const cea = (await getCEAAddress(made.wallet, chain, ENDPOINTS[chain])) + .cea as Address; + expect(await rpc.getCode({ address: cea })).toBeUndefined(); + await f.fundPC(made.wallet, parseEther('21')); + await ( + await f.owner.universal.sendTransaction({ + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [made.wallet, AMOUNT * BigInt(2)], + }), + }) + ).wait(); + const agent = await f.agent(made.wallet); + for (const stage of ['first-use', 'subsequent']) { + const oldBalance = await rpc.getBalance({ address: cea }); + const stop = observeAgwGas(agent, f, `${chain}:${stage}`); + try { + const tx = await agent.universal.sendTransaction({ + to: { chain, address: counter }, + funds: { amount: AMOUNT }, + data: [{ to: counter, data: '0xd09de08a', value: BigInt(0) }], + }); + f.evidence('evm-gas-submitted', { + chain, + stage, + wallet: made.wallet, + cea, + hash: tx.hash, + }); + const result = await tx.wait({ outboundTimeoutMs: 180000 }); + expect(result.externalStatus).toBe('success'); + const receipt = await rpc.getTransactionReceipt({ + hash: result.externalTxHash as Hex, + }); + expect(receipt.status).toBe('success'); + expect(await rpc.getBalance({ address: cea })).toBe( + oldBalance + AMOUNT + ); + const events = parseEventLogs({ abi: EXECUTED, logs: receipt.logs }); + expect( + events.some( + (e) => + e.address.toLowerCase() === cea.toLowerCase() && + e.args.originCaller.toLowerCase() === + made.wallet.toLowerCase() && + e.args.target.toLowerCase() === counter.toLowerCase() && + e.args.data === '0xd09de08a' + ) + ).toBe(true); + await recordAgwPushGas(f, tx.hash, `${chain}:${stage}`); + f.evidence('evm-gas-delivered', { + chain, + stage, + wallet: made.wallet, + hash: tx.hash, + destinationHash: result.externalTxHash, + destinationBlock: receipt.blockNumber, + destinationGasUsed: receipt.gasUsed, + destinationGasPaidWei: receipt.gasUsed * receipt.effectiveGasPrice, + }); + } finally { + stop(); + } + } + const stored = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (stored.spent?.kind !== 'universal') + throw new Error('Missing universal spend'); + expect(stored.spent.assets[0].spent).toBe(AMOUNT * BigInt(2)); + } finally { + f.teardown(); + } + } + it( + '1. Sepolia first-use and subsequent execution', + () => run(CHAIN.ETHEREUM_SEPOLIA), + 600000 + ); + it( + '2. Base Sepolia first-use and subsequent execution', + () => run(CHAIN.BASE_SEPOLIA), + 600000 + ); + it( + '3. Arbitrum Sepolia first-use and subsequent execution', + () => run(CHAIN.ARBITRUM_SEPOLIA), + 600000 + ); + it( + '4. BNB testnet first-use and subsequent execution', + () => run(CHAIN.BNB_TESTNET), + 600000 + ); +}); diff --git a/packages/core/__e2e__/agw/presets-acceptance.spec.ts b/packages/core/__e2e__/agw/presets-acceptance.spec.ts new file mode 100644 index 000000000..e8a1644f0 --- /dev/null +++ b/packages/core/__e2e__/agw/presets-acceptance.spec.ts @@ -0,0 +1,408 @@ +/** Release acceptance: EXACT shipped preset -> create -> readback -> two agent sends. + * Missing routes, protocols or exact PRC20 balances fail before any funding. This is + * deliberately separate from + * the prefunded protocol fixtures; no asset, cap, call or pin is removed here. + */ +import '@e2e/shared/setup'; +import { + createPublicClient, + http, + encodeFunctionData, + erc20Abi, + parseAbi, + parseEther, + type ContractFunctionReturnType, + type Address, + type Hex, +} from 'viem'; +import { PushChain, CHAIN, type Rule } from '../../src'; +import { resolveAgenticPrc20 } from '../../src/lib/agentic/token-routes'; +import { v6 } from '../../src/lib/agentic/contracts/v6'; +import { actionId, configId } from '../../src/lib/agentic/codec/ids'; +import { + PRESET_ACCEPTANCE_CASES, + expectStoredPreset, +} from '../shared/agw-preset-acceptance'; +import { observeAgwGas, recordAgwPushGas } from '../shared/agw-gas-evidence'; +import { AGW_E2E_ENABLED, setupAgw } from './_fixture'; +import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const endpoints: Record = { + 'eip155:11155111': 'https://ethereum-sepolia-rpc.publicnode.com', + 'eip155:84532': 'https://base-sepolia-rpc.publicnode.com', + 'eip155:421614': 'https://arbitrum-sepolia-rpc.publicnode.com', + 'eip155:97': 'https://bsc-testnet-rpc.publicnode.com', +}; +const ABI = parseAbi([ + 'function factory() view returns(address)', + 'function getPool(address,address,uint24) view returns(address)', + 'function liquidity() view returns(uint128)', + 'function supply(address,uint256,address,uint16)', + 'function mint(uint256) returns(uint256)', + 'function balanceOf(address) view returns(uint256)', + 'function getReserveData(address) view returns ((uint256 configuration,uint128 liquidityIndex,uint128 currentLiquidityRate,uint128 variableBorrowIndex,uint128 currentVariableBorrowRate,uint128 currentStableBorrowRate,uint40 lastUpdateTimestamp,uint16 id,address aTokenAddress,address stableDebtTokenAddress,address variableDebtTokenAddress,address interestRateStrategyAddress,uint128 accruedToTreasury,uint128 unbacked,uint128 isolationModeTotalDebt))', + 'function depositIntoStrategy(address,address,uint256) returns(uint256)', + 'function stakerDepositShares(address,address) view returns(uint256)', + 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns(uint256)', +]); +// Concrete calls supported by the current catalog. A newly introduced protocol +// requires its own operation/receipt assertion rather than a false generic pass. +// `input` selects which preset asset funds the operation; swaps can trade either way. +async function operation(rule: Rule, cea: Address, input = 0) { + const rpc = createPublicClient({ + transport: http(endpoints[rule.chainNamespace!], { + timeout: 15000, + retryCount: 1, + }), + }); + const asset = rule.assets![input], + token = ( + typeof asset.token === 'string' ? asset.token : asset.token.address + ) as Address; + const amount = asset.maxPerCall / BigInt(1000); + if (amount <= BigInt(0) || (asset.maxTotal ?? BigInt(0)) < amount * BigInt(2)) + throw new Error('Default caps cannot cover two bounded acceptance sends'); + const read = < + F extends + | 'factory' + | 'getPool' + | 'liquidity' + | 'getReserveData' + | 'balanceOf' + | 'stakerDepositShares' + >( + address: Address, + functionName: F, + args: readonly unknown[] = [] + ) => + rpc.readContract({ + address, + abi: ABI, + functionName: functionName as never, + args: args as never, + }) as Promise>; + const swap = rule.allowedCalls.find((c) => + c.selector.toString().startsWith('exactInputSingle(') + ); + let target: Address, + data: Hex, + outputToken: Address | undefined, + shares: (() => Promise) | undefined; + if (swap) { + target = swap.target as Address; + const output = rule.assets![input === 0 ? 1 : 0]; + outputToken = ( + typeof output.token === 'string' ? output.token : output.token.address + ) as Address; + const factory = await read(target, 'factory'); + let fee: number | undefined; + for (const tier of [100, 500, 2500, 3000, 10000]) { + const pool: Address = await read(factory, 'getPool', [ + token, + outputToken, + tier, + ]); + if (!/^0x0+$/.test(pool) && (await read(pool, 'liquidity')) > BigInt(0)) { + fee = tier; + break; + } + } + if (!fee) + throw new Error( + 'PRESET_PROTOCOL_UNAVAILABLE: routed default pair has no active pool' + ); + data = encodeFunctionData({ + abi: ABI, + functionName: 'exactInputSingle', + args: [ + { + tokenIn: token, + tokenOut: outputToken, + fee, + recipient: cea, + amountIn: amount, + amountOutMinimum: BigInt(1), + sqrtPriceLimitX96: BigInt(0), + }, + ], + }); + } else { + const call = rule.allowedCalls.find( + (c) => + c.selector === 'supply(address,uint256,address,uint16)' || + c.selector === 'mint(uint256)' || + c.selector === 'depositIntoStrategy(address,address,uint256)' + ); + if (!call) + throw new Error( + 'PRESET_ACCEPTANCE_UNIMPLEMENTED: no verified operation for this catalog entry' + ); + target = call.target as Address; + if (call.selector === 'supply(address,uint256,address,uint16)') { + const reserve = await read(target, 'getReserveData', [token]); + if (/^0x0+$/.test(reserve.aTokenAddress)) + throw new Error( + 'PRESET_PROTOCOL_UNAVAILABLE: token is not an Aave reserve' + ); + shares = () => read(reserve.aTokenAddress, 'balanceOf', [cea]); + data = encodeFunctionData({ + abi: ABI, + functionName: 'supply', + args: [token, amount, cea, 0], + }); + } else if (call.selector === 'mint(uint256)') { + shares = () => read(target, 'balanceOf', [cea]); + data = encodeFunctionData({ + abi: ABI, + functionName: 'mint', + args: [amount], + }); + } else { + const strategy = '0x424246eF71b01ee33aA33aC590fd9a0855F5eFbc' as Address; + shares = () => read(target, 'stakerDepositShares', [cea, strategy]); + data = encodeFunctionData({ + abi: ABI, + functionName: 'depositIntoStrategy', + args: [strategy, token, amount], + }); + } + } + const resultBalance = + shares ?? + (() => + rpc.readContract({ + address: outputToken!, + abi: erc20Abi, + functionName: 'balanceOf', + args: [cea], + })); + return { + rpc, + token, + amount, + target, + resultBalance, + calls: [ + { + to: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [target, amount], + }), + }, + { to: target, value: BigInt(0), data }, + ], + }; +} + +d('agw unchanged preset acceptance', () => { + // Explicit titles also allow CI's scenario selector to enumerate all nine. + const run = async ( + category: 'DEXES' | 'LENDING' | 'STAKING', + chainNamespace: `eip155:${string}` + ) => { + const preview = PushChain.utils.agentic.presets.get(category, { + chainNamespace, + agent: '0x0000000000000000000000000000000000005678', + }); + if (!preview.readiness.ready) + throw new Error( + `PRESET_BLOCKED_BEFORE_FUNDING ${category} ${chainNamespace}: ${JSON.stringify( + preview.readiness.chains[0].issues + )}` + ); + const f = await setupAgw({ deferAgentFunding: true }); + try { + const generated = PushChain.utils.agentic.presets.get(category, { + chainNamespace, + agent: f.agentAddress, + }); + const before = JSON.stringify(generated.rules, (_k, v) => + typeof v === 'bigint' ? String(v) : v + ); + const predicted = await f.owner.agentic.derive(); + const cea = ( + await getCEAAddress( + predicted.address, + chainNamespace as CHAIN, + endpoints[chainNamespace] + ) + ).cea as Address; + // A swap may start from whichever default asset the owner holds as a PRC20. + const swapPreset = generated.rules[0].allowedCalls.some((c) => + c.selector.toString().startsWith('exactInputSingle(') + ); + let input = 0; + if (swapPreset) + for (const [i, a] of generated.rules[0].assets!.entries()) { + const prc20 = resolveAgenticPrc20( + { + chain: chainNamespace, + address: typeof a.token === 'string' ? a.token : a.token.address, + }, + f.manifest.network + ).address; + const held = await f.push.readContract({ + address: prc20, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + }); + if (held >= (a.maxPerCall / BigInt(1000)) * BigInt(2)) { + input = i; + break; + } + } + const op = await operation(generated.rules[0], cea, input); + const mapped = resolveAgenticPrc20( + { chain: chainNamespace, address: op.token }, + f.manifest.network + ); + if ( + (await f.push.readContract({ + address: mapped.address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + })) < + op.amount * BigInt(2) + ) + throw new Error('PRESET_PREREQUISITE: owner lacks exact default PRC20'); + await f.fundAgent(); + const made = await f.owner.agentic.create('preset-acceptance', { + rules: generated.rules, + }); + expect(made.wallet).toBe(predicted.address); + const config = await f.push.readContract({ + address: f.manifest.addresses.rulesPolicy, + abi: v6.abis.policy, + functionName: 'getConfig', + args: [ + configId( + made.wallet, + made.rulesIds[0], + actionId(f.manifest.addresses.gateway, '0x77b86bec') + ), + made.wallet, + ], + }); + expect(config.expectedCEA).toBe(cea); + expectStoredPreset( + generated.rules[0], + await f.owner.agentic.wallet(made.wallet).rules.get(made.rulesIds[0]) + ); + expect( + JSON.stringify(generated.rules, (_k, v) => + typeof v === 'bigint' ? String(v) : v + ) + ).toBe(before); + f.evidence('unchanged-preset-created', { + category, + chainNamespace, + wallet: made.wallet, + hash: made.tx.hash, + rules: generated.rules, + }); + await f.fundPC(made.wallet, parseEther('50')); + await ( + await f.owner.universal.sendTransaction({ + to: mapped.address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [made.wallet, op.amount * BigInt(2)], + }), + }) + ).wait(); + const agent = await f.agent(made.wallet); + for (const stage of ['first-use', 'subsequent']) { + const oldBalance = await op.resultBalance(); + const stop = observeAgwGas( + agent, + f, + `${category}:${chainNamespace}:${stage}` + ); + try { + const tx = await agent.universal.sendTransaction({ + to: { address: op.target, chain: chainNamespace as CHAIN }, + data: op.calls, + funds: { + amount: op.amount, + token: { + symbol: mapped.symbol, + decimals: mapped.decimals, + address: op.token, + mechanism: 'approve', + }, + }, + }); + f.evidence('unchanged-preset-submitted', { + category, + chainNamespace, + stage, + wallet: made.wallet, + hash: tx.hash, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 180000 }); + expect(receipt.externalStatus).toBe('success'); + expect(receipt.externalTxHash).toBeTruthy(); + const dest = await op.rpc.getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + expect(dest.status).toBe('success'); + expect(await op.resultBalance()).toBeGreaterThan(oldBalance); + await recordAgwPushGas(f, tx.hash, stage); + f.evidence('unchanged-preset-delivered', { + category, + chainNamespace, + stage, + hash: tx.hash, + destinationHash: receipt.externalTxHash, + destinationGasUsed: dest.gasUsed, + destinationGasPaidWei: dest.gasUsed * dest.effectiveGasPrice, + }); + const stored = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (stored.spent?.kind !== 'universal') + throw new Error('Missing universal spend'); + expect(stored.spent.assets[input].spent).toBe( + op.amount * BigInt(stage === 'first-use' ? 1 : 2) + ); + } finally { + stop(); + } + } + } finally { + f.teardown(); + } + }; + it('1. DEXES Sepolia', () => run(...PRESET_ACCEPTANCE_CASES[0]), 600000); + it('2. DEXES Base Sepolia', () => run(...PRESET_ACCEPTANCE_CASES[1]), 600000); + it( + '3. DEXES Arbitrum Sepolia', + () => run(...PRESET_ACCEPTANCE_CASES[2]), + 600000 + ); + it('4. DEXES BNB testnet', () => run(...PRESET_ACCEPTANCE_CASES[3]), 600000); + it('5. LENDING Sepolia', () => run(...PRESET_ACCEPTANCE_CASES[4]), 600000); + it( + '6. LENDING Base Sepolia', + () => run(...PRESET_ACCEPTANCE_CASES[5]), + 600000 + ); + it( + '7. LENDING Arbitrum Sepolia', + () => run(...PRESET_ACCEPTANCE_CASES[6]), + 600000 + ); + it( + '8. LENDING BNB testnet', + () => run(...PRESET_ACCEPTANCE_CASES[7]), + 600000 + ); + it('9. STAKING Sepolia', () => run(...PRESET_ACCEPTANCE_CASES[8]), 600000); +}); diff --git a/packages/core/__e2e__/agw/svm-agent-spl.spec.ts b/packages/core/__e2e__/agw/svm-agent-spl.spec.ts new file mode 100644 index 000000000..a63b2f608 --- /dev/null +++ b/packages/core/__e2e__/agw/svm-agent-spl.spec.ts @@ -0,0 +1,307 @@ +/** Public agent SPL delivery, wallet ATA pin and amount enforcement on devnet. + * Uses the verified test_counter.receive_spl harness; no third-party protocol + * support is implied. Two sends exercise omitted gas on first/subsequent use. + */ +import '@e2e/shared/setup'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import bs58 from 'bs58'; +import type { Idl } from '@coral-xyz/anchor'; +import { encodeFunctionData, erc20Abi, numberToHex, parseEther } from 'viem'; +import { PushChain, CHAIN } from '../../src'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; +import { getPRC20Address } from '../../src/lib/universal/prc20-address'; +import { deriveAgwSvmCea } from '../../src/lib/agentic/codec/svm-accounts'; +import { AGW_E2E_ENABLED, setupAgw, inSeconds } from './_fixture'; +import { + inspectSvmWirePrograms, + svmWireConnection, + SVM_WIRE_CHAIN, + SVM_WIRE_GATEWAY, + SVM_WIRE_PROGRAM, + SVM_WIRE_COUNTER, +} from '../shared/agw-svm-preflight'; +import { observeAgwGas, recordAgwPushGas } from '../shared/agw-gas-evidence'; +import counterIdl from '../../src/lib/orchestrator/svm-idl/__fixtures__/test_counter.idl.json'; +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const SPL = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +const ATA = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); +const amount = BigInt(1000); +const hex = (key: PublicKey | string) => + `0x${new PublicKey(key).toBuffer().toString('hex')}`; +const ataFor = (owner: PublicKey, mint: PublicKey) => + PublicKey.findProgramAddressSync( + [owner.toBuffer(), SPL.toBuffer(), mint.toBuffer()], + ATA + )[0]; + +d('agw public agent spl', () => { + it('1. exact wallet ATA and amount constraints survive create and two omitted-gas deliveries', async () => { + const sol = svmWireConnection(); + await inspectSvmWirePrograms(sol); + const raw = process.env['SOLANA_PRIVATE_KEY']; + if (!raw) + throw new Error('SOLANA_PRIVATE_KEY is required for the owned recipient'); + let recipient: PublicKey; + try { + recipient = Keypair.fromSecretKey(bs58.decode(raw)).publicKey; + } catch { + recipient = Keypair.fromSecretKey( + Uint8Array.from(JSON.parse(raw)) + ).publicKey; + } + const asset = MOVEABLE_TOKEN_CONSTANTS.SOLANA_DEVNET.USDT; + const mint = new PublicKey(asset.address), + recipientATA = ataFor(recipient, mint); + const info = await sol.getAccountInfo(recipientATA); + if (!info || !info.owner.equals(SPL)) + throw new Error( + 'SPL_PREREQUISITE: owned recipient needs an existing standard USDT ATA' + ); + const f = await setupAgw(); + try { + const mapped = getPRC20Address({ + chain: SVM_WIRE_CHAIN, + address: asset.address, + }); + if ( + (await f.push.readContract({ + address: mapped.address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + })) < + amount * BigInt(2) + ) + throw new Error('SPL_PREREQUISITE: owner lacks bounded USDT PRC20'); + const rule = { + agent: f.agentAddress, + chainNamespace: SVM_WIRE_CHAIN, + validUntil: inSeconds(3600), + assets: [ + { + token: asset.address, + maxPerCall: amount, + maxTotal: amount * BigInt(2), + }, + ], + maxGasPerCall: parseEther('20'), + allowedCalls: [ + { + target: SVM_WIRE_PROGRAM, + selector: 'receive_spl(u64)' as const, + pins: [ + { account: 0, expected: SVM_WIRE_COUNTER.toBase58() }, + { + account: 1, + expected: 'agentic:wallet' as const, + token: asset.address, + }, + { account: 2, expected: recipientATA.toBase58() }, + { account: 3, expected: 'agentic:wallet' as const }, + { account: 4, expected: SPL.toBase58() }, + { arg: 0, max: amount }, + ], + }, + ], + }; + const made = await f.owner.agentic.create('agent-spl-acceptance', { + rules: [rule], + }); + const cea = new PublicKey( + Buffer.from( + deriveAgwSvmCea( + made.wallet, + `0x${new PublicKey(SVM_WIRE_GATEWAY).toBuffer().toString('hex')}` + ).address.slice(2), + 'hex' + ) + ); + const ceaATA = ataFor(cea, mint); + const record = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (!('dataPins' in record.rule)) + throw new Error('Expected a decoded Solana rule'); + expect(record.rule).toMatchObject({ + format: 'decoded', + expectedCEA: hex(cea), + maxGasPerCall: parseEther('20'), + assets: [ + { + token: hex(mint), + maxPerCall: amount, + maxTotal: amount * BigInt(2), + }, + ], + }); + // Every authored pin survives exactly; none is dropped or widened. + expect(record.rule.ceaAccounts).toEqual([hex(cea), hex(ceaATA)]); + expect(record.rule.programs).toEqual([ + { + program: hex(SVM_WIRE_PROGRAM), + discriminator: '0xb654fa2e8aa449c4', + discriminatorLen: 8, + dataless: false, + maxAccounts: 255, + }, + ]); + expect(record.rule.pins).toEqual( + [SVM_WIRE_COUNTER, ceaATA, recipientATA, cea, SPL].map((key, i) => ({ + ruleIndex: 0, + accountIndex: i, + expected: hex(key), + })) + ); + expect(record.rule.dataPins).toEqual([ + { + ruleIndex: 0, + fromEnd: false, + offset: 8, + offsetB: 0, + len: 8, + mode: 2, + expected: numberToHex(amount, { size: 32 }), + num: BigInt(0), + den: BigInt(0), + }, + ]); + await f.fundPC(made.wallet, parseEther('41')); + await ( + await f.owner.universal.sendTransaction({ + to: mapped.address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [made.wallet, amount * BigInt(2)], + }), + }) + ).wait(); + const agent = await f.agent(made.wallet); + const idl = JSON.parse(JSON.stringify(counterIdl)) as Idl; + const instruction = idl.instructions.find( + (i) => i.name === 'receive_spl' + )!; + Object.assign(instruction.accounts.find((a) => a.name === 'cea_ata')!, { + address: ceaATA.toBase58(), + }); + Object.assign( + instruction.accounts.find((a) => a.name === 'recipient_ata')!, + { address: recipientATA.toBase58() } + ); + const encoded = (n: bigint, source = idl) => + PushChain.utils.helpers.encodeTxData({ + abi: source, + functionName: 'receive_spl', + args: [n], + }); + const send = (n: bigint, source = idl, burnAmount = n) => + agent.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: SVM_WIRE_PROGRAM }, + data: encoded(n, source), + funds: { token: asset, amount: burnAmount }, + }); + const startNonce = await f.push.getTransactionCount({ + address: f.agentAddress, + }); + await expect( + send(amount + BigInt(1), idl, BigInt(1)) + ).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmDataCeilingExceeded)' }, + }); + const wrong = JSON.parse(JSON.stringify(idl)) as Idl; + Object.assign( + wrong.instructions + .find((i) => i.name === 'receive_spl')! + .accounts.find((a) => a.name === 'cea_ata')!, + { address: recipientATA.toBase58() } + ); + await expect(send(BigInt(1), wrong)).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmAccountPinMismatch)' }, + }); + // Funds may only reach the pinned recipient account. + const misdirected = JSON.parse(JSON.stringify(idl)) as Idl; + Object.assign( + misdirected.instructions + .find((i) => i.name === 'receive_spl')! + .accounts.find((a) => a.name === 'recipient_ata')!, + { + address: ataFor( + new PublicKey('11111111111111111111111111111112'), + mint + ).toBase58(), + } + ); + await expect(send(BigInt(1), misdirected)).rejects.toMatchObject({ + name: 'AgenticRevertError', + decodedError: { name: 'PolicyCheckReverted(SvmAccountPinMismatch)' }, + }); + expect( + await f.push.getTransactionCount({ address: f.agentAddress }) + ).toBe(startNonce); + for (const stage of ['first-use', 'subsequent']) { + const balance = BigInt( + (await sol.getTokenAccountBalance(recipientATA)).value.amount + ); + const stop = observeAgwGas(agent, f, `agent-spl:${stage}`); + try { + const tx = await send(amount); + f.evidence('agent-spl-submitted', { + stage, + wallet: made.wallet, + cea: cea.toBase58(), + ceaATA: ceaATA.toBase58(), + hash: tx.hash, + }); + const result = await tx.wait({ outboundTimeoutMs: 180000 }); + expect(result.externalStatus).toBe('success'); + expect( + BigInt( + (await sol.getTokenAccountBalance(recipientATA)).value.amount + ) + ).toBe(balance + amount); + const parsed = await sol.getParsedTransaction( + result.externalTxHash!, + { commitment: 'confirmed', maxSupportedTransactionVersion: 0 } + ); + expect(parsed?.meta?.err).toBeNull(); + expect( + parsed?.meta?.innerInstructions?.some((g) => + g.instructions.some( + (ix) => + 'parsed' in ix && + ix.program === 'spl-token' && + ['transfer', 'transferChecked'].includes(ix.parsed.type) && + ix.parsed.info.source === ceaATA.toBase58() && + ix.parsed.info.destination === recipientATA.toBase58() && + BigInt( + ix.parsed.info.amount ?? ix.parsed.info.tokenAmount?.amount + ) === amount + ) + ) + ).toBe(true); + await recordAgwPushGas(f, tx.hash, `agent-spl:${stage}`); + f.evidence('agent-spl-delivered', { + stage, + wallet: made.wallet, + hash: tx.hash, + destinationHash: result.externalTxHash, + destinationFeeLamports: parsed?.meta?.fee, + computeUnitsConsumed: parsed?.meta?.computeUnitsConsumed, + }); + } finally { + stop(); + } + } + const updated = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (updated.spent?.kind !== 'universal') + throw new Error('Expected per-asset spend'); + expect(updated.spent.assets[0].spent).toBe(amount * BigInt(2)); + } finally { + f.teardown(); + } + }, 600000); +}); diff --git a/packages/core/__e2e__/agw/svm-memo-live.spec.ts b/packages/core/__e2e__/agw/svm-memo-live.spec.ts new file mode 100644 index 000000000..2acc8b72a --- /dev/null +++ b/packages/core/__e2e__/agw/svm-memo-live.spec.ts @@ -0,0 +1,182 @@ +/** A second Solana program through the public API: SPL Memo v2 (non-Anchor). + * The owner door proves the gateway CPI with the wallet CEA as the signer; the + * agent door proves a rule with a raw 8-byte data prefix plus a CEA account pin + * is accepted, and that a different prefix is refused before broadcast. + */ +import '@e2e/shared/setup'; +import { Connection, PublicKey } from '@solana/web3.js'; +import { erc20Abi, parseEther, toHex, type Hex } from 'viem'; +import { PushChain, CHAIN } from '../../src'; +import { deriveAgwSvmCea } from '../../src/lib/agentic/codec/svm-accounts'; +import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; +import { AGW_E2E_ENABLED, evmClient, inSeconds, setupAgw } from './_fixture'; +import { SVM_WIRE_GATEWAY } from '../shared/agw-svm-preflight'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const MEMO = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; +// The rule matches this 8-byte prefix; Memo accepts any UTF-8 after it. +const PREFIX = 'PUSHAGW:'; +const OTHER = 'OTHERPFX'; +const bytes = (s: string) => Array.from(Buffer.from(s, 'utf8')); +// Memo has no Anchor IDL; this one only lets the SDK place the CEA signer. +PushChain.utils.svm.registerIdl({ + address: MEMO, + metadata: { name: 'spl_memo', version: '2.0.0', spec: '0.1.0' }, + instructions: [PREFIX, OTHER].map((p) => ({ + name: p === PREFIX ? 'memo' : 'other', + discriminator: bytes(p), + accounts: [{ name: 'cea_authority', signer: true }], + args: [], + })), +}); + +d('agw svm memo', () => { + async function expectMemo(sig: string, text: string, cea: string) { + const sol = new Connection('https://api.devnet.solana.com', 'confirmed'); + let tx = await sol.getParsedTransaction(sig, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + for (let retry = 0; !tx && retry < 5; retry++) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + tx = await sol.getParsedTransaction(sig, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + } + expect(tx?.meta?.err).toBeNull(); + const memo = (tx?.meta?.innerInstructions ?? []) + .flatMap((g) => g.instructions) + .find((ix) => ix.programId.toBase58() === MEMO); + expect(memo && 'parsed' in memo ? memo.parsed : undefined).toBe(text); + expect(tx?.meta?.logMessages?.join('\n')).toContain(`Signed by ${cea}`); + } + + it('1. owner door: the gateway runs Memo with the wallet CEA as signer', async () => { + const f = await setupAgw({ deferAgentFunding: true }); + try { + const made = await f.owner.agentic.create('svm-memo-owner', { + rules: [], + }); + await f.fundPC(made.wallet, parseEther('21')); + const owner = await evmClient( + process.env['PUSH_PRIVATE_KEY'] as Hex, + CHAIN.PUSH_TESTNET_DONUT, + f.manifest.network, + made.wallet + ); + const cea = new PublicKey( + Buffer.from( + deriveAgwSvmCea( + made.wallet, + `0x${new PublicKey(SVM_WIRE_GATEWAY).toBuffer().toString('hex')}` + ).address.slice(2), + 'hex' + ) + ).toBase58(); + const text = `${PREFIX}owner ${Date.now()}`; + const tx = await owner.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: MEMO }, + data: toHex(text), + }); + f.evidence('svm-memo-owner-submitted', { + wallet: made.wallet, + hash: tx.hash, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 180000 }); + expect(receipt.externalStatus).toBe('success'); + await expectMemo(receipt.externalTxHash!, text, cea); + f.evidence('svm-memo-owner-delivered', { + hash: tx.hash, + destinationHash: receipt.externalTxHash, + }); + } finally { + f.teardown(); + } + }, 600000); + + it('2. agent door: a raw-prefix rule with a CEA pin runs, another prefix is refused', async () => { + const f = await setupAgw({ deferAgentFunding: true }); + try { + const made = await f.owner.agentic.create('svm-memo-agent', { + rules: [ + { + agent: f.agentAddress, + chainNamespace: CHAIN.SOLANA_DEVNET, + validUntil: inSeconds(3600), + assets: [], + maxGasPerCall: parseEther('20'), + allowedCalls: [ + { + target: MEMO, + selector: toHex(PREFIX) as `0x${string}`, + pins: [{ account: 0, expected: 'agentic:wallet' as const }], + }, + ], + }, + ], + }); + await f.fundPC(made.wallet, parseEther('21')); + await f.fundAgent(); + const agent = await f.agent(made.wallet); + const cea = new PublicKey( + Buffer.from( + deriveAgwSvmCea( + made.wallet, + `0x${new PublicKey(SVM_WIRE_GATEWAY).toBuffer().toString('hex')}` + ).address.slice(2), + 'hex' + ) + ).toBase58(); + const startNonce = await f.push.getTransactionCount({ + address: f.agentAddress, + }); + await expect( + agent.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: MEMO }, + data: toHex(`${OTHER}not allowed`), + }) + ).rejects.toMatchObject({ name: 'AgenticRevertError' }); + expect( + await f.push.getTransactionCount({ address: f.agentAddress }) + ).toBe(startNonce); + const text = `${PREFIX}agent ${Date.now()}`; + const tx = await agent.universal.sendTransaction({ + to: { chain: CHAIN.SOLANA_DEVNET, address: MEMO }, + data: toHex(text), + }); + f.evidence('svm-memo-agent-submitted', { + wallet: made.wallet, + hash: tx.hash, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 180000 }); + expect(receipt.externalStatus).toBe('success'); + await expectMemo(receipt.externalTxHash!, text, cea); + // A zero-amount send names the zero-cap gas token and spends nothing. + const stored = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (stored.spent?.kind !== 'universal') throw new Error('Missing spend'); + expect(stored.spent.assets.every((a) => a.spent === BigInt(0))).toBe( + true + ); + expect( + await f.push.readContract({ + address: getNativePRC20ForChain( + CHAIN.SOLANA_DEVNET, + f.manifest.network + ), + abi: erc20Abi, + functionName: 'balanceOf', + args: [made.wallet], + }) + ).toBe(BigInt(0)); + f.evidence('svm-memo-agent-delivered', { + hash: tx.hash, + destinationHash: receipt.externalTxHash, + }); + } finally { + f.teardown(); + } + }, 600000); +}); diff --git a/packages/core/__e2e__/ci/preflight.ts b/packages/core/__e2e__/ci/preflight.ts index e7bc8c5e3..208c594ac 100644 --- a/packages/core/__e2e__/ci/preflight.ts +++ b/packages/core/__e2e__/ci/preflight.ts @@ -346,7 +346,24 @@ function renderTable(rows: Row[]): void { process.exit(1); } } - if (scenarios.some((s) => s.group === 'agw-svm-wire' || s.file === '__e2e__/agw/svm-public.spec.ts')) { + const presetCases = scenarios + .filter((s) => s.file === '__e2e__/agw/presets-acceptance.spec.ts') + .map((s) => Number(/-(\d+)$/.exec(s.id)?.[1])); + if (presetCases.length) { + const { PRESET_ACCEPTANCE_CASES } = await import('../shared/agw-preset-acceptance'); + // Only the selected cases; their ids end in the spec's case number. + const selected = presetCases.map((n) => { + const entry = PRESET_ACCEPTANCE_CASES[n - 1]; + if (!entry) throw new Error(`Unknown preset acceptance case ${n}`); + return entry; + }); + const blocked = selected.flatMap(([category, chainNamespace]) => { + const result = PushChain.utils.agentic.presets.get(category, { chainNamespace, agent: '0x0000000000000000000000000000000000005678' }); + return result.readiness.ready ? [] : [{ category, chainNamespace, issues: result.readiness.chains[0].issues }]; + }); + if (blocked.length) throw new Error(`PRESET_BLOCKED_BEFORE_FUNDING: ${JSON.stringify(blocked)}`); + } + if (scenarios.some((s) => s.group === 'agw-svm-wire' || s.group === 'agw-svm-agent-spl' || s.file === '__e2e__/agw/svm-public.spec.ts')) { const { inspectSvmWirePrograms, svmWireConnection } = await import('../shared/agw-svm-preflight'); await inspectSvmWirePrograms(svmWireConnection(solRpc)); console.log('Solana wire fixture verified read-only before funding.'); diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index 472727960..a95d91549 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -39,6 +39,12 @@ export const GROUPS = [ 'agw-svm-replay', 'agw-presets', 'agw-presets-expanded', + 'agw-presets-acceptance', + 'agw-presets-routed', + 'agw-exact-routes', + 'agw-svm-memo', + 'agw-svm-agent-spl', + 'agw-gas', 'marketplace', 'marketplace-live', 'marketplace-funding', @@ -1036,6 +1042,59 @@ export const SCENARIOS: Scenario[] = [ needs: { masterPC: '110' }, env: { AGW_E2E: '1' }, }, + ...Array.from({ length: 9 }, (_, i) => ({ + id: `agw-unchanged-preset-${i + 1}`, + group: 'agw-presets-acceptance' as const, + file: '__e2e__/agw/presets-acceptance.spec.ts', + grep: `agw unchanged preset acceptance ${i + 1}\\.`, + needs: { masterPC: '55' }, + env: { AGW_E2E: '1' }, + note: 'Exact public defaults; unavailable routes fail before funding. Exact default PRC20 funds are checked in the scenario.', + })), + // The subset whose default assets all have registered routes (case numbers above). + ...[1, 7].map((n) => ({ + id: `agw-routed-preset-${n}`, + group: 'agw-presets-routed' as const, + file: '__e2e__/agw/presets-acceptance.spec.ts', + grep: `agw unchanged preset acceptance ${n}\\.`, + needs: { masterPC: '55' }, + env: { AGW_E2E: '1' }, + note: 'DEXES Sepolia and LENDING Arbitrum Sepolia; the owner must hold the exact default PRC20s.', + })), + ...Array.from({ length: 4 }, (_, i) => ({ + id: `agw-evm-gas-${i + 1}`, + group: 'agw-gas' as const, + file: '__e2e__/agw/gas-live.spec.ts', + grep: `agw live gas sizing ${i + 1}\\.`, + needs: { masterPC: '26' }, + env: { AGW_E2E: '1' }, + note: 'Two native outbounds; exact per-chain PRC20 balances are checked by the scenario before wallet creation.', + })), + ...['Sepolia', 'Base Sepolia', 'Arbitrum Sepolia'].map((name, i) => ({ + id: `agw-exact-route-${i + 1}`, + group: 'agw-exact-routes' as const, + file: '__e2e__/agw/exact-routes-live.spec.ts', + grep: `agw exact route outbound ${i + 1}\\.`, + needs: { masterPC: '26' }, + env: { AGW_E2E: '1' }, + note: `${name} Circle USDC node route; the owner must hold its exact PRC20.`, + })), + ...[1, 2].map((n) => ({ + id: `agw-svm-memo-${n}`, + group: 'agw-svm-memo' as const, + file: '__e2e__/agw/svm-memo-live.spec.ts', + grep: `agw svm memo ${n}\\.`, + needs: { masterPC: '26' }, + env: { AGW_E2E: '1' }, + })), + { + id: 'agw-agent-spl-acceptance', + group: 'agw-svm-agent-spl', + file: '__e2e__/agw/svm-agent-spl.spec.ts', + grep: 'agw public agent spl 1\\.', + needs: { masterPC: '45', masterUsdtSol: '0.002' }, + env: { AGW_E2E: '1' }, + }, { id: 'agw-preset-uniswap-live', group: 'agw-presets', diff --git a/packages/core/__e2e__/shared/agw-gas-evidence.ts b/packages/core/__e2e__/shared/agw-gas-evidence.ts new file mode 100644 index 000000000..28f22f4ef --- /dev/null +++ b/packages/core/__e2e__/shared/agw-gas-evidence.ts @@ -0,0 +1,44 @@ +/** Record quotes and measured gas separately from permission ceilings. */ +import type { PushChain } from '../../src'; +import type { AgenticRuntime } from '../../src/lib/agentic/runtime'; +import type { AgwFixture } from '../agw/_fixture'; +import type { Hex } from 'viem'; +export function observeAgwGas(client: PushChain, f: AgwFixture, stage: string) { + const rt = (client as unknown as { agenticRuntime: AgenticRuntime }) + .agenticRuntime; + const previous = rt.quoteOutbound; + rt.quoteOutbound = async (...args) => { + const quote = await previous(...args); + f.evidence('agw-gas-quote', { + stage, + token: args[0], + requestedGasLimit: args[1], + chain: args[2], + protocolFeePCWei: quote.protocolFee, + quotedGasBudgetPCWei: quote.nativeValueForGas, + gasLimitUsed: quote.gasLimitUsed, + attachedPCWei: quote.protocolFee + quote.nativeValueForGas, + gasTokenFee: quote.gasFee, + poolPcInRequired: quote.poolPcIn, + }); + return quote; + }; + return () => { + rt.quoteOutbound = previous; + }; +} +export async function recordAgwPushGas( + f: AgwFixture, + hash: string, + stage: string +) { + const receipt = await f.push.getTransactionReceipt({ hash: hash as Hex }); + f.evidence('agw-push-gas', { + stage, + hash, + block: receipt.blockNumber, + gasUsed: receipt.gasUsed, + effectiveGasPrice: receipt.effectiveGasPrice, + transactionGasPaidPCWei: receipt.gasUsed * receipt.effectiveGasPrice, + }); +} diff --git a/packages/core/__e2e__/shared/agw-preset-acceptance.ts b/packages/core/__e2e__/shared/agw-preset-acceptance.ts new file mode 100644 index 000000000..683b60bb5 --- /dev/null +++ b/packages/core/__e2e__/shared/agw-preset-acceptance.ts @@ -0,0 +1,108 @@ +/** Assertions shared by live acceptance and the actual-contract local suite. + * No route substitution or asset removal is permitted in this acceptance path. + */ +import { + encodeAbiParameters, + getAddress, + parseAbiItem, + toFunctionSelector, + type AbiParameter, + type Hex, +} from 'viem'; +import type { Rule, RulesRecord } from '../../src/lib/agentic/agentic.types'; +import type { PresetCategory } from '../../src/lib/agentic/presets/types'; + +export const PRESET_ACCEPTANCE_CASES = [ + ['DEXES', 'eip155:11155111'], + ['DEXES', 'eip155:84532'], + ['DEXES', 'eip155:421614'], + ['DEXES', 'eip155:97'], + ['LENDING', 'eip155:11155111'], + ['LENDING', 'eip155:84532'], + ['LENDING', 'eip155:421614'], + ['LENDING', 'eip155:97'], + ['STAKING', 'eip155:11155111'], +] as const satisfies readonly (readonly [PresetCategory, `eip155:${string}`])[]; + +function words(param: AbiParameter): number { + if (param.type === 'tuple' && 'components' in param) + return param.components.reduce((n, c) => n + words(c), 0); + if (/\[|bytes$|string$/.test(param.type)) + throw new Error('Acceptance pin needs a static argument'); + return 1; +} +function pinLocation( + inputs: readonly AbiParameter[], + path: number | readonly number[] +): { arg: number; param: AbiParameter } { + const parts = typeof path === 'number' ? [path] : path; + let current = inputs, + offset = 0, + selected: AbiParameter | undefined; + for (let depth = 0; depth < parts.length; depth++) { + const index = parts[depth]; + selected = current[index]; + if (!selected) throw new Error('Invalid pin argument path'); + offset += current.slice(0, index).reduce((n, c) => n + words(c), 0); + if (depth < parts.length - 1) { + if (selected.type !== 'tuple' || !('components' in selected)) + throw new Error('Invalid pin tuple path'); + current = selected.components; + } + } + return { arg: offset, param: selected! }; +} +export function expectStoredPreset(input: Rule, record: RulesRecord): void { + if (!('allowedCalls' in record.rule)) + throw new Error('Expected an EVM rule record'); + expect(record.enabled).toBe(true); + expect(record.agent).toBe(getAddress(input.agent)); + expect(record.chainNamespace).toBe(input.chainNamespace); + expect(record.validUntil).toBe(input.validUntil); + expect(record.rule.maxGasPerCall).toBe(input.maxGasPerCall); + expect(record.rule.assets).toEqual( + input.assets!.map((a) => ({ + token: getAddress( + typeof a.token === 'string' ? a.token : a.token.address + ), + maxPerCall: a.maxPerCall, + maxTotal: a.maxTotal ?? BigInt(0), + })) + ); + expect(record.spent).toEqual({ + kind: 'universal', + assets: input.assets!.map((a) => ({ + token: getAddress( + typeof a.token === 'string' ? a.token : a.token.address + ), + spent: BigInt(0), + })), + }); + expect(record.rule.allowedCalls).toEqual( + input.allowedCalls.map((c) => { + const fn = parseAbiItem(`function ${c.selector}`); + if (fn.type !== 'function') throw new Error('Expected a function'); + return { + target: getAddress(c.target), + selector: toFunctionSelector(fn), + maxValue: c.maxValue ?? BigInt(0), + pins: (c.pins ?? []).map((p) => { + if (!('arg' in p) || p.arg === undefined) + throw new Error('Expected authored argument pin'); + const loc = pinLocation(fn.inputs, p.arg); + if ('max' in p) return { arg: loc.arg, max: p.max }; + return { + arg: loc.arg, + expected: + p.expected === 'agentic:wallet' + ? 'agentic:wallet' + : (encodeAbiParameters( + [loc.param], + [p.expected as never] + ).toLowerCase() as Hex), + }; + }), + }; + }) + ); +} diff --git a/packages/core/scripts/check-agw-gas-quotes.js b/packages/core/scripts/check-agw-gas-quotes.js new file mode 100644 index 000000000..8eac1e502 --- /dev/null +++ b/packages/core/scripts/check-agw-gas-quotes.js @@ -0,0 +1,122 @@ +/** Read-only current outbound quotes. These are observations, not validated gas + * defaults or measured destination execution fees. No signer/keys are loaded. + * The attached budget depends on the agent's origin: Push-native signers use + * the fixed PC/USD fallback, UEA signers the Donut WPC/stable pool for their + * origin. poolPcIn is the PC the gateway's gas swap needs at quote time. + * Build core, then: node packages/core/scripts/check-agw-gas-quotes.js [report.json] + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { parseEther } = require('viem'); +const built = path.resolve(__dirname, '../../../dist/packages/core/src'); +const { PushChain } = require(built); +const { CHAIN, PUSH_NETWORK } = PushChain.CONSTANTS; +const { getNativePRC20ForChain } = require(path.join( + built, + 'lib/orchestrator/internals/helpers' +)); +const { MOVEABLE_TOKEN_CONSTANTS } = require(path.join( + built, + 'lib/constants/tokens' +)); +const { getPRC20Address } = require(path.join( + built, + 'lib/universal/prc20-address' +)); +async function main() { + const wallet = '0x0000000000000000000000000000000000005678'; + // A wallet with no CEA token account, so SPL quotes include first-use rent. + const freshWallet = '0x00000000000000000000000000000000000a6e01'; + const usdtSol = MOVEABLE_TOKEN_CONSTANTS.SOLANA_DEVNET.USDT; + const report = { + at: new Date().toISOString(), + readOnly: true, + network: PUSH_NETWORK.TESTNET_DONUT, + note: 'Current quotes only; first-use and subsequent labels describe gas-limit inputs, not executed transactions.', + origins: [], + }; + for (const origin of [CHAIN.PUSH_TESTNET_DONUT, CHAIN.ETHEREUM_SEPOLIA]) { + const client = await PushChain.initialize( + { address: wallet, chain: origin }, + { network: PUSH_NETWORK.TESTNET_DONUT } + ); + const rt = client.agenticRuntime; + if (!rt.isReadOnly) throw new Error('Quote audit must be read-only'); + const entry = { + signerOrigin: origin, + pushBlockBefore: String(await rt.reader.getBlockNumber()), + cases: [], + }; + report.origins.push(entry); + const cases = []; + for (const chain of [ + CHAIN.ETHEREUM_SEPOLIA, + CHAIN.BASE_SEPOLIA, + CHAIN.ARBITRUM_SEPOLIA, + CHAIN.BNB_TESTNET, + ]) + cases.push( + [chain, 'default-new-account', BigInt(2000000)], + [chain, 'route-default-zero-input', BigInt(0)] + ); + const sol = CHAIN.SOLANA_DEVNET; + cases.push( + [ + sol, + 'legacy-million-unit-fallback', + BigInt(1000000), + { wallet, burnAmount: BigInt(10000) }, + ], + [ + sol, + 'sdk-default-route-sizing', + BigInt(0), + { wallet, burnAmount: BigInt(10000) }, + ], + [ + sol, + 'spl-first-use-route-sizing', + BigInt(0), + { + wallet: freshWallet, + splMintBase58: usdtSol.address, + burnAmount: BigInt(1000), + }, + getPRC20Address(usdtSol).address, + ] + ); + for (const [chain, stage, gasLimit, svm, token] of cases) { + const c = { chain, stage, inputGasLimit: gasLimit }; + entry.cases.push(c); + try { + const quote = await rt.quoteOutbound( + token ?? getNativePRC20ForChain(chain, rt.network), + gasLimit, + chain, + svm + ); + Object.assign(c, quote, { + attachedPCWei: quote.protocolFee + quote.nativeValueForGas, + fits20PCCeiling: + quote.protocolFee + quote.nativeValueForGas <= parseEther('20'), + }); + } catch (e) { + c.error = e.shortMessage || e.message; + } + } + entry.pushBlockAfter = String(await rt.reader.getBlockNumber()); + } + const json = JSON.stringify( + report, + (_k, v) => (typeof v === 'bigint' ? String(v) : v), + 2 + ); + if (process.argv[2]) fs.writeFileSync(process.argv[2], json); + console.log(json); + if (report.origins.some((o) => o.cases.some((c) => c.error))) + process.exitCode = 1; +} +main().catch((e) => { + console.error(e.shortMessage || e.message); + process.exitCode = 1; +}); diff --git a/packages/core/scripts/check-agw-preset-compatibility.js b/packages/core/scripts/check-agw-preset-compatibility.js new file mode 100644 index 000000000..f746d0447 --- /dev/null +++ b/packages/core/scripts/check-agw-preset-compatibility.js @@ -0,0 +1,323 @@ +/** Read-only protocol compatibility of the exact routed testnet tokens. + * Build core first. No signer, environment keys, funding or transactions. + * Usage: node packages/core/scripts/check-agw-preset-compatibility.js [report.json] + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { createPublicClient, http, parseAbi, zeroAddress } = require('viem'); +const built = path.resolve(__dirname, '../../../dist/packages/core/src'); +const { PushChain } = require(built); +const { MOVEABLE_TOKENS } = require(path.join(built, 'lib/constants/tokens')); +const { exactAgenticRoutes, resolveAgenticPrc20 } = require(path.join( + built, + 'lib/agentic/token-routes' +)); +const { PRESET_CATALOG } = require(path.join( + built, + 'lib/agentic/presets/catalog' +)); +const DONUT = PushChain.CONSTANTS.PUSH_NETWORK.TESTNET_DONUT; +const abi = parseAbi([ + 'function factory() view returns (address)', + 'function getPool(address,address,uint24) view returns (address)', + 'function liquidity() view returns (uint128)', + 'function getReservesList() view returns (address[])', + 'function underlying() view returns (address)', + 'function underlyingToken() view returns (address)', + 'function SOURCE_CHAIN_NAMESPACE() view returns (string)', + 'function SOURCE_TOKEN_ADDRESS() view returns (string)', +]); +const endpoints = { + 'eip155:11155111': 'https://ethereum-sepolia-rpc.publicnode.com', + 'eip155:84532': 'https://base-sepolia-rpc.publicnode.com', + 'eip155:421614': 'https://arbitrum-sepolia-rpc.publicnode.com', + 'eip155:97': 'https://bsc-testnet-rpc.publicnode.com', +}; +async function main() { + const report = { at: new Date().toISOString(), readOnly: true, chains: [] }; + const push = createPublicClient({ + transport: http('https://evm.donut.rpc.push.org/', { + timeout: 15000, + retryCount: 1, + }), + }); + report.pushBlock = String(await push.getBlockNumber()); + if ((await push.getChainId()) !== 42101) throw new Error('Expected Donut'); + for (const [chain, endpoint] of Object.entries(endpoints)) { + const entry = { chain, candidates: [], protocols: [], presets: [] }; + report.chains.push(entry); + const rpc = createPublicClient({ + transport: http(endpoint, { timeout: 12000, retryCount: 1 }), + }); + try { + const block = await rpc.getBlockNumber(); + entry.block = String(block); + if ((await rpc.getChainId()) !== Number(chain.split(':')[1])) + throw new Error('Destination chain mismatch'); + const read = (address, functionName, args = []) => + rpc.readContract({ + address, + abi, + functionName, + args, + blockNumber: block, + }); + // Registry tokens plus exact node routes the symbol registry cannot hold. + const sources = new Map(); + for (const token of MOVEABLE_TOKENS[chain] || []) + if (token.mechanism !== 'native') + sources.set(token.address.toLowerCase(), token); + for (const route of exactAgenticRoutes(chain, DONUT)) + sources.set(route.source.toLowerCase(), { + address: route.source, + symbol: route.symbol, + }); + for (const token of sources.values()) { + let mapped; + try { + mapped = resolveAgenticPrc20( + { chain, address: token.address }, + DONUT + ); + } catch (e) { + entry.candidates.push({ + token: token.address, + symbol: token.symbol, + unavailable: e.shortMessage || e.message, + }); + continue; + } + try { + const [sourceChain, source] = await Promise.all([ + push.readContract({ + address: mapped.address, + abi, + functionName: 'SOURCE_CHAIN_NAMESPACE', + blockNumber: BigInt(report.pushBlock), + }), + push + .readContract({ + address: mapped.address, + abi, + functionName: 'SOURCE_TOKEN_ADDRESS', + blockNumber: BigInt(report.pushBlock), + }) + .catch(() => + push.readContract({ + address: mapped.address, + abi: parseAbi([ + 'function SOURCE_TOKEN_ADDRESS() view returns (address)', + ]), + functionName: 'SOURCE_TOKEN_ADDRESS', + blockNumber: BigInt(report.pushBlock), + }) + ), + ]); + entry.candidates.push({ + token: token.address, + symbol: token.symbol, + prc20: mapped.address, + identityVerified: + sourceChain === chain && + source.toLowerCase() === token.address.toLowerCase(), + }); + } catch (e) { + // An RPC failure is unknown, not unsupported. + entry.candidates.push({ + token: token.address, + symbol: token.symbol, + prc20: mapped.address, + readError: e.shortMessage || e.message, + }); + } + } + const routed = entry.candidates.filter((c) => c.identityVerified); + for (const preset of PRESET_CATALOG) { + const def = preset.chains.find((c) => c.chainNamespace === chain); + if (!def) continue; + const generated = PushChain.utils.agentic.presets.get(preset.constant, { + chainNamespace: chain, + agent: '0x0000000000000000000000000000000000005678', + }); + entry.presets.push({ + category: preset.constant, + readiness: generated.readiness, + assets: generated.rules[0].assets, + }); + if (def.swapRouters) + for (const router of def.swapRouters) { + const result = { + app: router.app, + kind: 'dex', + router: router.address, + pairs: [], + }; + entry.protocols.push(result); + try { + result.factory = await read(router.address, 'factory'); + // The preset's own pair: one routed side is enough to trade into the other. + const pair = generated.rules[0].assets.map((a) => + typeof a.token === 'string' ? a.token : a.token.address + ); + if (pair.length === 2) { + result.defaultPair = { + tokens: pair, + routed: pair.map((t) => + routed.some( + (c) => c.token.toLowerCase() === t.toLowerCase() + ) + ), + pools: [], + }; + for (const fee of [100, 500, 2500, 3000, 10000]) { + try { + const pool = await read(result.factory, 'getPool', [ + ...pair, + fee, + ]); + if (pool === zeroAddress) continue; + result.defaultPair.pools.push({ + fee, + pool, + liquidity: String(await read(pool, 'liquidity')), + }); + } catch (e) { + result.defaultPair.pools.push({ + fee, + error: e.shortMessage || e.message, + }); + } + } + } + for (let i = 0; i < routed.length; i++) + for (let j = i + 1; j < routed.length; j++) { + const pair = { + tokens: [routed[i].token, routed[j].token], + pools: [], + }; + result.pairs.push(pair); + for (const fee of [100, 500, 2500, 3000, 10000]) { + try { + const pool = await read(result.factory, 'getPool', [ + ...pair.tokens, + fee, + ]); + if (pool === zeroAddress) continue; + pair.pools.push({ + fee, + pool, + liquidity: String(await read(pool, 'liquidity')), + }); + } catch (e) { + pair.pools.push({ + fee, + error: e.shortMessage || e.message, + }); + } + } + } + } catch (e) { + result.error = e.shortMessage || e.message; + } + } + const aave = def.allowedCalls.find((c) => c.app === 'Aave V3'); + if (aave) { + const result = { app: 'Aave V3', kind: 'lending', pool: aave.target }; + entry.protocols.push(result); + try { + result.reserves = await read(aave.target, 'getReservesList'); + result.routedReserves = routed.filter((c) => + result.reserves.some( + (a) => a.toLowerCase() === c.token.toLowerCase() + ) + ); + } catch (e) { + result.error = e.shortMessage || e.message; + } + } + const markets = [ + ...new Map( + def.allowedCalls + .filter((c) => c.budget?.kind === 'fixed-token') + .map((c) => [c.target, c]) + ).values(), + ]; + for (const market of markets) { + const result = { + app: market.app, + kind: 'market', + market: market.target, + }; + entry.protocols.push(result); + try { + result.underlying = await read(market.target, 'underlying'); + result.routed = routed.some( + (c) => c.token.toLowerCase() === result.underlying.toLowerCase() + ); + } catch (e) { + result.error = e.shortMessage || e.message; + } + } + if (preset.constant === 'STAKING') { + const strategy = '0x424246eF71b01ee33aA33aC590fd9a0855F5eFbc'; + const result = { app: 'EigenLayer', kind: 'strategy', strategy }; + entry.protocols.push(result); + try { + result.underlying = await read(strategy, 'underlyingToken'); + result.routed = routed.some( + (c) => c.token.toLowerCase() === result.underlying.toLowerCase() + ); + } catch (e) { + result.error = e.shortMessage || e.message; + } + } + } + } catch (e) { + entry.error = e.shortMessage || e.message; + } + } + const output = process.argv[2]; + if (output) + fs.writeFileSync( + output, + JSON.stringify( + report, + (_k, v) => (typeof v === 'bigint' ? String(v) : v), + 2 + ) + ); + for (const c of report.chains) + console.log( + JSON.stringify( + { + chain: c.chain, + block: c.block, + error: c.error, + routed: c.candidates + .filter((x) => x.identityVerified) + .map((x) => x.symbol), + protocols: c.protocols, + }, + null, + 2 + ) + ); + if ( + report.chains.some( + (c) => + c.error || + c.candidates.some((x) => x.readError) || + c.protocols.some( + (p) => + p.error || + p.pairs?.some((x) => x.pools.some((y) => y.error)) || + p.defaultPair?.pools.some((y) => y.error) + ) + ) + ) + process.exitCode = 1; +} +main().catch((e) => { + console.error(e.shortMessage || e.message); + process.exitCode = 1; +}); diff --git a/packages/core/scripts/check-agw-route-registry.js b/packages/core/scripts/check-agw-route-registry.js new file mode 100644 index 000000000..f084db1b1 --- /dev/null +++ b/packages/core/scripts/check-agw-route-registry.js @@ -0,0 +1,192 @@ +/** Read-only drift check between the SDK's token routes and the Push node's + * token registry (uregistry, read through Tendermint abci_query). No keys. + * - mismatch: the SDK maps a source token to a different PRC20 than the node. + * - sdkOnly: the SDK maps a PRC20 the node does not list as enabled. + * - nodeOnly: an enabled node route the SDK does not resolve (informational). + * Exit 1 on any mismatch or sdkOnly entry. + * Build core, then: node packages/core/scripts/check-agw-route-registry.js [report.json] + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { getAddress } = require('viem'); +const built = path.resolve(__dirname, '../../../dist/packages/core/src'); +const { PushChain } = require(built); +const { SYNTHETIC_PUSH_ERC20 } = require(path.join( + built, + 'lib/constants/chain' +)); +const { MOVEABLE_TOKENS } = require(path.join(built, 'lib/constants/tokens')); +const { getPRC20Address } = require(path.join( + built, + 'lib/universal/prc20-address' +)); +const { getNativePRC20ForChain } = require(path.join( + built, + 'lib/orchestrator/internals/helpers' +)); +const { exactAgenticRoutes, resolveAgenticPrc20 } = require(path.join( + built, + 'lib/agentic/token-routes' +)); +const { PRESET_CATALOG } = require(path.join( + built, + 'lib/agentic/presets/catalog' +)); +const RPC = 'https://donut.rpc.push.org:443'; +const DONUT = PushChain.CONSTANTS.PUSH_NETWORK.TESTNET_DONUT; +const NATIVE = new Set([ + '0x0000000000000000000000000000000000000000', + '11111111111111111111111111111111', +]); + +function varint(buf, i) { + let x = BigInt(0), + s = BigInt(0); + for (;;) { + const b = buf[i++]; + x |= BigInt(b & 0x7f) << s; + if (!(b & 0x80)) return [x, i]; + s += BigInt(7); + } +} +function fields(buf) { + const out = []; + let i = 0; + while (i < buf.length) { + let key; + [key, i] = varint(buf, i); + const no = Number(key >> BigInt(3)), + wt = Number(key & BigInt(7)); + if (wt === 0) { + let v; + [v, i] = varint(buf, i); + out.push([no, v]); + } else if (wt === 2) { + let l; + [l, i] = varint(buf, i); + out.push([no, buf.subarray(i, i + Number(l))]); + i += Number(l); + } else if (wt === 5) i += 4; + else if (wt === 1) i += 8; + else throw new Error(`unsupported protobuf wire type ${wt}`); + } + return out; +} +const text = (b) => Buffer.from(b).toString('utf8'); +function tokenConfig(buf) { + const t = {}; + for (const [no, v] of fields(buf)) { + if (no === 1) t.chain = text(v); + else if (no === 2) t.address = text(v); + else if (no === 4) t.symbol = text(v); + else if (no === 5) t.decimals = Number(v); + else if (no === 6) t.enabled = v === BigInt(1); + else if (no === 9) + for (const [n2, v2] of fields(v)) if (n2 === 2) t.prc20 = text(v2); + } + return t; +} +async function nodeTokenConfigs() { + const info = await (await fetch(`${RPC}/abci_info`)).json(); + // QueryAllTokenConfigsRequest { pagination: { limit: 1000 } } + const data = '0a0318e807'; + const r = await ( + await fetch( + `${RPC}/abci_query?path=%22/uregistry.v1.Query/AllTokenConfigs%22&data=0x${data}` + ) + ).json(); + const res = r.result && r.result.response; + if (!res || res.code) + throw new Error(`abci_query failed: ${JSON.stringify(r)}`); + return { + node: info.result.response.version, + height: res.height, + configs: fields(Buffer.from(res.value || '', 'base64')) + .filter(([no]) => no === 1) + .map(([, v]) => tokenConfig(v)), + }; +} +function sdkResolve(chain, address) { + if (NATIVE.has(address)) + return getAddress(getNativePRC20ForChain(chain, DONUT)); + if (chain.startsWith('solana:')) { + const token = (MOVEABLE_TOKENS[chain] || []).find( + (t) => t.address === address + ); + if (!token) throw new Error('not in SDK registry'); + return getAddress(getPRC20Address(token, { network: DONUT }).address); + } + return resolveAgenticPrc20({ chain, address }, DONUT).address; +} +async function main() { + const { node, height, configs } = await nodeTokenConfigs(); + const report = { + at: new Date().toISOString(), + readOnly: true, + rpc: RPC, + node, + height, + matched: [], + mismatch: [], + nodeOnly: [], + sdkOnly: [], + presetTokens: [], + }; + const enabled = configs.filter((c) => c.enabled && c.prc20); + const nodePrc20s = new Set(enabled.map((c) => getAddress(c.prc20))); + for (const c of enabled) { + const entry = { + chain: c.chain, + address: c.address, + symbol: c.symbol, + decimals: c.decimals, + nodePrc20: getAddress(c.prc20), + }; + try { + const sdkPrc20 = sdkResolve(c.chain, c.address); + (sdkPrc20 === entry.nodePrc20 ? report.matched : report.mismatch).push({ + ...entry, + sdkPrc20, + }); + } catch (e) { + report.nodeOnly.push({ ...entry, sdk: e.message }); + } + } + const sdkPrc20s = new Map(); + for (const [key, address] of Object.entries(SYNTHETIC_PUSH_ERC20[DONUT])) + sdkPrc20s.set(getAddress(address), key); + for (const chain of new Set(enabled.map((c) => c.chain))) + for (const route of exactAgenticRoutes(chain, DONUT)) + sdkPrc20s.set(getAddress(route.prc20), `exact:${chain}:${route.source}`); + for (const [prc20, key] of sdkPrc20s) + if (!nodePrc20s.has(prc20)) report.sdkOnly.push({ key, prc20 }); + for (const preset of PRESET_CATALOG) + for (const chain of preset.chains) { + if (!enabled.some((c) => c.chain === chain.chainNamespace)) continue; + for (const token of chain.defaultTokens || []) { + const route = enabled.find( + (c) => + c.chain === chain.chainNamespace && + c.address.toLowerCase() === token.toLowerCase() + ); + report.presetTokens.push({ + preset: preset.constant, + chain: chain.chainNamespace, + token, + nodePrc20: route ? getAddress(route.prc20) : null, + }); + } + } + const json = JSON.stringify( + report, + (_k, v) => (typeof v === 'bigint' ? String(v) : v), + 2 + ); + if (process.argv[2]) fs.writeFileSync(process.argv[2], json); + console.log(json); + if (report.mismatch.length || report.sdkOnly.length) process.exitCode = 1; +} +main().catch((e) => { + console.error(e.message); + process.exitCode = 1; +}); diff --git a/packages/core/src/lib/agentic/__tests__/gas-budget.spec.ts b/packages/core/src/lib/agentic/__tests__/gas-budget.spec.ts new file mode 100644 index 000000000..a69855616 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/gas-budget.spec.ts @@ -0,0 +1,220 @@ +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import type { Orchestrator } from '../../orchestrator/orchestrator'; +import type { OrchestratorContext } from '../../orchestrator/internals/context'; +import { quoteGasPoolCost } from '../../orchestrator/internals/gas-pool-quote'; +import { agwGasBudget } from '../execution/gas-budget'; +import { createAgenticRuntime } from '../runtime'; +import { ADDR } from './fake-chain'; + +// One shared fn: requireActual re-enters this factory through circular imports. +const mockQuery = jest.fn(); +jest.mock('../../orchestrator/internals/gas-calculator', () => ({ + ...jest.requireActual('../../orchestrator/internals/gas-calculator'), + queryOutboundGasFee: (...args: unknown[]) => mockQuery(...args), +})); +const mockFinalize = jest.fn(); +jest.mock('../../orchestrator/internals/svm-rent', () => ({ + ...jest.requireActual('../../orchestrator/internals/svm-rent'), + ensureSvmFinalizeGasBudgetQuote: (...args: unknown[]) => + mockFinalize(...args), +})); +const PC = BigInt(10) ** BigInt(18); + +describe('AGW gas budget', () => { + it('excludes protocolFee, which the wallet attaches separately', () => { + expect( + agwGasBudget({ protocolFee: BigInt(3), nativeValueForGas: BigInt(13) }) + ).toBe(BigInt(10)); + }); + it('keeps the over-$10 share the sizer splits off for other routes', () => { + expect( + agwGasBudget({ + protocolFee: BigInt(0), + nativeValueForGas: BigInt(100) * PC, + sizing: { category: 'C', overflowNativePc: BigInt(806) * PC }, + }) + ).toBe(BigInt(906) * PC); + }); + it('never attaches less than the pool cost plus 20%, rounded up', () => { + // A Sepolia-UEA BNB first send: sized 23.00 PC, pool needs 23.65 PC. + const sized = BigInt('22998723343893328290'); + const pool = BigInt('23653617545786043528'); + expect( + agwGasBudget({ protocolFee: BigInt(0), nativeValueForGas: sized }, pool) + ).toBe((pool * BigInt(12) + BigInt(9)) / BigInt(10)); + // The $1 floor still wins when the pool is cheap (the observed case). + expect( + agwGasBudget( + { protocolFee: BigInt(0), nativeValueForGas: BigInt(10) * PC }, + BigInt('2069650875064483730') + ) + ).toBe(BigInt(10) * PC); + }); +}); + +describe('pool cost quote', () => { + const ctx = (reads: Record) => + ({ + pushNetwork: PUSH_NETWORK.TESTNET_DONUT, + printTraces: false, + pushClient: { + readContract: jest.fn( + async ({ functionName }: { functionName: string }) => { + const value = reads[functionName]; + if (value instanceof Error) throw value; + return value; + } + ), + }, + } as unknown as OrchestratorContext); + it('returns the exact-output WPC amount for the gas fee', async () => { + const c = ctx({ + WPC: ADDR.other, + defaultFeeTier: 500, + quoteExactOutputSingle: [BigInt(7), BigInt(0), 0, BigInt(0)], + }); + await expect( + quoteGasPoolCost(c, ADDR.target, ADDR.agent, BigInt(5)) + ).resolves.toBe(BigInt(7)); + expect( + (c.pushClient.readContract as jest.Mock).mock.calls[2][0].args[0] + ).toEqual({ + tokenIn: ADDR.other, + tokenOut: ADDR.agent, + amount: BigInt(5), + fee: 500, + sqrtPriceLimitX96: BigInt(0), + }); + }); + it('is unknown without a fee tier, a fee or a working quoter', async () => { + await expect( + quoteGasPoolCost( + ctx({ WPC: ADDR.other, defaultFeeTier: 0 }), + ADDR.target, + ADDR.agent, + BigInt(5) + ) + ).resolves.toBeUndefined(); + await expect( + quoteGasPoolCost(ctx({}), ADDR.target, ADDR.agent, BigInt(0)) + ).resolves.toBeUndefined(); + await expect( + quoteGasPoolCost( + ctx({ + WPC: ADDR.other, + defaultFeeTier: 500, + quoteExactOutputSingle: new Error('no pool'), + }), + ADDR.target, + ADDR.agent, + BigInt(5) + ) + ).resolves.toBeUndefined(); + }); +}); + +describe('AGW runtime quotes', () => { + const quote = (gasFee: bigint) => ({ + gasToken: ADDR.agent, + gasFee, + protocolFee: BigInt(2), + nativeValueForGas: BigInt(12), + gasPrice: BigInt(1), + gasLimitUsed: BigInt(500000), + universalCoreAddress: ADDR.target, + sizing: { category: 'A', overflowNativePc: BigInt(0) }, + }); + const runtime = () => { + const readContract = jest.fn( + async ({ functionName }: { functionName: string }) => + functionName === 'WPC' + ? ADDR.other + : functionName === 'defaultFeeTier' + ? 500 + : functionName === 'baseGasLimitByChainNamespace' + ? BigInt(500000) + : [BigInt(50), BigInt(0), 0, BigInt(0)] + ); + return createAgenticRuntime( + { + getNetwork: () => PUSH_NETWORK.TESTNET_DONUT, + getRpcUrls: () => ({}), + pushNetwork: PUSH_NETWORK.TESTNET_DONUT, + pushClient: { readContract }, + } as unknown as Orchestrator, + true + ); + }; + const svm = { wallet: ADDR.owner, burnAmount: BigInt(1) }; + beforeEach(() => { + mockQuery.mockReset(); + mockFinalize.mockReset(); + }); + + it('return the pool-safe budget and keep protocolFee separate', async () => { + mockQuery.mockResolvedValue(quote(BigInt(5))); + await expect( + runtime().quoteOutbound(ADDR.agent, BigInt(0), CHAIN.ETHEREUM_SEPOLIA) + ).resolves.toEqual({ + protocolFee: BigInt(2), + nativeValueForGas: BigInt(60), + gasLimitUsed: BigInt(500000), + gasFee: BigInt(5), + poolPcIn: BigInt(50), + }); + expect(mockQuery).toHaveBeenCalledWith( + expect.anything(), + ADDR.agent, + BigInt(0), + CHAIN.ETHEREUM_SEPOLIA + ); + }); + it('refuse an explicit gasLimit below the chain base before quoting', async () => { + await expect( + runtime().quoteOutbound( + ADDR.agent, + BigInt(100000), + CHAIN.ETHEREUM_SEPOLIA + ) + ).rejects.toMatchObject({ + code: 'INVALID_RULE', + details: { field: 'gasLimit', minimum: BigInt(500000) }, + }); + expect(mockQuery).not.toHaveBeenCalled(); + mockQuery.mockResolvedValue(quote(BigInt(5))); + await expect( + runtime().quoteOutbound( + ADDR.agent, + BigInt(500000), + CHAIN.ETHEREUM_SEPOLIA + ) + ).resolves.toMatchObject({ gasFee: BigInt(5) }); + }); + it('re-quote a Solana budget left short by a gas-price change', async () => { + mockQuery.mockResolvedValue(quote(BigInt(1))); + mockFinalize + .mockImplementationOnce(async ({ onMinimum }) => { + onMinimum(BigInt(10)); + return quote(BigInt(9)); + }) + .mockImplementationOnce(async ({ onMinimum, quote: q }) => { + onMinimum(BigInt(10)); + expect(q.gasFee).toBe(BigInt(9)); + return quote(BigInt(10)); + }); + await expect( + runtime().quoteOutbound(ADDR.agent, BigInt(0), CHAIN.SOLANA_DEVNET, svm) + ).resolves.toMatchObject({ gasFee: BigInt(10) }); + expect(mockFinalize).toHaveBeenCalledTimes(2); + }); + it('refuse a Solana budget that stays short after re-quoting', async () => { + mockQuery.mockResolvedValue(quote(BigInt(1))); + mockFinalize.mockImplementation(async ({ onMinimum }) => { + onMinimum(BigInt(10)); + return quote(BigInt(9)); + }); + await expect( + runtime().quoteOutbound(ADDR.agent, BigInt(0), CHAIN.SOLANA_DEVNET, svm) + ).rejects.toMatchObject({ code: 'INCONSISTENT_READ' }); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts index 789ea6ddd..c64ccee59 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts @@ -20,7 +20,8 @@ it('reports unmapped source tokens without silently changing the default Push gr }); it('preserves assets and rejects the removed funding option instead of silently changing permissions', () => { const result = agenticPresets.get('DEXES', options); - expect(result.readiness.ready).toBe(false); + // WETH and Circle USDC both have exact node routes on Sepolia. + expect(result.readiness.ready).toBe(true); expect(result.rules[0].assets).toEqual( result.readiness.chains[0].selectedAssets ); @@ -111,6 +112,10 @@ it('reports exact unavailable assets across all nine testnet preset combinations CHAIN.ARBITRUM_SEPOLIA, CHAIN.BNB_TESTNET, ]; + const routed = new Set([ + `DEXES:${CHAIN.ETHEREUM_SEPOLIA}`, + `LENDING:${CHAIN.ARBITRUM_SEPOLIA}`, + ]); let checked = 0; for (const category of ['DEXES', 'LENDING', 'STAKING'] as const) { const entry = agenticPresets @@ -123,7 +128,9 @@ it('reports exact unavailable assets across all nine testnet preset combinations agent: ADDR.agent, chainNamespace, }); - expect(res.readiness.ready).toBe(false); + expect(res.readiness.ready).toBe( + routed.has(`${category}:${chainNamespace}`) + ); const state = res.readiness.chains[0]; expect(res.rules[0].assets).toEqual(state.selectedAssets); for (const asset of state.bridgeAssets.filter( diff --git a/packages/core/src/lib/agentic/__tests__/send.spec.ts b/packages/core/src/lib/agentic/__tests__/send.spec.ts index c5ce94e71..b59cbe598 100644 --- a/packages/core/src/lib/agentic/__tests__/send.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/send.spec.ts @@ -352,7 +352,8 @@ describe('EVM outbound composition from the wallet', () => { it.each([ [false, undefined, BigInt(2_000_000)], - [true, undefined, BigInt(0)], + // Token export plus a call: transfer + call exceed the network base. + [true, undefined, BigInt(1_000_000)], [false, BigInt(123456), BigInt(123456)], [true, BigInt(123456), BigInt(123456)], [false, BigInt(0), BigInt(0)], @@ -382,6 +383,37 @@ describe('EVM outbound composition from the wallet', () => { } ); + it.each([ + ['a single call without a token export', undefined, BigInt(0)], + ['a native-value export with one call', BigInt(5), BigInt(0)], + ])( + 'destination gas on a deployed CEA keeps the network base for %s', + async (_label, value, expected) => { + const s = universalSetup(); + const native = getAddress('0x0000000000000000000000000000000000007171'); + s.fake.balances.set(`balanceOf:${native.toLowerCase()}`, BigInt(10)); + const quoteOutbound = jest.fn(async (_token: Address, _gas: bigint) => ({ + protocolFee: BigInt(100), + nativeValueForGas: BigInt(1000), + gasLimitUsed: BigInt(200_000), + })); + const rt = mockRuntime(s.fake, { + signer: ADDR.agent, + resolvePrc20: (t: unknown) => (t === undefined ? native : token), + resolveCEA: async () => ({ + cea: getAddress('0x000000000000000000000000000000000000cea1'), + isDeployed: true, + }), + quoteOutbound, + }); + await agenticSend(rt, s.ctx, { + ...send(), + ...(value === undefined ? {} : { value }), + }); + expect(quoteOutbound.mock.calls[0][1]).toBe(expected); + } + ); + it('builds the policy-compatible request: empty recipient, wallet refund, nonzero gas cap, multicall payload, CEA of the wallet', async () => { const s = universalSetup(); const resolveCEA = jest.fn(async () => ({ diff --git a/packages/core/src/lib/agentic/__tests__/svm-agent-send-defaults.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-agent-send-defaults.spec.ts new file mode 100644 index 000000000..feeec976a --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/svm-agent-send-defaults.spec.ts @@ -0,0 +1,77 @@ +import { PublicKey } from '@solana/web3.js'; +import { CHAIN } from '../../constants/enums'; +import { sendPublicSvm } from '../execution/svm-public'; +import { resetAgenticGenerations } from '../deployments'; +import { ADDR, FakeChain, registerFakeGeneration } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; + +const mockPrepare = jest.fn(); +jest.mock('../execution/svm-send', () => ({ + ...jest.requireActual('../execution/svm-send'), + prepareSvmAgentExecution: (...args: unknown[]) => mockPrepare(...args), +})); +jest.mock('../reads/rules', () => ({ + ...jest.requireActual('../reads/rules'), + selectRuleForSend: async () => ({ rulesId: `0x${'ab'.repeat(32)}` }), +})); +jest.mock('../reads/svm', () => ({ + ...jest.requireActual('../reads/svm'), + readSvmConfig: async () => ({ + config: { expectedCEA: `0x${'cc'.repeat(32)}`, assets: [] }, + }), +})); + +afterEach(resetAgenticGenerations); +it.each([undefined, BigInt(0), BigInt(5000)])( + 'agent Solana sends pass gasLimit %s to route sizing unchanged, defaulting to 0', + async (gasLimit) => { + const gen = registerFakeGeneration(); + mockPrepare.mockReset().mockResolvedValue({ + data: '0x1234', + instruction: { + recipient: `0x${'22'.repeat(32)}`, + instructionData: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + accounts: [], + }, + }); + const rt = mockRuntime(new FakeChain(), { + signer: ADDR.agent, + resolvePrc20: () => ADDR.target, + svmMetadata: { + gateway: async () => ({ + chainNamespace: CHAIN.SOLANA_DEVNET, + program: `0x${'22'.repeat(32)}`, + }), + mint: async () => { + throw new Error('No mint read in this send'); + }, + }, + }); + await sendPublicSvm( + rt, + { + wallet: ADDR.other, + door: 'agent', + signerPushAccount: ADDR.agent, + signerOrigin: { chain: CHAIN.PUSH_TESTNET_DONUT, address: ADDR.agent }, + generation: gen, + }, + { + to: { + address: PublicKey.default.toBase58(), + chain: CHAIN.SOLANA_DEVNET, + }, + data: '0x0102030405060708', + funds: { token: ADDR.target as never, amount: BigInt(1) }, + ...(gasLimit === undefined ? {} : { gasLimit }), + } + ); + expect(mockPrepare).toHaveBeenCalledTimes(1); + expect(mockPrepare.mock.calls[0][2]).toMatchObject({ + wallet: ADDR.other, + amount: BigInt(1), + gasLimit: gasLimit ?? BigInt(0), + }); + expect(rt.executeMock).toHaveBeenCalledTimes(1); + } +); diff --git a/packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts index 766135cdf..83e9bcd1d 100644 --- a/packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/svm-quote.spec.ts @@ -249,3 +249,16 @@ describe('AGW Solana finalization quote context', () => { expect(rt.resolvePrc20).toHaveBeenCalledWith(expected, CHAIN.SOLANA_DEVNET); }); }); +it('refuses a zero Solana gas ceiling, which would reject every agent send', async () => { + const f = fixture(); + await expect( + preparePublicSvmRule(f.rt, f.snap, ADDR.owner, { + agent: ADDR.agent, + chainNamespace: CHAIN.SOLANA_DEVNET, + assets: [], + validUntil: f.rt.nowSeconds() + 3600, + maxGasPerCall: BigInt(0), + allowedInstructions: [], + }) + ).rejects.toThrow('maxGasPerCall must be positive'); +}); diff --git a/packages/core/src/lib/agentic/__tests__/svm-send-defaults.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-send-defaults.spec.ts new file mode 100644 index 000000000..02d999377 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/svm-send-defaults.spec.ts @@ -0,0 +1,66 @@ +import { PublicKey } from '@solana/web3.js'; +import { CHAIN } from '../../constants/enums'; +import { sendPublicSvm } from '../execution/svm-public'; +import { resetAgenticGenerations } from '../deployments'; +import { ADDR, FakeChain, registerFakeGeneration } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; + +afterEach(resetAgenticGenerations); +it.each([undefined, BigInt(0), BigInt(5000)])( + 'sizes a public SVM owner transfer through the route quote, preserving explicit gas: %s', + async (gasLimit) => { + const gen = registerFakeGeneration(); + const fake = new FakeChain(); + fake.getBalance = async () => BigInt(10) ** BigInt(20); + fake.readContract = async ({ functionName }) => { + if (functionName === 'SOURCE_CHAIN_NAMESPACE') return CHAIN.SOLANA_DEVNET; + if (functionName === 'gasTokenPRC20ByChainNamespace') return ADDR.target; + if (functionName === 'balanceOf') return BigInt(1000000); + throw new Error(`Unexpected read: ${functionName}`); + }; + const quoteOutbound = jest.fn(async (_token, budget) => ({ + protocolFee: BigInt(0), + nativeValueForGas: BigInt(10), + gasLimitUsed: budget || BigInt(1052), + })); + const rt = mockRuntime(fake, { + signer: ADDR.owner, + resolvePrc20: () => ADDR.target, + quoteOutbound, + svmMetadata: { + gateway: async () => ({ + chainNamespace: CHAIN.SOLANA_DEVNET, + program: `0x${'22'.repeat(32)}`, + }), + mint: async () => { + throw new Error('No mint read for native SOL'); + }, + }, + }); + await sendPublicSvm( + rt, + { + wallet: ADDR.other, + door: 'owner', + signerPushAccount: ADDR.owner, + signerOrigin: { chain: CHAIN.PUSH_TESTNET_DONUT, address: ADDR.owner }, + generation: gen, + }, + { + to: { + address: PublicKey.default.toBase58(), + chain: CHAIN.SOLANA_DEVNET, + }, + value: BigInt(1), + ...(gasLimit === undefined ? {} : { gasLimit }), + } + ); + expect(quoteOutbound).toHaveBeenCalledWith( + ADDR.target, + gasLimit ?? BigInt(0), + CHAIN.SOLANA_DEVNET, + { wallet: ADDR.other, splMintBase58: undefined, burnAmount: BigInt(1) } + ); + expect(rt.executeMock).toHaveBeenCalledTimes(1); + } +); diff --git a/packages/core/src/lib/agentic/__tests__/token-routes.spec.ts b/packages/core/src/lib/agentic/__tests__/token-routes.spec.ts new file mode 100644 index 000000000..8535a1690 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/token-routes.spec.ts @@ -0,0 +1,123 @@ +import { getAddress } from 'viem'; +import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; +import { SYNTHETIC_PUSH_ERC20 } from '../../constants/chain'; +import type { Orchestrator } from '../../orchestrator/orchestrator'; +import { createAgenticRuntime } from '../runtime'; +import { agenticPresets } from '../presets'; +import { exactAgenticRoute, resolveAgenticPrc20 } from '../token-routes'; +import { ADDR } from './fake-chain'; + +const CIRCLE = { + [CHAIN.ETHEREUM_SEPOLIA]: [ + '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + '0x387b9C8Db60E74999aAAC5A2b7825b400F12d68E', + ], + [CHAIN.BASE_SEPOLIA]: [ + '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + '0x84B62e44F667F692F7739Ca6040cD17DA02068A8', + ], + [CHAIN.ARBITRUM_SEPOLIA]: [ + '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + '0xa261A10e94aE4bA88EE8c5845CbE7266bD679DD6', + ], +} as const; +const runtime = () => + createAgenticRuntime( + { + getNetwork: () => PUSH_NETWORK.TESTNET_DONUT, + getRpcUrls: () => ({}), + } as unknown as Orchestrator, + false + ); + +it.each(Object.entries(CIRCLE))( + 'maps the exact node-registered Circle USDC on %s', + (chain, [source, prc20]) => { + expect( + resolveAgenticPrc20( + { chain, address: source.toLowerCase() }, + PUSH_NETWORK.TESTNET_DONUT + ) + ).toEqual({ address: getAddress(prc20), symbol: 'USDC', decimals: 6 }); + expect(runtime().resolvePrc20(source, chain as CHAIN)).toBe( + getAddress(prc20) + ); + expect(exactAgenticRoute(chain, source, PUSH_NETWORK.TESTNET)?.prc20).toBe( + prc20 + ); + } +); +it('never matches by symbol, by another chain or on another network', () => { + const donut = SYNTHETIC_PUSH_ERC20[PUSH_NETWORK.TESTNET_DONUT]; + // The SDK's routed Sepolia mock USDC keeps its own PRC20. + expect( + resolveAgenticPrc20( + { + chain: CHAIN.ETHEREUM_SEPOLIA, + address: '0x97F477B7f970D47a87B42869ceeace218106152a', + }, + PUSH_NETWORK.TESTNET_DONUT + ).address + ).toBe(getAddress(donut.USDC_ETH)); + const sepoliaCircle = CIRCLE[CHAIN.ETHEREUM_SEPOLIA][0]; + expect( + exactAgenticRoute( + CHAIN.BASE_SEPOLIA, + sepoliaCircle, + PUSH_NETWORK.TESTNET_DONUT + ) + ).toBeUndefined(); + expect(() => + runtime().resolvePrc20(sepoliaCircle, CHAIN.BASE_SEPOLIA) + ).toThrow(); + expect( + exactAgenticRoute( + CHAIN.ETHEREUM_SEPOLIA, + sepoliaCircle, + PUSH_NETWORK.MAINNET + ) + ).toBeUndefined(); + expect( + exactAgenticRoute(CHAIN.ETHEREUM_SEPOLIA, 'USDC', PUSH_NETWORK.TESTNET) + ).toBeUndefined(); +}); +it.each([ + ['DEXES', CHAIN.ETHEREUM_SEPOLIA], + ['LENDING', CHAIN.ARBITRUM_SEPOLIA], +] as const)( + '%s %s defaults are route-ready with unchanged assets and caps', + (category, chainNamespace) => { + const res = agenticPresets.get(category, { + chainNamespace, + agent: ADDR.agent, + }); + expect(res.readiness.ready).toBe(true); + const state = res.readiness.chains[0]; + expect(state.bridgeAssets.every((a) => a.status === 'registered')).toBe( + true + ); + expect(state.bridgeAssets).toContainEqual({ + token: getAddress(CIRCLE[chainNamespace][0]), + prc20: getAddress(CIRCLE[chainNamespace][1]), + status: 'registered', + }); + expect(res.rules[0].assets).toEqual(state.selectedAssets); + } +); +it('Base DEX USDC is routed while its WETH leg stays unavailable', () => { + const state = agenticPresets.get('DEXES', { + chainNamespace: CHAIN.BASE_SEPOLIA, + agent: ADDR.agent, + }).readiness.chains[0]; + expect(state.bridgeAssets).toEqual([ + { + token: '0x4200000000000000000000000000000000000006', + status: 'unavailable', + }, + { + token: getAddress(CIRCLE[CHAIN.BASE_SEPOLIA][0]), + prc20: getAddress(CIRCLE[CHAIN.BASE_SEPOLIA][1]), + status: 'registered', + }, + ]); +}); diff --git a/packages/core/src/lib/agentic/execution/gas-budget.ts b/packages/core/src/lib/agentic/execution/gas-budget.ts new file mode 100644 index 000000000..26b14e6c1 --- /dev/null +++ b/packages/core/src/lib/agentic/execution/gas-budget.ts @@ -0,0 +1,69 @@ +import { UNIVERSAL_CORE_EVM } from '../../constants/abi/prc20.evm'; +import type { OrchestratorContext } from '../../orchestrator/internals/context'; +import { CORE_ADDRESS } from '../contracts/prc20-metadata'; +import { AGENTIC_ERROR_CODE, AgenticError } from '../errors'; + +/** Headroom over the pool quote; the gateway refunds whatever the swap leaves. */ +const POOL_HEADROOM_NUM = BigInt(12); +const POOL_HEADROOM_DEN = BigInt(10); + +/** + * PC gas budget (maxPCForGas) for an AGW outbound, excluding protocolFee. + * The gateway buys exactly `gasFee` of the gas token from the Donut WPC pool + * and reverts when the budget cannot. The shared USD sizer can attach less: + * its over-$10 overflow has no funds leg on this path, and its USD price can + * differ from the pool. Keep the larger of the sized budget and the pool cost. + */ +export function agwGasBudget( + quote: { + protocolFee: bigint; + nativeValueForGas: bigint; + sizing?: { category: string; overflowNativePc: bigint }; + }, + poolPcIn?: bigint +): bigint { + const sized = + (quote.nativeValueForGas > quote.protocolFee + ? quote.nativeValueForGas - quote.protocolFee + : BigInt(0)) + + (quote.sizing?.category === 'C' + ? quote.sizing.overflowNativePc + : BigInt(0)); + const pool = + poolPcIn === undefined + ? BigInt(0) + : (poolPcIn * POOL_HEADROOM_NUM + POOL_HEADROOM_DEN - BigInt(1)) / + POOL_HEADROOM_DEN; + return sized > pool ? sized : pool; +} + +/** Refuses an explicit gasLimit below the chain base before quoting, where core + * would otherwise revert. Omitted or zero gas uses the base. */ +export async function assertGasLimitAtLeastBase( + ctx: OrchestratorContext, + chain: string, + gasLimit: bigint +): Promise { + if (gasLimit <= BigInt(0)) return; + let base: bigint; + try { + base = await ctx.pushClient.readContract({ + address: CORE_ADDRESS, + abi: UNIVERSAL_CORE_EVM, + functionName: 'baseGasLimitByChainNamespace', + args: [chain], + }); + } catch { + return; // The quote itself reports any real failure. + } + if (gasLimit < base) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + `gasLimit ${gasLimit} is below the ${chain} minimum of ${base}; omit it or pass 0 to use the network default`, + { + userMessage: + 'The gas limit is below this network’s minimum. Leave it empty to use the network default.', + details: { field: 'gasLimit', minimum: base }, + } + ); +} diff --git a/packages/core/src/lib/agentic/execution/outbound.ts b/packages/core/src/lib/agentic/execution/outbound.ts index 5e77b6f66..63b5beb0d 100644 --- a/packages/core/src/lib/agentic/execution/outbound.ts +++ b/packages/core/src/lib/agentic/execution/outbound.ts @@ -30,6 +30,10 @@ import type { AgenticRuntime } from '../runtime'; /** Includes first-use CEA deployment; explicit caller gas always takes precedence. */ const FIRST_USE_EVM_GAS_LIMIT = BigInt(2_000_000); +/** Token exports and multi-call payloads can exceed the 500k network base: a + * Sepolia Circle USDC export with approve + Uniswap swap ran out of gas on it. */ +const MULTI_STEP_EVM_GAS_LIMIT = BigInt(1_000_000); + /** URP MAX_ACTIONS_PER_REQUEST at v6. */ export const MAX_DESTINATION_CALLS = 10; @@ -220,10 +224,18 @@ export async function composeOutbound( } } + const multiStep = + calls.length > 1 || + (amount > BigInt(0) && + getAddress(token) !== getAddress(runtime.resolvePrc20(undefined, chain))); const quote = await runtime.quoteOutbound( token, params.gasLimit ?? - (resolved.isDeployed ? BigInt(0) : FIRST_USE_EVM_GAS_LIMIT), + (!resolved.isDeployed + ? FIRST_USE_EVM_GAS_LIMIT + : multiStep + ? MULTI_STEP_EVM_GAS_LIMIT + : BigInt(0)), chain ); if (quote.nativeValueForGas <= BigInt(0)) { diff --git a/packages/core/src/lib/agentic/execution/svm-public.ts b/packages/core/src/lib/agentic/execution/svm-public.ts index d8c3e5755..93d651b7e 100644 --- a/packages/core/src/lib/agentic/execution/svm-public.ts +++ b/packages/core/src/lib/agentic/execution/svm-public.ts @@ -78,7 +78,10 @@ export async function sendPublicSvm( cea: Hex; let destinationTransfer: AgenticTxMetadata['destinationTransfer']; let transferValue = BigInt(0); - const gasLimit = p.gasLimit ?? BigInt(1_000_000); + // SVM gateway gasLimit is a lamport-budget conversion input, not EVM gas + // units. Let the live route quote and finalize-rent floor size an omitted + // limit; copying an EVM-style million-unit fallback can overquote 100 PC. + const gasLimit = p.gasLimit ?? BigInt(0); if (actx.door === 'agent') { const active = await selectRuleForSend( snap, diff --git a/packages/core/src/lib/agentic/management/svm-public.ts b/packages/core/src/lib/agentic/management/svm-public.ts index 42289b8a1..96da8645d 100644 --- a/packages/core/src/lib/agentic/management/svm-public.ts +++ b/packages/core/src/lib/agentic/management/svm-public.ts @@ -37,6 +37,9 @@ export async function preparePublicSvmRule( throw svmInvalid('unknown Solana chain'); if (!Array.isArray(rule.assets) || rule.assets.length > 8) throw svmInvalid('assets must contain at most eight entries'); + // URP compares the attached PC to this ceiling, so zero refuses every send. + if (rule.maxGasPerCall === BigInt(0)) + throw svmInvalid('maxGasPerCall must be positive'); const raw = 'format' in rule && rule.format === 'decoded'; const outputTokens = 'outputTokens' in rule ? rule.outputTokens : undefined; if (outputTokens !== undefined && !Array.isArray(outputTokens)) diff --git a/packages/core/src/lib/agentic/presets/routes.ts b/packages/core/src/lib/agentic/presets/routes.ts index c18269d83..c022fa089 100644 --- a/packages/core/src/lib/agentic/presets/routes.ts +++ b/packages/core/src/lib/agentic/presets/routes.ts @@ -1,7 +1,6 @@ -import { getAddress } from 'viem'; import { PUSH_NETWORK, type CHAIN } from '../../constants/enums'; import { SUPPORTED_GATEWAY_CHAINS } from '../../orchestrator/internals/helpers'; -import { getPRC20Address } from '../../universal/prc20-address'; +import { resolveAgenticPrc20 } from '../token-routes'; import { generationsFor } from '../deployments'; import type { RuleAsset } from '../agentic.types'; import type { PresetChainReadiness, PresetConfigurationIssue } from './types'; @@ -35,10 +34,10 @@ export function presetRoutes( const token = assetAddress(asset); try { if (!supported) throw new Error(`No supported gateway route to ${chain}`); - const prc20 = getPRC20Address({ chain, address: token }, { network }); + const prc20 = resolveAgenticPrc20({ chain, address: token }, network); return { token, - prc20: getAddress(prc20.address), + prc20: prc20.address, status: 'registered' as const, }; } catch { diff --git a/packages/core/src/lib/agentic/runtime.ts b/packages/core/src/lib/agentic/runtime.ts index 530e672ed..89af2571f 100644 --- a/packages/core/src/lib/agentic/runtime.ts +++ b/packages/core/src/lib/agentic/runtime.ts @@ -26,6 +26,12 @@ import { } from '../orchestrator/internals/helpers'; import { pushChainNamespaceFor } from './chain'; import { getPRC20Address } from '../universal/prc20-address'; +import { resolveAgenticPrc20 } from './token-routes'; +import { + agwGasBudget, + assertGasLimitAtLeastBase, +} from './execution/gas-budget'; +import { quoteGasPoolCost } from '../orchestrator/internals/gas-pool-quote'; import { chainReaderFromPublicClient, type ChainReader, @@ -76,8 +82,12 @@ export interface AgenticRuntime { svm?: { wallet: Address; splMintBase58?: string; burnAmount: bigint } ): Promise<{ protocolFee: bigint; + /** PC gas budget sent as maxPCForGas; protocolFee is attached separately. */ nativeValueForGas: bigint; gasLimitUsed: bigint; + /** Diagnostics: gas-token fee and the pool's current PC cost for it. */ + gasFee?: bigint; + poolPcIn?: bigint; }>; resolveCEA( account: Address, @@ -139,23 +149,49 @@ export function createAgenticRuntime( signerBalance: () => ctx.pushClient.getBalance(computeUEAOffchain(ctx)), getGasPrice: () => ctx.pushClient.getGasPrice(), quoteOutbound: async (prc20, gasLimit, chain, svm) => { + await assertGasLimitAtLeastBase(ctx, chain, gasLimit); let q = await queryOutboundGasFee(ctx, prc20, gasLimit, chain); if (svm) { - q = await ensureSvmFinalizeGasBudgetQuote({ - ctx, - ueaAddress: svm.wallet, - targetChain: chain, - prc20Token: prc20, - quote: q, - splMintBase58: svm.splMintBase58, - burnAmount: svm.burnAmount, - pathTag: 'AGW_SVM', - }); + let minimum = BigInt(0); + const finalize = (quote: typeof q) => + ensureSvmFinalizeGasBudgetQuote({ + ctx, + ueaAddress: svm.wallet, + targetChain: chain, + prc20Token: prc20, + quote, + splMintBase58: svm.splMintBase58, + burnAmount: svm.burnAmount, + pathTag: 'AGW_SVM', + onMinimum: (m) => { + minimum = m; + }, + }); + q = await finalize(q); + // A gas-price change between quote and re-quote can leave the bump short. + if (q.gasFee < minimum) q = await finalize(q); + if (q.gasFee < minimum) + throw new AgenticError( + AGENTIC_ERROR_CODE.INCONSISTENT_READ, + `Solana finalize budget ${q.gasFee} is below the required ${minimum} lamports after re-quoting`, + { + userMessage: + 'Network gas prices changed while preparing this transaction. Try again.', + } + ); } + const poolPcIn = await quoteGasPoolCost( + ctx, + q.universalCoreAddress, + q.gasToken, + q.gasFee + ); return { protocolFee: q.protocolFee, - nativeValueForGas: q.nativeValueForGas, + nativeValueForGas: agwGasBudget(q, poolPcIn), gasLimitUsed: q.gasLimitUsed, + gasFee: q.gasFee, + poolPcIn, }; }, resolveCEA: async (account, chain) => { @@ -178,12 +214,10 @@ export function createAgenticRuntime( ); return getAddress(getPRC20Address(token, { network }).address); } - return getAddress( - getPRC20Address( - { chain, address: typeof token === 'string' ? token : token.address }, - { network } - ).address - ); + return resolveAgenticPrc20( + { chain, address: typeof token === 'string' ? token : token.address }, + network + ).address; }, nowSeconds: () => Math.floor(Date.now() / 1000), }; diff --git a/packages/core/src/lib/agentic/token-routes.ts b/packages/core/src/lib/agentic/token-routes.ts new file mode 100644 index 000000000..ea3ce9685 --- /dev/null +++ b/packages/core/src/lib/agentic/token-routes.ts @@ -0,0 +1,93 @@ +import { getAddress, isAddress, type Address } from 'viem'; +import { PUSH_NETWORK } from '../constants/enums'; +import { getPRC20Address } from '../universal/prc20-address'; + +export interface AgenticTokenRoute { + /** Exact destination-chain token address. */ + source: Address; + /** Push PRC20 the node registry maps that token to. */ + prc20: Address; + symbol: string; + decimals: number; +} + +/** Exact routes enabled in the node's uregistry that the shared symbol-keyed + * registry cannot hold (a second USDC on one chain). Snapshot: Donut uregistry + * height 24,457,377; SOURCE_* metadata, decimals and outbound quotes checked at + * block 24,457,398. Grant preparation still verifies the source on chain. + */ +const DONUT_EXACT_ROUTES: Readonly< + Record +> = { + 'eip155:11155111': [ + { + source: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + prc20: '0x387b9C8Db60E74999aAAC5A2b7825b400F12d68E', + symbol: 'USDC', + decimals: 6, + }, + ], + 'eip155:84532': [ + { + source: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + prc20: '0x84B62e44F667F692F7739Ca6040cD17DA02068A8', + symbol: 'USDC', + decimals: 6, + }, + ], + 'eip155:421614': [ + { + source: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + prc20: '0xa261A10e94aE4bA88EE8c5845CbE7266bD679DD6', + symbol: 'USDC', + decimals: 6, + }, + ], +}; + +const EXACT_ROUTES: Partial< + Record>> +> = { + [PUSH_NETWORK.TESTNET_DONUT]: DONUT_EXACT_ROUTES, + [PUSH_NETWORK.TESTNET]: DONUT_EXACT_ROUTES, +}; + +export function exactAgenticRoutes( + chain: string, + network: PUSH_NETWORK +): readonly AgenticTokenRoute[] { + return EXACT_ROUTES[network]?.[chain] ?? []; +} + +/** Match by chain AND exact address; never by symbol. */ +export function exactAgenticRoute( + chain: string, + source: string, + network: PUSH_NETWORK +): AgenticTokenRoute | undefined { + if (!isAddress(source, { strict: false })) return undefined; + const wanted = getAddress(source); + return exactAgenticRoutes(chain, network).find( + (route) => getAddress(route.source) === wanted + ); +} + +/** AGW source-token resolution: exact node routes first, then the shared registry. */ +export function resolveAgenticPrc20( + token: { chain: string; address: string }, + network: PUSH_NETWORK +): { address: Address; symbol: string; decimals: number } { + const exact = exactAgenticRoute(token.chain, token.address, network); + if (exact) + return { + address: getAddress(exact.prc20), + symbol: exact.symbol, + decimals: exact.decimals, + }; + const mapped = getPRC20Address(token, { network }); + return { + address: getAddress(mapped.address), + symbol: mapped.symbol, + decimals: mapped.decimals, + }; +} diff --git a/packages/core/src/lib/constants/tokens.ts b/packages/core/src/lib/constants/tokens.ts index fc3dc1a2b..c2c710ad8 100644 --- a/packages/core/src/lib/constants/tokens.ts +++ b/packages/core/src/lib/constants/tokens.ts @@ -235,7 +235,7 @@ const TOKEN_META: Partial>> = { WETH: { symbol: 'WETH', decimals: 18, - address: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + address: '0x4200000000000000000000000000000000000006', mechanism: 'approve', }, }, diff --git a/packages/core/src/lib/orchestrator/__tests__/gas-usd-sizer.spec.ts b/packages/core/src/lib/orchestrator/__tests__/gas-usd-sizer.spec.ts index 8550f9f61..19afc2872 100644 --- a/packages/core/src/lib/orchestrator/__tests__/gas-usd-sizer.spec.ts +++ b/packages/core/src/lib/orchestrator/__tests__/gas-usd-sizer.spec.ts @@ -398,3 +398,62 @@ describe('sizeR1PushGas — R1 Push-chain gas sizing', () => { expect(decision.paddedDepositUsd).toBe(__THRESHOLDS.TEN_USD_8D); }); }); + +describe('sizeOutboundGas — BNB testnet gas is valued at the Push pool rate', () => { + const pool = { + universalCore: '0x0000000000000000000000000000000000001111', + gasToken: '0x0000000000000000000000000000000000005555', + } as const; + const PC = BigInt(10) ** BigInt(18); + // The BSC gateway feed reports ETH/USD; it must not price BNB gas. + const ethFeed = BigInt(2450_0000_0000); + function poolCtx(pcIn: bigint | Error) { + const ctx = makeCtx(); + const readContract = ctx.pushClient.readContract as unknown as jest.Mock; + const base = readContract.getMockImplementation()!; + readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName !== 'quoteExactOutputSingle') return base(args); + if (pcIn instanceof Error) throw pcIn; + return [pcIn, BigInt(0), 0, BigInt(0)]; + }); + return ctx; + } + + it('uses the exact-output pool cost instead of the ETH feed', async () => { + mockGetPrice.mockResolvedValue(ethFeed); + // 23.65 PC at $0.10/PC = $2.365 → Case B. + const pcIn = (PC * BigInt(2365)) / BigInt(100); + const r = await sizeOutboundGas(poolCtx(pcIn), { + gasFee: BigInt('2200000000000000'), + originChain: CHAIN.ETHEREUM_SEPOLIA, + destinationChain: CHAIN.BNB_TESTNET, + pool, + }); + expect(mockGetPrice).not.toHaveBeenCalled(); + expect(r.category).toBe('B'); + expect(r.gasUsd).toBe(BigInt(236_500_000)); + expect(r.gasLegNativePc).toBe(pcIn); + }); + + it('falls back to the feed when the pool cannot be quoted', async () => { + mockGetPrice.mockResolvedValue(ethFeed); + await sizeOutboundGas(poolCtx(new Error('no pool')), { + gasFee: BigInt('2200000000000000'), + originChain: CHAIN.ETHEREUM_SEPOLIA, + destinationChain: CHAIN.BNB_TESTNET, + pool, + }); + expect(mockGetPrice).toHaveBeenCalledWith(CHAIN.ETHEREUM_SEPOLIA); + }); + + it('keeps ETH destinations on the ETH feed', async () => { + mockGetPrice.mockResolvedValue(ethFeed); + await sizeOutboundGas(poolCtx(PC), { + gasFee: BigInt('2200000000000000'), + originChain: CHAIN.ETHEREUM_SEPOLIA, + destinationChain: CHAIN.BASE_SEPOLIA, + pool, + }); + expect(mockGetPrice).toHaveBeenCalledWith(CHAIN.ETHEREUM_SEPOLIA); + }); +}); diff --git a/packages/core/src/lib/orchestrator/internals/gas-calculator.ts b/packages/core/src/lib/orchestrator/internals/gas-calculator.ts index 3d1abbba9..8d36c31d0 100644 --- a/packages/core/src/lib/orchestrator/internals/gas-calculator.ts +++ b/packages/core/src/lib/orchestrator/internals/gas-calculator.ts @@ -28,6 +28,10 @@ import { SvmClient } from '../../vm-client/svm-client'; import type { OrchestratorContext } from './context'; import { printLog, fireProgressHook } from './context'; import { getUniversalGatewayPCAddress, getPushChainForNetwork } from './helpers'; +import { + UNISWAP_V3_QUOTER_EXACT_OUTPUT_ABI, + UNIVERSAL_CORE_SWAP_HELPERS_ABI, +} from './gas-pool-quote'; import { sizeOutboundGas, type GasSizingDecision, @@ -74,38 +78,6 @@ const UNISWAP_V3_FACTORY_ABI = [ }, ]; -const UNISWAP_V3_QUOTER_EXACT_OUTPUT_ABI = [ - { - type: 'function' as const, - name: 'quoteExactOutputSingle', - inputs: [ - { - type: 'tuple', - components: [ - { name: 'tokenIn', type: 'address' }, - { name: 'tokenOut', type: 'address' }, - { name: 'amount', type: 'uint256' }, - { name: 'fee', type: 'uint24' }, - { name: 'sqrtPriceLimitX96', type: 'uint160' }, - ], - }, - ], - outputs: [ - { name: 'amountIn', type: 'uint256' }, - { name: 'sqrtPriceX96After', type: 'uint160' }, - { name: 'initializedTicksCrossed', type: 'uint32' }, - { name: 'gasEstimate', type: 'uint256' }, - ], - stateMutability: 'view', - }, -]; - -const UNIVERSAL_CORE_SWAP_HELPERS_ABI = [ - { type: 'function' as const, name: 'WPC', inputs: [], outputs: [{ name: '', type: 'address', internalType: 'address' }], stateMutability: 'view' }, - { type: 'function' as const, name: 'uniswapV3Factory', inputs: [], outputs: [{ name: '', type: 'address', internalType: 'address' }], stateMutability: 'view' }, - { type: 'function' as const, name: 'defaultFeeTier', inputs: [{ name: '', type: 'address', internalType: 'address' }], outputs: [{ name: '', type: 'uint24', internalType: 'uint24' }], stateMutability: 'view' }, -]; - const ZERO_ADDR = '0x0000000000000000000000000000000000000000'; // ============================================================================ @@ -246,6 +218,7 @@ export async function queryOutboundGasFee( gasFee, originChain: ctx.universalSigner.account.chain, destinationChain, + pool: { universalCore: universalCoreAddress, gasToken }, }); // All categories — A, B, and C — return the sizer's calibrated gas leg. // R3 consumers additionally bump `nativeValueForGas` by `overflowNativePc` @@ -529,6 +502,7 @@ export async function queryRescueGasFee( gasFee, originChain: ctx.universalSigner.account.chain, destinationChain, + pool: { universalCore: universalCoreAddress, gasToken }, }); nativeValueForGas = sizing.gasLegNativePc; printLog( diff --git a/packages/core/src/lib/orchestrator/internals/gas-pool-quote.ts b/packages/core/src/lib/orchestrator/internals/gas-pool-quote.ts new file mode 100644 index 000000000..7ea320fb7 --- /dev/null +++ b/packages/core/src/lib/orchestrator/internals/gas-pool-quote.ts @@ -0,0 +1,114 @@ +/** + * Live Push-pool cost of an outbound gas fee. The gateway's swapAndBurnGas + * buys exactly `gasFee` of the gas token from the WPC pool (exact output), so + * this quote is what the swap will actually need at the current pool state. + */ +import { CHAIN_INFO } from '../../constants/chain'; +import type { OrchestratorContext } from './context'; +import { printLog } from './context'; +import { getPushChainForNetwork } from './helpers'; + +export const UNISWAP_V3_QUOTER_EXACT_OUTPUT_ABI = [ + { + type: 'function' as const, + name: 'quoteExactOutputSingle', + inputs: [ + { + type: 'tuple', + components: [ + { name: 'tokenIn', type: 'address' }, + { name: 'tokenOut', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'fee', type: 'uint24' }, + { name: 'sqrtPriceLimitX96', type: 'uint160' }, + ], + }, + ], + outputs: [ + { name: 'amountIn', type: 'uint256' }, + { name: 'sqrtPriceX96After', type: 'uint160' }, + { name: 'initializedTicksCrossed', type: 'uint32' }, + { name: 'gasEstimate', type: 'uint256' }, + ], + stateMutability: 'view', + }, +]; + +export const UNIVERSAL_CORE_SWAP_HELPERS_ABI = [ + { + type: 'function' as const, + name: 'WPC', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function' as const, + name: 'uniswapV3Factory', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function' as const, + name: 'defaultFeeTier', + inputs: [{ name: '', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'uint24', internalType: 'uint24' }], + stateMutability: 'view', + }, +]; + +/** Exact PC (WPC wei) the gas swap needs now; undefined when it cannot be quoted. */ +export async function quoteGasPoolCost( + ctx: OrchestratorContext, + universalCore: `0x${string}`, + gasToken: `0x${string}`, + gasFee: bigint +): Promise { + if (gasFee <= BigInt(0)) return undefined; + const quoter = + CHAIN_INFO[getPushChainForNetwork(ctx.pushNetwork)]?.dex?.uniV3QuoterV2; + if (!quoter || quoter.startsWith('0xTBD')) return undefined; + try { + const [wpc, fee] = await Promise.all([ + ctx.pushClient.readContract<`0x${string}`>({ + address: universalCore, + abi: UNIVERSAL_CORE_SWAP_HELPERS_ABI, + functionName: 'WPC', + args: [], + }), + ctx.pushClient.readContract({ + address: universalCore, + abi: UNIVERSAL_CORE_SWAP_HELPERS_ABI, + functionName: 'defaultFeeTier', + args: [gasToken], + }), + ]); + if (!fee) return undefined; + const quote = await ctx.pushClient.readContract< + [bigint, bigint, number, bigint] + >({ + address: quoter as `0x${string}`, + abi: UNISWAP_V3_QUOTER_EXACT_OUTPUT_ABI, + functionName: 'quoteExactOutputSingle', + args: [ + { + tokenIn: wpc, + tokenOut: gasToken, + amount: gasFee, + fee, + sqrtPriceLimitX96: BigInt(0), + }, + ], + }); + return quote[0]; + } catch (err) { + printLog( + ctx, + `quoteGasPoolCost — unavailable (${ + err instanceof Error ? err.message : String(err) + })` + ); + return undefined; + } +} diff --git a/packages/core/src/lib/orchestrator/internals/gas-usd-sizer.ts b/packages/core/src/lib/orchestrator/internals/gas-usd-sizer.ts index e033b6151..e14df555d 100644 --- a/packages/core/src/lib/orchestrator/internals/gas-usd-sizer.ts +++ b/packages/core/src/lib/orchestrator/internals/gas-usd-sizer.ts @@ -16,7 +16,8 @@ import { CHAIN_INFO } from '../../constants/chain'; import { PriceFetch } from '../../price-fetch/price-fetch'; import type { OrchestratorContext } from './context'; import { printLog } from './context'; -import { getPcUsdPrice, usdToPc } from './pc-usd-oracle'; +import { getPcUsdPrice, pcToUsd, usdToPc } from './pc-usd-oracle'; +import { quoteGasPoolCost } from './gas-pool-quote'; // ============================================================================ // Thresholds (USD, 8 decimals) @@ -25,6 +26,11 @@ import { getPcUsdPrice, usdToPc } from './pc-usd-oracle'; const ONE_USD_8D = parseUnits('1', 8); // 100_000_000 const TEN_USD_8D = parseUnits('10', 8); // 1_000_000_000 +/** EVM destinations whose gas token is not ETH. No USD feed exists for them + * (the BSC gateway's feed is ETH/USD), so their gas is valued at the Push pool + * rate the gateway's swap actually pays. */ +const POOL_VALUED_GAS_CHAINS: ReadonlySet = new Set([CHAIN.BNB_TESTNET]); + // ============================================================================ // Types // ============================================================================ @@ -41,6 +47,8 @@ export interface GasSizingInput { * Used to price gasFee in USD via PriceFetch (Chainlink ETH/USD, Pyth SOL/USD). */ destinationChain: CHAIN; + /** UniversalCore and gas token, for pool-valued destinations. */ + pool?: { universalCore: `0x${string}`; gasToken: `0x${string}` }; } export interface GasSizingDecision { @@ -63,7 +71,7 @@ export async function sizeOutboundGas( ctx: OrchestratorContext, input: GasSizingInput ): Promise { - const { gasFee, originChain, destinationChain } = input; + const { gasFee, originChain, destinationChain, pool } = input; // 1) Price gasFee in USD (8 decimals). gasFee is denominated in the // destination native (pETH = ETH value, pSOL = SOL value). The price @@ -75,27 +83,41 @@ export async function sizeOutboundGas( destVm === VM.SVM ? CHAIN.SOLANA_DEVNET : CHAIN.ETHEREUM_SEPOLIA; const destDecimals = destVm === VM.SVM ? 9 : 18; - let destUsdPrice: bigint; - try { - destUsdPrice = await new PriceFetch(ctx.rpcUrls).getPrice(priceSourceChain); - } catch (err) { + const poolPcIn = + pool && POOL_VALUED_GAS_CHAINS.has(destinationChain) + ? await quoteGasPoolCost(ctx, pool.universalCore, pool.gasToken, gasFee) + : undefined; + let gasUsd: bigint; + if (poolPcIn !== undefined) { + gasUsd = await pcToUsd(ctx, poolPcIn, originChain); printLog( ctx, - `sizeOutboundGas — PriceFetch.getPrice failed (${err instanceof Error ? err.message : String(err)}); treating as Case B passthrough` + `sizeOutboundGas — gasFee=${gasFee.toString()} valued at the Push pool: ` + + `pcIn=${poolPcIn.toString()}, gasUsd=${gasUsd.toString()} (1e8)` ); - // Degrade gracefully: assume the raw gasFee is already a sane size and - // convert 1:1 to native PC. Callers keep their existing fallback logic. - return passthroughAsCaseB(ctx, gasFee, originChain); - } + } else { + let destUsdPrice: bigint; + try { + destUsdPrice = await new PriceFetch(ctx.rpcUrls).getPrice(priceSourceChain); + } catch (err) { + printLog( + ctx, + `sizeOutboundGas — PriceFetch.getPrice failed (${err instanceof Error ? err.message : String(err)}); treating as Case B passthrough` + ); + // Degrade gracefully: assume the raw gasFee is already a sane size and + // convert 1:1 to native PC. Callers keep their existing fallback logic. + return passthroughAsCaseB(ctx, gasFee, originChain); + } - const oneDestNativeUnit = parseUnits('1', destDecimals); - const gasUsd = (gasFee * destUsdPrice) / oneDestNativeUnit; + const oneDestNativeUnit = parseUnits('1', destDecimals); + gasUsd = (gasFee * destUsdPrice) / oneDestNativeUnit; - printLog( - ctx, - `sizeOutboundGas — gasFee=${gasFee.toString()} ${destVm === VM.SVM ? 'pSOL' : 'pETH'} (${destDecimals}d), ` + - `destUsdPrice=${destUsdPrice.toString()} (1e8), gasUsd=${gasUsd.toString()} (1e8)` - ); + printLog( + ctx, + `sizeOutboundGas — gasFee=${gasFee.toString()} ${destVm === VM.SVM ? 'pSOL' : 'gas token'} (${destDecimals}d), ` + + `destUsdPrice=${destUsdPrice.toString()} (1e8), gasUsd=${gasUsd.toString()} (1e8)` + ); + } // 2) Categorize and compute target USD for each leg. let category: GasSizingCategory; diff --git a/packages/core/src/lib/orchestrator/internals/svm-rent.ts b/packages/core/src/lib/orchestrator/internals/svm-rent.ts index 68515e3a4..711b1efdc 100644 --- a/packages/core/src/lib/orchestrator/internals/svm-rent.ts +++ b/packages/core/src/lib/orchestrator/internals/svm-rent.ts @@ -199,6 +199,7 @@ export async function ensureSvmFinalizeGasBudgetQuote({ pathTag, pc20, requoteFn, + onMinimum, }: { ctx: OrchestratorContext; ueaAddress: `0x${string}`; @@ -216,6 +217,8 @@ export async function ensureSvmFinalizeGasBudgetQuote({ * PC20 token, which has no PRC20 config. */ requoteFn?: (gasLimit: bigint) => Promise; + /** Receives the finalize minimum, so strict callers can refuse a short quote. */ + onMinimum?: (minGasFee: bigint) => void; }): Promise { const minGasFee = await getSvmFinalizeGasBudget({ ctx, @@ -225,6 +228,7 @@ export async function ensureSvmFinalizeGasBudgetQuote({ burnAmount, pc20, }); + onMinimum?.(minGasFee); if (quote.gasFee >= minGasFee) { const resolvedQuote = resolveSvmGasLimitUsed(quote); From 2c52d747c1e9114928b38432433abe9621987b36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 21 Oct 2026 04:15:54 +0000 Subject: [PATCH 25/27] release(core): bump to 6.0.27-alpha.6 [skip ci] --- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 1b7b4a8b1..9515d4410 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,9 @@ +@pushchain/core@6.0.27-alpha.6 (2026-10-21) + +- fix: route exact AGW tokens and size agent gas from the pool + +--- + @pushchain/core@6.0.27-alpha.5 (2026-10-20) - fix: clarify agentic creation permissions and route guidance diff --git a/packages/core/package.json b/packages/core/package.json index aa3ee917b..87aaa3467 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.27-alpha.5", + "version": "6.0.27-alpha.6", "homepage": "https://push.org", "keywords": [ "pushchain", From 077d007cfb232a3a07439274b46cafe34679e7af Mon Sep 17 00:00:00 2001 From: Mohammed S Date: Sat, 24 Oct 2026 17:09:38 +0530 Subject: [PATCH 26/27] feat(core): ship working AGW presets, tokens-only rules and MOVEABLE ALL Act on the product, preset and contract answers (Harsh, Aman, Zaryab): - Presets contain only what works: Uniswap V3 and Aave V3 on Sepolia, Base Sepolia and Arbitrum Sepolia. Each sends native ETH from Push and wraps it on arrival; DEX swaps WETH and Circle USDC on the destination only. get() returns { rules }: readiness and the network option are removed, PREDICTION and the mainnet templates are dropped, and supplied assets must be one ETH asset per chain with positive limits. - Tokens-only EVM rules: ERC20 assets without allowedCalls become transfer calls pinned to the owner's EVM address, plus opt-in transferTo recipients. Native assets and Solana are refused. - MOVEABLE.TOKEN..ALL expands to every routable token with default limits; utils.agentic.assets.all(chain) exposes the list. create, add and update take the new AuthoredRule input type; Rule is unchanged. - Report the eight-function Push-chain limit before the generic 1..32. - Refuse Solana fromEnd data pins on write until the policy fix. - Decode InnerValueExceedsAllowance and BeneficiaryMismatch, which URP encodes by selector and its compiled ABI omits. Tests: rewritten preset unit, local v6 and live acceptance specs (the DEX case picks the fee tier with QuoterV2); new tokens-only/ALL unit and live specs; the old prefunded preset fixtures are removed. All six presets and both tokens-only/ALL cases pass live on Donut. --- packages/core/AGW.md | 189 ++-- .../__agw-local__/preset-acceptance.spec.ts | 34 +- .../core/__agw-local__/v5-multiasset.spec.ts | 444 +++++----- .../__e2e__/agw/presets-acceptance.spec.ts | 258 +++--- .../__e2e__/agw/presets-expanded-live.spec.ts | 819 ------------------ .../core/__e2e__/agw/presets-live.spec.ts | 556 ------------ .../core/__e2e__/agw/tokens-only-live.spec.ts | 220 +++++ packages/core/__e2e__/ci/preflight.ts | 12 +- packages/core/__e2e__/ci/suite.ts | 92 +- .../__e2e__/shared/agw-preset-acceptance.ts | 3 - packages/core/scripts/check-agw-package.js | 15 +- .../scripts/check-agw-preset-compatibility.js | 412 +++------ .../core/scripts/check-agw-preset-routes.js | 28 +- .../core/scripts/check-agw-route-registry.js | 3 +- .../agentic/__tests__/creation-errors.spec.ts | 171 ++-- .../agentic/__tests__/preset-catalog.spec.ts | 420 +++++---- .../__tests__/preset-default-policy.spec.ts | 190 +--- .../__tests__/preset-default-tokens.spec.ts | 154 ---- .../__tests__/preset-operation-limits.spec.ts | 264 +----- .../agentic/__tests__/preset-routes.spec.ts | 174 +--- .../src/lib/agentic/__tests__/presets.spec.ts | 241 ++---- .../__tests__/response-surface.spec.ts | 23 +- .../lib/agentic/__tests__/rule-inputs.spec.ts | 301 +++++++ .../agentic/__tests__/svm-internals.spec.ts | 14 + .../agentic/__tests__/token-routes.spec.ts | 42 - .../core/src/lib/agentic/agentic.types.ts | 22 +- packages/core/src/lib/agentic/codec/rules.ts | 7 +- .../core/src/lib/agentic/codec/svm-terms.ts | 13 + packages/core/src/lib/agentic/constants.ts | 1 - .../core/src/lib/agentic/error-messages.ts | 23 +- packages/core/src/lib/agentic/index.ts | 5 +- .../src/lib/agentic/management/prepare.ts | 7 +- .../src/lib/agentic/management/rule-inputs.ts | 310 +++++++ .../src/lib/agentic/management/svm-public.ts | 3 +- .../src/lib/agentic/presets/aave-reserves.ts | 146 ---- .../core/src/lib/agentic/presets/catalog.ts | 562 ++++-------- .../src/lib/agentic/presets/default-policy.ts | 94 +- .../src/lib/agentic/presets/default-tokens.ts | 80 -- .../core/src/lib/agentic/presets/index.ts | 303 +------ .../lib/agentic/presets/operation-limits.ts | 120 +-- .../core/src/lib/agentic/presets/routes.ts | 53 -- .../core/src/lib/agentic/presets/types.ts | 80 +- packages/core/src/lib/agentic/public-rule.ts | 32 +- packages/core/src/lib/agentic/revert.ts | 9 + packages/core/src/lib/constants/index.ts | 1 + packages/core/src/lib/constants/tokens.ts | 41 +- packages/core/src/lib/index.ts | 5 +- packages/core/src/lib/utils.ts | 7 +- 48 files changed, 2332 insertions(+), 4671 deletions(-) delete mode 100644 packages/core/__e2e__/agw/presets-expanded-live.spec.ts delete mode 100644 packages/core/__e2e__/agw/presets-live.spec.ts create mode 100644 packages/core/__e2e__/agw/tokens-only-live.spec.ts delete mode 100644 packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts create mode 100644 packages/core/src/lib/agentic/__tests__/rule-inputs.spec.ts create mode 100644 packages/core/src/lib/agentic/management/rule-inputs.ts delete mode 100644 packages/core/src/lib/agentic/presets/aave-reserves.ts delete mode 100644 packages/core/src/lib/agentic/presets/default-tokens.ts delete mode 100644 packages/core/src/lib/agentic/presets/routes.ts diff --git a/packages/core/AGW.md b/packages/core/AGW.md index 78d4bd36e..d2d28b644 100644 --- a/packages/core/AGW.md +++ b/packages/core/AGW.md @@ -11,6 +11,7 @@ import { PushChain, AgenticError, AgenticRevertError, + type AuthoredRule, type Rule, } from '@pushchain/core'; import { @@ -132,9 +133,9 @@ function walletErrorText(error: unknown): string { Creation and rule-preparation errors include a zero-based `details.ruleIndex` when a rule can be identified, with `assetIndex`, `callIndex` and `field` where available. The UI message uses a one-based rule number. Use these details to show the error on the relevant rule instead of only in a footer. Read/receipt failures preserve their causes and transaction hashes; do not automatically retry a submitted transaction whose outcome is unknown. -`TOKEN_ROUTE_UNAVAILABLE` means an asset cannot be resolved for transfers from Push. Choose a token with a supported route on this network, or wait for a verified PRC20 route for that exact token address. Changing a symbol does not create a route. Display `error.userMessage` and check preset readiness before creation. +`TOKEN_ROUTE_UNAVAILABLE` means an asset cannot be resolved for transfers from Push. Choose a token with a supported route on this network, or wait for a verified PRC20 route for that exact token address. Changing a symbol does not create a route. Display `error.userMessage`. -A DEX condition-limit failure reports `details.reason = 'RULE_CONSTRAINT_LIMIT'`, `actual`, `limit` and `callCount`. Conditions here are generated input checks (for example token, recipient and amount checks), rather than the number of visible function names. A stock two-token, single-router preset contains six allowed-call entries and twenty conditions. Appending the same call list again makes forty conditions. The SDK rejects this; it does not deduplicate, remove conditions or split authority automatically. Preserve per-chain rules and regenerate after edits rather than appending generated calls. +A DEX condition-limit failure reports `details.reason = 'RULE_CONSTRAINT_LIMIT'`, `actual`, `limit` and `callCount`. Conditions here are generated input checks (for example token, recipient and amount checks), rather than the number of visible function names. A stock DEX preset contains eight allowed-call entries and twenty-one conditions. Appending the same call list again makes forty-two conditions. The SDK rejects this; it does not deduplicate, remove conditions or split authority automatically. Preserve per-chain rules and regenerate after edits rather than appending generated calls. For the review screen, show the actual network (including testnet name), selected token addresses and limits. Label maxGasPerCall as a maximum PC gas budget, not a guaranteed fee or execution gas-unit count. maxTotal meters cumulative Push outbounds, including outbounds whose destination execution later fails; it is not a lifetime destination-spend limit. Destination pins and maxValue limit each call separately. @@ -163,7 +164,7 @@ function solanaRule(agent: Address, program: string): Rule { Use the program's actual account positions. `{ account, expected: WALLET, token: mint }` pins the wallet's ATA, using the verified mint/token-program context. Fixed integer, bool, pubkey and fixed-array layouts are supported; dynamic or undefined layouts fail before signing. Raw eight-byte discriminators support account pins; argument pins also need argument types. A nonzero per-call EVM `maxValue` has no Solana encoding. -Solana reads return `format: 'decoded'` with exact stored discriminators, offsets and constraints. This raw form is accepted by `rules.update`, preserving restrictions without inventing an IDL. Reusing it for another wallet/gateway is rejected. Send-data encoding still uses the existing `utils.helpers.encodeTxData` IDL helper; rule authoring and transaction encoding are separate operations. +Solana reads return `format: 'decoded'` with exact stored discriminators, offsets and constraints. This raw form is accepted by `rules.update`, preserving restrictions without inventing an IDL. Reusing it for another wallet/gateway is rejected. A decoded rule with a data pin counted from the end of the instruction data (`fromEnd: true`) is refused on create/add/update (`INVALID_RULE`, `details.reason = 'FROM_END_PIN_UNSUPPORTED'`): the deployed SvmRulebook does not tie the instruction-data length to the rule, so appended bytes could shift such a pin. Existing rules with these pins still read normally. Send-data encoding still uses the existing `utils.helpers.encodeTxData` IDL helper; rule authoring and transaction encoding are separate operations. ## Spend, checkpoints and execution @@ -189,12 +190,13 @@ The wire encoder uses uint32 maximum for unlimited native calls and 255 for unsp ## Preset discovery and templates (section 7) -`PushChain.utils.agentic` contains `presets.list/get`. The categories are `AGENTIC.PRESETS.CATEGORY.DEXES`, `LENDING`, `STAKING` and `PREDICTION`. The catalog includes mainnet and testnet templates. Discovery is network-independent: pass `chainNamespace` to select the intended chain. A mainnet template does not imply that the connected Push network supports execution to it: +`PushChain.utils.agentic` contains `presets.list/get`. The categories are `AGENTIC.PRESETS.CATEGORY.DEXES`, `LENDING` and `STAKING`. The catalog contains only presets that ran live on the Donut testnets: -- DEXES: Uniswap V3 on Ethereum, Base and Arbitrum (mainnet and their Sepolia testnets); PancakeSwap V3 on BNB mainnet and BSC testnet. -- LENDING: Aave on Ethereum, Base, Arbitrum and BNB mainnets, plus Sepolia/Base Sepolia/Arbitrum Sepolia; Moonwell on Base mainnet/testnet and Venus on BNB mainnet/testnet. -- STAKING: EigenLayer deposits on Ethereum mainnet and Sepolia; Lido stake and wrap/unwrap on Ethereum mainnet. Withdrawal requests with dynamic amounts, claims with position-derived amounts and EigenLayer withdrawals are excluded from these bounded presets. A selected native asset supplies the Lido `submit` value cap; omitted caps in explicitly supplied assets remain zero. -- PREDICTION: no entries yet. Hide categories with empty `chains` lists. +- DEXES: Uniswap V3 on Sepolia, Base Sepolia and Arbitrum Sepolia. +- LENDING: Aave V3 on Sepolia, Base Sepolia and Arbitrum Sepolia. +- STAKING: no entries yet. Hide categories with empty `chains` lists. + +Every preset sends only native ETH from Push. The agent wraps it into WETH on the destination chain as the first call of the same transaction, then uses the app. Because native ETH is routed to every listed chain, every listed preset can be created; there is no readiness report to check. ```ts const { presets } = PushChain.utils.agentic.presets.list({ type: 'CATEGORY' }); @@ -202,141 +204,91 @@ const availablePresets = presets.filter(preset => preset.chains.length > 0); const { rules: reviewTemplates } = PushChain.utils.agentic.presets.get( PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, ); -// Every populated chain includes curated destination tokens in rule.assets. -// The SDK supplies spending limits, a PC budget and a seven-day expiry. -// Supply the agent to get complete Rule[] configuration. +// Each rule sends native ETH from Push with the SDK's limits, a 20 PC gas +// ceiling and a seven-day expiry. Supply the agent to get complete Rule[]. const configuredPreset = PushChain.utils.agentic.presets.get( PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.LENDING, { chainNamespace: 'eip155:11155111', agent: '0x0000000000000000000000000000000000001234' }, ); -// Destination assets must already be funded; no Push token exports are granted. -// Show configuredPreset.readiness.chains[0].selectedAssets in the review UI. -// Pass configuredPreset.rules directly to create after checking readiness. -// The helper below illustrates explicit overrides, rather than required UI input. +// Pass configuredPreset.rules directly to create. -function filledPreset(agent: Address, tokenIn: Address, tokenOut: Address) { +function filledPreset(agent: Address, maxPerCall: bigint, maxTotal: bigint) { return PushChain.utils.agentic.presets.get( PushChain.CONSTANTS.AGENTIC.PRESETS.CATEGORY.DEXES, { - chainNamespace: 'eip155:11155111', + chainNamespace: 'eip155:84532', agent, validUntil: Math.floor(Date.now() / 1000) + 3600, - maxGasPerCall: BigInt(10) ** BigInt(18), assets: [ - { token: tokenIn, maxPerCall: BigInt(100), maxTotal: BigInt(1000) }, - { token: tokenOut, maxPerCall: BigInt(100), maxTotal: BigInt(1000) }, + { token: PushChain.CONSTANTS.MOVEABLE.TOKEN.BASE_SEPOLIA.ETH, maxPerCall, maxTotal }, ], }, ); } ``` -The example caps are illustrative raw units; choose caps for the actual token decimals and gas requirements. `get` groups calls into one rule per chain. The result is `{ rules, readiness }`. With an agent supplied, `rules` is typed as `Rule[]`; otherwise it is `PresetRuleTemplate[]` with a generated expiry and missing agent. This type distinction alone does not establish readiness. When `assets` is omitted, each chain includes its curated destination tokens with the explicit spending limits below. An explicit `assets` array replaces the defaults for every returned chain; `[]` selects no tokens. Assets in an explicit list must name their chain unless a single chain filter is supplied. For an explicit asset list, omitted caps still default to zero. An omitted gas budget uses the preset policy; an explicit zero remains zero. - -DEX discovery without `assets` includes a curated wrapped-native/stablecoin pair per chain, with both swap directions, exact-input/output functions and the token-specific input caps below. It does not require the UI to select tokens separately for every network. DEX discovery also works with an explicit empty asset list or with assets for only some chains. A chain with fewer than two explicitly supplied tokens returns full-signature review calls with wallet-recipient pins and an explicit zero input-amount maximum. A single supplied token is also pinned as the input. These calls cannot spend swap input tokens, even if asset budgets are filled later. Call `get` again after selecting both tokens to replace the review calls with the complete token-pinned rules; merely assigning `rule.assets` is insufficient. - -With two ERC20 tokens, `get` expands ordered pairs into exact-input and exact-output calls with token, wallet-recipient and input-amount pins. Native markers and expansions exceeding the policy's 32-pin limit are rejected. Two tokens fit the shipped router template; three do not. Static tuple fields use paths such as `[0, 3]`. - -Reviewed EVM spenders generate approvals pinned to that spender and bounded by the asset's per-call cap. Money-market approvals are restricted to the market's underlying token. Distinct spenders become separate pinned alternatives under v6. These destination approvals are ordinary ERC20 allowances; the wallet's automatic Push-side gateway approval is separate. - -Templates describe permissions, not available liquidity or guaranteed execution. Verify destination token balances, supported reserves and source PRC20 routes before use: protocol testnet mock tokens may differ from MOVEABLE tokens. Presets do not sign, fetch app ABIs or modify wallets. Dynamic-path swaps, Solana protocol templates and unconfirmed deployments are excluded. Mainnet addresses were checked read-only; no mainnet transactions were executed. Other mainnet protocols in the proposal remain excluded until their deployment and permission constraints are verified. Local policy tests do not execute the destination protocols. - -### Curated token defaults - -These are small, protocol-specific subsets chosen to fit the 32-call/32-pin ceilings. The full reserve catalog is not automatically granted. - -| Network | DEX pair | Lending tokens | -| --- | --- | --- | -| Ethereum | WETH / USDC | WETH, USDC | -| Base | WETH / USDC | USDC (Aave and Moonwell) | -| Arbitrum | WETH / native USDC | WETH, native USDC | -| BNB | WBNB / USDT | USDT (Aave and Venus) | -| Sepolia | DEX WETH / Circle USDC | Aave faucet LINK | -| Base Sepolia | WETH / Circle USDC | Aave faucet LINK, Moonwell mock USDbC and WETH | -| Arbitrum Sepolia | WETH / Circle USDC | Aave USDC | -| BSC testnet | WBNB / Venus USDT | Venus USDT, USDC | +`get` returns `{ rules }`, one rule per chain, in the shape `create` takes. With an agent supplied, `rules` is typed as `Rule[]`; otherwise it is `PresetRuleTemplate[]` with a generated expiry and missing agent. An omitted gas budget uses the preset policy; an explicit zero remains zero. -Staking defaults are ETH/stETH/wstETH on Ethereum and the reviewed EigenLayer strategy's WETH on Sepolia. Testnet tokens with similar names can have different addresses; the returned addresses are authoritative. Defaults do not guarantee active reserves, liquidity or a Push PRC20 route. An unmodified preset cannot be granted when its assets lack matching PRC20 routes. The SDK preserves the selected tokens and caps and reports unavailable routes; it never removes assets to bypass this check. On Donut, every default asset of DEXES Sepolia and LENDING Arbitrum Sepolia currently has a registered route; the other testnet combinations do not. No runtime metadata/price fetch is added to get. +### What each preset allows -### Funding and release-network checks +Caps marked "ETH cap" equal the ETH asset's `maxPerCall`, in wei. -Missing PRC20 registry entries produce `BRIDGE_ASSET_UNAVAILABLE`, and readiness is false while the returned rule retains all requested assets. No address or cap is silently replaced. `network` defaults to `TESTNET_DONUT` (`TESTNET` is accepted as an alias). Networks without a verified AGW deployment report `AGW_NETWORK_UNAVAILABLE`. - -The public preset API has no `funding` option. Remove it from existing integrations; supplying it now produces `INVALID_RULE` rather than silently changing permissions. Internal prefunded CEA test fixtures explicitly clear bridge assets to isolate protocol constraints. Those fixtures do not demonstrate that an unsupported preset route is executable. - -Existing mappings distinguish token identity by chain and address, including address casing for EVM. WETH/stETH Sepolia mappings and string/address-word source metadata are supported; mainnet or other-chain WETH is never aliased to them. Exact routes enabled in the Push node registry are also resolved by address: Circle testnet USDC on Sepolia, Base Sepolia and Arbitrum Sepolia maps to its own PRC20, separate from the routed mock USDC. Grant preparation still verifies each PRC20 source on chain. Protocol mock LINK, Moonwell/Venus tokens, Base/Arbitrum WETH, BNB testnet WBNB and the EigenLayer WETH remain unavailable for bridge funding until matching routes exist. - -### Default permission policy - -Only preset-generated assets receive these positive values. Direct rules, unknown tokens and explicit asset lists do not acquire guessed limits. - -| Selected token | Per allowed call / Push outbound | Cumulative Push-outbound total | -| --- | --- | --- | -| USDC / USDT / mock USDbC | 10 tokens | 100 tokens | -| ETH / WETH / stETH / wstETH | 0.001 token | 0.01 token | -| WBNB | 0.01 token | 0.1 token | -| LINK | 1 token | 10 tokens | +| Preset | Allowed calls | +| --- | --- | +| DEXES | `WETH.deposit()` with `maxValue` = ETH cap; `WETH.approve(router, ≤ ETH cap)`; `exactInputSingle`/`exactOutputSingle` WETH → USDC with WETH and USDC pinned, recipient = wallet and input ≤ ETH cap; `USDC.approve(router, ≤ 10 USDC)`; the same two swaps USDC → WETH with input ≤ 10 USDC; `WETH.withdraw(≤ ETH cap)` to unwrap | +| LENDING | `WETH.deposit()` with `maxValue` = ETH cap; `WETH.approve(pool, ≤ ETH cap)`; Aave `supply`, `withdraw`, `borrow`, `repay` with the asset pinned to the reserve WETH, the beneficiary pinned to the wallet and the amount ≤ ETH cap; `WETH.withdraw(≤ ETH cap)` | -Amounts use verified token decimals, keyed by chain and address, rather than symbol or live prices. For example, the USDT per-call limit is `10 * 10^18` on BNB mainnet and `10 * 10^6` on BSC testnet. These are fixed token quantities, not USD budgets. +| Network | Uniswap router | DEX WETH / USDC | Aave pool | Aave WETH reserve | +| --- | --- | --- | --- | --- | +| Sepolia | `0x3bFA…Ae48E` | canonical WETH `0xfff9…6B14` / Circle USDC `0x1c7D…7238` | `0x6Ae4…8951` | Aave WETH `0xC558…9c3c` | +| Base Sepolia | `0x94cC…2bc4` | WETH `0x4200…0006` / Circle USDC `0x036C…CF7e` | `0x8bAB…aE27` | WETH `0x4200…0006` | +| Arbitrum Sepolia | `0x101F…663E` | WETH `0x980B…7c73` / Circle USDC `0x75fa…AA4d` | `0xBfC9…2Eff` | Aave WETH `0x1dF4…053D` | -The default expiry is seven days from each get call, computed once for all returned rules. The default gas ceiling is **20 PC per outbound**, including the value checked by URP; it is not a flat fee or a reservation. Actual sends obtain a fresh quote and remain subject to the rule cap. This ceiling has testnet protocol-flow coverage; mainnet templates do not have funded execution acceptance. Quotes may exceed the cap, requiring an explicit higher budget. +Each router's `WETH9()` is the DEX WETH it pins. Circle USDC is the only token with WETH liquidity on these Uniswap deployments; it is used only on the destination chain and is never sent from Push. The fee tier is not pinned: testnet pools are thin and often mispriced (on Arbitrum Sepolia the 0.05% pool returns nothing for small swaps), so an agent should pick the tier from a QuoterV2 quote. Testnet tokens with similar names can have different addresses; the returned addresses are authoritative. Templates describe permissions, not available liquidity or guaranteed execution. Presets do not sign, fetch app ABIs or modify wallets. -Explicit `validUntil` and `maxGasPerCall` override policy values; zero gas is preserved and reports incomplete readiness. An explicit `assets` array replaces all default tokens and limits, and omitted caps inside it remain zero. To change a preset cap while retaining its token set, take the returned assets, replace the caps and pass them back to get. Do not just edit the returned rule assets: the allowed-call pins and approvals also need regeneration. +### Limits and overrides -`maxPerCall` applies to each allowed destination call; multiple calls may each consume that amount. `maxTotal` meters assets sent from Push, not aggregate destination activity. No destination lifetime limit is implied by these defaults. The general rules API continues to treat omitted caps as zero. +| Setting | Default | +| --- | --- | +| ETH per Push outbound and per allowed call | 0.001 ETH | +| ETH sent from Push in total | 0.01 ETH | +| USDC per swap on the destination chain | 10 USDC (fixed in the returned rule) | +| Gas ceiling | 20 PC per outbound | +| Expiry | seven days from the get call | -### Configure, inspect and regenerate +Pass `assets` to set the ETH limits. Each selected chain needs exactly one native ETH asset, written as the zero address or the chain's `MOVEABLE.TOKEN..ETH`, with positive `maxPerCall` and `maxTotal`. A non-ETH token, a missing or zero limit, an empty array or a selected chain without an asset throws `INVALID_RULE`. Assets must name their chain unless a single chain filter is supplied. Call `get` again after changing a limit: the wrap value, approvals and operation pins all follow `maxPerCall`. To change the 10 USDC swap limit, edit the returned rule before `create`. -`readiness.ready` requires complete configuration plus offline SDK deployment, destination and bridge-registry support. Per-chain `configurationReady` reports input completeness separately. These checks do not verify live balances, reserve flags, liquidity or deployed bytecode. Grant preparation verifies wiring and token metadata on-chain before signing. Per-chain `selectedAssets` mirrors the selected rule assets; `bridgeAssets` reports registry presence, not verified live deployment. Mainnet templates remain discoverable but report `DESTINATION_UNAVAILABLE` on Donut. Check readiness before passing `rules` to create/update. +The 20 PC gas ceiling is not a flat fee or a reservation. Actual sends obtain a fresh quote and remain subject to the rule cap; unused destination gas is refunded. -Tokens and permission defaults come directly from the SDK: omitted `assets` populates the curated subset and its caps in every returned `rule.assets`. Omitted gas and expiry use the policy below. The caller supplies the agent and can filter chains; no per-network token selection is required. Show the generated permissions for owner review and allow explicit overrides. Regenerate via get after an edit so operation pins/approvals match the new caps. Show explicitly zero-cap calls as disabled and present excluded calls clearly. A zero bridge limit can be intentional when assets are already at the destination; it is reported separately from operation readiness. +### Configure and regenerate ```ts async function createConfiguredDex( - client: PushChain, agent: Address, tokenIn: Address, tokenOut: Address, + client: PushChain, agent: Address, maxPerCall: bigint, maxTotal: bigint, ) { - const configured = filledPreset(agent, tokenIn, tokenOut); - if (!configured.readiness.ready) { - // Render these issues in the configuration screen; do not present it as ready. - return { issues: configured.readiness.chains }; - } + const configured = filledPreset(agent, maxPerCall, maxTotal); return client.agentic.create('My DEX agent', { rules: configured.rules }); } async function replaceConfiguredDex( client: PushChain, wallet: Address, rulesId: Hex, - agent: Address, tokenIn: Address, tokenOut: Address, + agent: Address, maxPerCall: bigint, maxTotal: bigint, ) { - // Re-run get after editing tokens or limits. Changing rule.assets alone - // does not regenerate allowedCalls, token pins or approvals. - const configured = filledPreset(agent, tokenIn, tokenOut); - if (!configured.readiness.ready) throw new Error('Complete the preset inputs'); + // Re-run get after editing limits so every cap in allowedCalls follows them. + const configured = filledPreset(agent, maxPerCall, maxTotal); return client.agentic.wallet(wallet).rules.update({ rules: [{ rulesId, rule: configured.rules[0] }], }); } ``` -Save the preset category and editable inputs in the UI if subsequent edits should regenerate the preset. On-chain rules do not retain preset provenance or display metadata. A regenerated result is a replacement: do not append it to the old allowedCalls or keep the old token approvals. An update creates a new rulesId and resets counters, as documented above. +Save the preset category and editable inputs in the UI if subsequent edits should regenerate the preset. On-chain rules do not retain preset provenance or display metadata. A regenerated result is a replacement: do not append it to the old allowedCalls. An update creates a new rulesId and resets counters, as documented above. -### Amount units and enforcement scope +### Enforcement scope -| Preset operation | Generated restriction | -| --- | --- | -| DEX swap | Selected input/output token equality, wallet recipient, maximum input amount | -| Aave supply/withdraw/borrow/repay | Listed reserve token equality, wallet beneficiary, amount maximum | -| Moonwell/Venus mint/redeemUnderlying/borrow/repayBorrow | Maximum in the market's underlying-token units | -| EigenLayer deposit | Selected token equality and deposit amount maximum | -| Lido wrap/unwrap | stETH/wstETH input limits respectively; their units are not interchangeable | -| Lido submit | Native-value cap from the selected native asset's maxPerCall | - -Aave reserve choices come from the reviewed official address book; fixed-token markets only enable for their actual underlying. Unknown assets do not receive an Aave approval or a positive Aave operation cap. A reserve listing is not a guarantee that its current supply cap, pause state or liquidity allows a transaction. - -Market `redeem(uint256)` takes shares: it is excluded and `redeemUnderlying(uint256)` is provided instead. Lido's dynamic withdrawal arrays and position-based claims are also excluded because an underlying amount cap cannot safely be converted into those arguments by this pure helper. Custom rules remain possible through the normal rule API. - -**A per-call limit applies to each allowed contract call, not the sum of several calls in one transaction. `maxTotal` is the cumulative Push-outbound amount, not lifetime turnover on the destination.** These statements also hold when a call's tokens are already in the CEA. Destination lifetime or batch-total accounting requires contract support. Bounded approvals do not supply such accounting. +**A per-call limit applies to each allowed contract call, not the sum of several calls in one transaction. `maxTotal` is the cumulative Push-outbound amount, including outbounds whose destination execution later fails; it is not lifetime turnover on the destination.** These statements also hold for tokens already in the wallet's destination account, such as USDC from an earlier swap. Destination lifetime or batch-total accounting requires contract support. -The contract allows at most 32 pins across a universal rule. Expansion fails explicitly if the selected assets/apps exceed that ceiling; no restrictions are silently dropped. Two Aave reserves fit its four-operation template; three do not. Blank/incomplete inputs return zero-cap review calls, and regeneration replaces them with the selected token-specific calls. +The contract allows at most 32 pins across a universal rule. A DEX preset uses 8 calls and 21 pins, and a LENDING preset 7 calls and 15 pins. Expansion fails explicitly if an edited rule exceeds the ceiling; no restrictions are silently dropped. Custom rules remain possible through the normal rule API. ## Validation @@ -345,28 +297,49 @@ Run `node packages/core/scripts/check-agw-docs.js` to typecheck these examples. ### Live preset acceptance -The opt-in `agw-presets` E2E group exercises real Donut-to-Sepolia protocol calls. From `packages/core`, run `npm run e2e:preflight -- --group agw-presets --dry-run`, then `npm run e2e:ci -- --group agw-presets`. It uses the existing E2E credentials in memory. Each scenario funds a fresh wallet with at most 65 PC plus a fresh agent's configured gas allocation; the Uniswap case also uses 0.000002 pETH. No mainnet transaction is part of this group. +The opt-in `agw-presets-acceptance` group runs all six shipped presets on Donut. From `packages/core`, run `npm run e2e:preflight -- --group agw-presets-acceptance --dry-run`, then `npm run e2e:ci -- --group agw-presets-acceptance`. Each case passes the returned `rules` unchanged to `create`, reads back every asset, cap, function and pin, then has the agent send ETH twice (first use and subsequent) with `[deposit, approve, swap or supply]`. It checks destination success, the resulting USDC or aToken balance and the recorded ETH spend. The owner needs the chain's gas PRC20 (pETH, pETH_BASE or pETH_ARB) for two bounded sends; each case funds a fresh wallet with 50 PC. No mainnet transaction is part of this group. -Uniswap uses destination WETH/Circle USDC; Aave uses its faucet LINK. Their generated preset calls and pins are retained, but bridge `assets` are set to `[]` because these destination tokens are not a supported SDK PRC20 pair. The owner funds the CEA separately. These tests establish swaps and lending against actual protocols, not the ability to bridge arbitrary assets. Missing liquidity, exhausted reserve caps or an unavailable faucet fail as prerequisites rather than silently skipping. Results are specific to Sepolia and do not certify the other catalog chains. +AGW token resolution uses the destination chain for ordinary token objects: an explicit Base or Arbitrum ETH token must not select Sepolia's PRC20 merely because its symbol/address is identical. An explicit Push PRC20 reference must name the same source chain as the destination; a mismatch fails before signing. +### Creation UI: apps, tokens and preset availability -The additional opt-in group `agw-presets-expanded` covers EigenLayer deposits on Sepolia; Uniswap swaps on Base/Arbitrum Sepolia; PancakeSwap plus Venus on BNB testnet; and Aave plus Moonwell on Base Sepolia. Use the same preflight/run commands with `--group agw-presets-expanded`. Each scenario funds a fresh wallet with at most 100 PC and checks any required destination-native PRC20 before transfer. Native-wrap cases use 10^12 raw native-token units. These are deposit/swap/redemption checks, not borrow/repay or every-market coverage. +**Tokens without an app.** On an EVM network, a rule with ERC20 assets and no `allowedCalls` (omitted or `[]`) lets the agent transfer each token only back to the owner. The SDK adds one `transfer(address,uint256)` call per token, pinned to the owner's own EVM address and capped at that asset's `maxPerCall`. The owner's address is the Push EOA, or the EVM address the owner's account comes from. `transferTo` names more recipients, one more call per token each; the owner stays included. -AGW token resolution uses the destination chain for ordinary token objects: an explicit Base or Arbitrum ETH token must not select Sepolia's PRC20 merely because its symbol/address is identical. An explicit Push PRC20 reference must name the same source chain as the destination; a mismatch fails before signing. +```ts +const tokensOnly: AuthoredRule = { + agent: '0x0000000000000000000000000000000000001234', + chainNamespace: 'eip155:11155111', + validUntil: Math.floor(Date.now() / 1000) + 7 * 86400, + assets: [ + { + token: PushChain.CONSTANTS.MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.USDC, + maxPerCall: BigInt(10_000_000), + maxTotal: BigInt(100_000_000), + }, + ], + // Optional: also allow transfers to this address. + transferTo: ['0x0000000000000000000000000000000000005678'], +}; +``` -### Creation UI: apps, tokens and preset availability +The per-transfer limit can be repeated until the rule expires, so the agent can move the token's whole balance in the destination account, including tokens already there, but only to these recipients. Refused with `INVALID_RULE` and a `details.reason`: -A rule requires at least one allowed function, even when tokens and limits are selected. A tokens-only rule returns `INVALID_RULE`, with `details.reason = 'ALLOWED_FUNCTION_REQUIRED'` and actionable `userMessage`. App selection can only be hidden if the application explicitly supplies the intended function permissions; the SDK does not invent transfer recipients or permissions. +- `TOKENS_ONLY_NATIVE`: native ETH or BNB in such a rule (a rule cannot allow a plain coin transfer). +- `TOKENS_ONLY_UNSUPPORTED`: a Solana rule without an app, or `transferTo` outside EVM. +- `TRANSFER_RECIPIENT_REQUIRED`: an owner whose account comes from Solana and no `transferTo`. +- `TRANSFER_TO_WITH_CALLS`: `transferTo` together with allowed functions; add the transfer as an allowed function instead. -`assets: []` remains valid for a universal rule with allowed calls. It grants no token transfers from Push; it never means all tokens. Allowed calls may still interact with destination-held assets under their configured constraints. There is no all-token wildcard. List each permitted token and cap explicitly (at most eight assets). +A Push-chain rule, or an EVM rule with neither assets nor functions, still returns `ALLOWED_FUNCTION_REQUIRED`. A Push-chain rule allows at most eight functions; other networks allow 32. -For presets, check `readiness.ready` before enabling Create. Display the per-chain issues and the affected token address. Empty selections explain that transfers from Push are disabled. `BRIDGE_ASSET_UNAVAILABLE` identifies an unverified SDK route, not proof that no deployment exists anywhere. Do not substitute a routed token solely because its symbol matches: the protocol must support that exact address, and swaps need compatible liquidity. A transfer-only helper is not included. An ERC20 transfer can be authorized explicitly with a `transfer(address,uint256)` allowed call pinned to a recipient and a per-transfer maximum; it also applies to that token already held in the wallet's destination account. +**All supported tokens.** `assets: [{ token: PushChain.CONSTANTS.MOVEABLE.TOKEN..ALL }]` expands, when the rule is prepared, to every token Push can send to that chain, each with its default limit (10 per send and 100 in total for USDC, USDT and DAI; 0.001 and 0.01 for ETH, WETH and stETH; 0.01 and 0.1 for BNB and SOL). The Circle USDC route and the routed mock USDC are separate tokens and are both listed. Tokens added to Push later are not included in an existing rule. `ALL` takes no limits (`ALL_WITH_LIMITS`); a token listed next to it replaces its default entry. Without an app, `ALL` leaves native ETH/BNB out, so the rule transfers every routable ERC20 back to the owner. Use `PushChain.utils.agentic.assets.all(chainNamespace)` to show the expanded list and its limits before signing. -### Exact-preset acceptance and gas evidence +`assets: []` still grants no transfers from Push; it never means all tokens. Allowed calls may still interact with destination-held assets under their configured constraints. A rule holds at most eight assets. + +Every listed preset can be created; there is no readiness check. Do not substitute a routed token solely because its symbol matches: the protocol must support that exact address, and swaps need compatible liquidity. -The opt-in `agw-presets-acceptance` group exercises all nine shipped testnet category/network combinations. It passes the returned `rules` unchanged to `create`, reads back every asset/cap/function/pin, and performs first-use and subsequent agent operations. Missing routes, protocols or exact PRC20 balances fail before funding; they do not count as passing acceptance. The opt-in `agw-presets-routed` group runs only the cases whose default assets are all routed (DEXES Sepolia and LENDING Arbitrum Sepolia); preflight checks readiness for the selected cases only. The earlier `agw-presets` and `agw-presets-expanded` protocol fixtures use prefunded destination assets and remain separate coverage. +### Exact-preset acceptance and gas evidence -Read-only compatibility checks are available with `node packages/core/scripts/check-agw-preset-compatibility.js /tmp/compatibility.json` after building core. The check verifies known routed source identities (registry and exact node routes), live Aave reserves, Moonwell/Venus underlyings, the reviewed EigenLayer strategy and pools at the preset routers' factories, including each DEX preset's own default pair. A matching symbol alone is insufficient. RPC and read errors are recorded as unknown checks, never as compatibility or as missing routes. No default assets were changed. `node packages/core/scripts/check-agw-route-registry.js /tmp/registry.json` compares the SDK's routes with the Push node's token registry (uregistry): it fails on a mismatched or SDK-only route and lists enabled node routes the SDK does not resolve. +Read-only compatibility checks are available with `node packages/core/scripts/check-agw-preset-compatibility.js /tmp/compatibility.json` after building core. For every preset chain it simulates `deposit()` on the wrap target with native value, checks that the router wraps into that WETH and that Uniswap's QuoterV2 returns USDC for a dust swap at some fee tier, and that the Aave pool lists the WETH reserve. RPC and read errors are recorded and fail the check; they are never reported as compatibility. `node packages/core/scripts/check-agw-route-registry.js /tmp/registry.json` compares the SDK's routes with the Push node's token registry (uregistry): it fails on a mismatched or SDK-only route and lists enabled node routes the SDK does not resolve. `maxGasPerCall` is an owner-authorized ceiling on the PC attached to each outbound. The send-time `gasLimit` is a different field: the multiplier for the destination gas price. Omitted or zero uses the network base (500,000 on the EVM testnets, 960 on Solana devnet). Agent EVM sends without an explicit gasLimit instead use 2,000,000 on first use (the destination account is deployed), and 1,000,000 when they export a non-native token or make more than one destination call. A Sepolia Circle USDC export with approve and a Uniswap swap ran out of gas at the 500,000 base. An explicit value below the base is refused before quoting with `INVALID_RULE` and `details.minimum`. When omitted on Solana, the SDK raises the limit to cover finalization rent instead of using an EVM-style million-unit fallback. If a gas-price change leaves that budget short even after one re-quote, the send is refused before signing (`INCONSISTENT_READ`). diff --git a/packages/core/__agw-local__/preset-acceptance.spec.ts b/packages/core/__agw-local__/preset-acceptance.spec.ts index 7e7caa527..34250b346 100644 --- a/packages/core/__agw-local__/preset-acceptance.spec.ts +++ b/packages/core/__agw-local__/preset-acceptance.spec.ts @@ -1,8 +1,8 @@ /** Exact default preset output -> real AGW/URP -> exact public readback. - * Source-token PRC20 metadata and gateway are local stand-ins. This proves SDK - * preservation with provisioned routes, not live route availability or delivery. + * The gas PRC20 and gateway are local stand-ins. This proves SDK preservation, + * not live route availability or delivery. */ -import { getAddress, parseAbi, type Address } from 'viem'; +import { getAddress, parseAbi, zeroAddress } from 'viem'; import { startHarness, type Harness } from './harness'; import { PushChain } from '../src'; import type { AgenticRuntime } from '../src/lib/agentic/runtime'; @@ -12,14 +12,17 @@ import { PRESET_ACCEPTANCE_CASES, expectStoredPreset, } from '../__e2e__/shared/agw-preset-acceptance'; -const sourceABI = parseAbi(['function setSourceTokenAddress(string)']); +const CORE = parseAbi([ + 'function setGasToken(string,address)', + 'function setSourceTokenAddress(string)', +]); const CEA = getAddress('0x000000000000000000000000000000000000cea1'); const runtime = (c: PushChain) => (c as unknown as { agenticRuntime: AgenticRuntime }).agenticRuntime; describe('unchanged default preset contract acceptance', () => { let h: Harness, owner: PushChain, lookup: jest.SpyInstance; - const routes = new Map(); + const gasTokens = new Set(); beforeAll(async () => { lookup = jest .spyOn(accountLookup, 'convertExecutorToOrigin') @@ -30,11 +33,8 @@ describe('unchanged default preset contract acceptance', () => { cea: CEA, isDeployed: false, }); - runtime(owner).resolvePrc20 = (token, chain) => { - const source = typeof token === 'string' ? token : token?.address; - const mapped = routes.get(`${chain}:${source?.toLowerCase()}`); - if (!mapped) throw new Error('Fixture route not provisioned'); - return mapped; + runtime(owner).resolvePrc20 = () => { + throw new Error('Presets must not resolve a non-native route'); }; }); afterAll(async () => { @@ -54,22 +54,26 @@ describe('unchanged default preset contract acceptance', () => { const before = JSON.stringify(generated.rules, (_k, v) => typeof v === 'bigint' ? String(v) : v ); + // Presets send only native ETH, which resolves to the chain's gas PRC20. for (const asset of generated.rules[0].assets!) { const source = getAddress( typeof asset.token === 'string' ? asset.token : asset.token.address ); - const key = `${chainNamespace}:${source.toLowerCase()}`; - if (!routes.has(key)) { + expect(source).toBe(zeroAddress); + if (!gasTokens.has(chainNamespace)) { const mapped = await h.deploy( 0, 'HarnessPRC20', 'HarnessFixtures.sol', [chainNamespace, 18] ); - await h.write(0, mapped, sourceABI, 'setSourceTokenAddress', [ - source, + // Live gas PRC20s report an empty or zero source token. + await h.write(0, mapped, CORE, 'setSourceTokenAddress', ['']); + await h.write(0, h.addresses.universalCore, CORE, 'setGasToken', [ + chainNamespace, + mapped, ]); - routes.set(key, mapped); + gasTokens.add(chainNamespace); } } const made = await owner.agentic.create('preset-exact', { diff --git a/packages/core/__agw-local__/v5-multiasset.spec.ts b/packages/core/__agw-local__/v5-multiasset.spec.ts index 193a7e89f..a1e5af80c 100644 --- a/packages/core/__agw-local__/v5-multiasset.spec.ts +++ b/packages/core/__agw-local__/v5-multiasset.spec.ts @@ -7,6 +7,7 @@ import { encodeFunctionData, getAddress, parseAbi, + zeroAddress, type Address, type Hex, } from 'viem'; @@ -240,260 +241,180 @@ describe('v6 multi-asset SDK management and execution', () => { expect(await owner.agentic.derive()).toEqual(before); }); - it('the shipped DEX catalog enforces tuple receiver/token/amount pins on actual v6 URP', async () => { - const { rules } = agenticPresets.get('DEXES', { - chainNamespace: CHAIN_NS, - agent: h.wallets[1].account!.address, - validUntil: Math.floor(Date.now() / 1000) + 3600, - maxGasPerCall: BigInt(10) ** BigInt(16), - assets: [ - { token: a, maxPerCall: BigInt(5), maxTotal: BigInt(20) }, - { token: b, maxPerCall: BigInt(7), maxTotal: BigInt(20) }, - ], - }); + const presetOptions = (maxPerCall: bigint) => ({ + chainNamespace: CHAIN_NS, + agent: h.wallets[1].account!.address, + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(16), + assets: [{ token: zeroAddress, maxPerCall, maxTotal: BigInt(40) }], + }); + const PRESET_ABI = parseAbi([ + 'function deposit() payable', + 'function approve(address,uint256) returns (bool)', + 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns (uint256)', + 'function supply(address,uint256,address,uint16)', + ]); + // Native ETH funds the wallet's destination account; the rule caps the wrap value. + const wrapCall = (weth: Address, value: bigint) => ({ + to: weth, + value, + data: encodeFunctionData({ abi: PRESET_ABI, functionName: 'deposit' }), + }); + const approveCall = (token: Address, spender: Address, amount: bigint) => ({ + to: token, + value: BigInt(0), + data: encodeFunctionData({ + abi: PRESET_ABI, + functionName: 'approve', + args: [spender, amount], + }), + }); + it('the shipped DEX catalog enforces the wrap value, tuple receiver/token and amount pins on actual v6 URP', async () => { + const { rules } = agenticPresets.get('DEXES', presetOptions(BigInt(5))); const { wallet, id, agent } = await setup(rules[0]); - const router = rules[0].allowedCalls[0].target as Address; - const abi = parseAbi([ - 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns (uint256)', - ]); - const send = (recipient = CEA, input = a, amount = BigInt(5)) => - agent.universal.sendTransaction({ - to: { address: router, chain: CHAIN_NS }, - data: [ + expect((await stored(wallet, id)).assets[0]).toMatchObject({ + token: a, + maxPerCall: BigInt(5), + }); + const weth = getAddress(rules[0].allowedCalls[0].target); + const router = getAddress(rules[0].allowedCalls[2].target); + const usdc = getAddress(rules[0].allowedCalls[4].target); + const swap = ( + tokenIn: Address, + tokenOut: Address, + amountIn: bigint, + recipient = CEA + ) => ({ + to: router, + value: BigInt(0), + data: encodeFunctionData({ + abi: PRESET_ABI, + functionName: 'exactInputSingle', + args: [ { - to: router, - value: BigInt(0), - data: encodeFunctionData({ - abi, - functionName: 'exactInputSingle', - args: [ - { - tokenIn: input, - tokenOut: b, - fee: 3000, - recipient, - amountIn: amount, - amountOutMinimum: BigInt(0), - sqrtPriceLimitX96: BigInt(0), - }, - ], - }), + tokenIn, + tokenOut, + fee: 3000, + recipient, + amountIn, + amountOutMinimum: BigInt(0), + sqrtPriceLimitX96: BigInt(0), }, ], + }), + }); + const send = (data: ReturnType[], amount = BigInt(5)) => + agent.universal.sendTransaction({ + to: { address: router, chain: CHAIN_NS }, + funds: { amount }, + data, }); - await waitPush(send()); - await expect(send(DEST)).rejects.toMatchObject({ + const sell = (amount: bigint, recipient = CEA) => [ + wrapCall(weth, amount), + approveCall(weth, router, amount), + swap(weth, usdc, amount, recipient), + ]; + await waitPush(send(sell(BigInt(5)))); + expect((await stored(wallet, id)).assets[0].spent).toBe(BigInt(5)); + await expect( + send([wrapCall(weth, BigInt(6)), ...sell(BigInt(5)).slice(1)]) + ).rejects.toMatchObject({ + decodedError: { + name: 'PolicyCheckReverted(InnerValueExceedsAllowance)', + }, + }); + await expect(send(sell(BigInt(5), DEST))).rejects.toMatchObject({ decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, }); - await expect(send(CEA, DEST)).rejects.toMatchObject({ + await expect( + send([...sell(BigInt(5)).slice(0, 2), swap(DEST, usdc, BigInt(5))]) + ).rejects.toMatchObject({ decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, }); - await expect(send(CEA, a, BigInt(6))).rejects.toMatchObject({ + await expect( + send([...sell(BigInt(5)).slice(0, 2), swap(weth, usdc, BigInt(6))]) + ).rejects.toMatchObject({ decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, }); - const handle = owner.agentic.wallet(wallet), - record = await handle.rules.get(id); - const changed = await handle.rules.update({ - rules: [ - { - rulesId: id, - rule: { ...record.rule, validUntil: record.validUntil + 60 }, - }, - ], - }); - expect((await handle.rules.get(changed.rules[0].rulesId)).rule).toEqual({ - ...record.rule, - validUntil: record.validUntil + 60, + // Destination-held USDC swaps back under the fixed 10 USDC per-swap pin. + const usdcCap = BigInt(10_000_000); + await waitPush( + send( + [approveCall(usdc, router, usdcCap), swap(usdc, weth, usdcCap)], + BigInt(0) + ) + ); + // The policy reports the first matching entry's failure: the WETH-in swap's token pin. + await expect( + send([swap(usdc, weth, usdcCap + BigInt(1))], BigInt(0)) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, }); + // Calls using destination-held capital do not consume the Push bridge counter. + expect((await stored(wallet, id)).assets[0].spent).toBe(BigInt(5)); }); - it.each(['exactInputSingle', 'exactOutputSingle'] as const)( - 'a default-token DEX %s call rejects spending above its policy cap despite edited bridge budgets', - async (functionName) => { - const preview = agenticPresets.get('DEXES', { - chainNamespace: CHAIN_NS, - agent: h.wallets[1].account!.address, - }).rules[0]; - const { wallet, id, agent } = await setup({ - ...preview, - assets: [{ token: a, maxPerCall: BigInt(5), maxTotal: BigInt(20) }], - }); - const stored = await owner.agentic.wallet(wallet).rules.get(id); - expect(stored.rule.validUntil).toBe(preview.validUntil); - expect(stored.rule.maxGasPerCall).toBe(preview.maxGasPerCall); - const router = preview.allowedCalls[0].target as Address; - const abi = parseAbi([ - `function ${functionName}((address,address,uint24,address,uint256,uint256,uint160))`, - ]); - await expect( - agent.universal.sendTransaction({ - to: { address: router, chain: CHAIN_NS }, - data: [ - { - to: router, - value: BigInt(0), - data: encodeFunctionData({ - abi, - functionName, - args: [ - [ - preview.assets![0].token as Address, - preview.assets![1].token as Address, - 3000, - CEA, - preview.assets![0].maxPerCall + BigInt(1), - preview.assets![0].maxPerCall + BigInt(1), - BigInt(0), - ], - ], - }), - }, - ], - }) - ).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, - userMessage: expect.stringContaining('exceeds a rule'), - }); - } - ); - - it.each(['DEXES', 'LENDING', 'STAKING'] as const)( - '%s constraints work in an explicit prefunded contract fixture', - async (category) => { - const generated = agenticPresets.get(category, { - chainNamespace: CHAIN_NS, - agent: h.wallets[1].account!.address, - }); - expect(generated.readiness.chains[0].configurationReady).toBe(true); - expect( - generated.readiness.chains[0].selectedAssets.length - ).toBeGreaterThan(0); - expect(generated.rules[0].assets!.length).toBeGreaterThan(0); - const { wallet, id } = await setup({ ...generated.rules[0], assets: [] }); - const actual = await stored(wallet, id); - expect(actual.assets).toEqual([ - { - token: a, - maxPerCall: BigInt(0), - maxTotal: BigInt(0), - spent: BigInt(0), - }, - ]); - const record = await owner.agentic.wallet(wallet).rules.get(id); - expect(record.rule.maxGasPerCall).toBe(generated.rules[0].maxGasPerCall); - expect(record.rule.validUntil).toBe(generated.rules[0].validUntil); - } - ); - - it.each(['LENDING', 'STAKING'] as const)( - 'regenerated %s presets enforce new token/amount pins through atomic rules.update', - async (category) => { - const firstToken = getAddress( - '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5' - ); - const nextToken = getAddress( - '0x29f2D40B0605204364af54EC677bD022dA425d03' - ); - const opts = { - chainNamespace: CHAIN_NS, - agent: h.wallets[1].account!.address, - validUntil: Math.floor(Date.now() / 1000) + 3600, - maxGasPerCall: BigInt(10) ** BigInt(16), - }; - const preview = agenticPresets.get(category, { ...opts, assets: [] }); - expect(preview.readiness.ready).toBe(false); - const { wallet, id, agent } = await setup({ - ...preview.rules[0], - assets: [], - }); - const target = preview.rules[0].allowedCalls[0].target as Address; - const abi = parseAbi([ - 'function supply(address,uint256,address,uint16)', - 'function depositIntoStrategy(address,address,uint256)', - ]); - const send = (token: Address, amount: bigint, count = 1) => - agent.universal.sendTransaction({ - to: { address: target, chain: CHAIN_NS }, - data: Array.from({ length: count }, () => ({ - to: target, - value: BigInt(0), - data: - category === 'LENDING' - ? encodeFunctionData({ - abi, - functionName: 'supply', - args: [token, amount, CEA, 0], - }) - : encodeFunctionData({ - abi, - functionName: 'depositIntoStrategy', - args: [DEST, token, amount], - }), - })), - }); - await expect(send(firstToken, BigInt(1))).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, - }); - const w = owner.agentic.wallet(wallet); - const first = agenticPresets.get(category, { - ...opts, - assets: [ - { token: firstToken, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, - ], + it('the shipped LENDING catalog pins Aave supply to WETH and the wallet, and a regenerated cap applies through rules.update', async () => { + const first = agenticPresets.get('LENDING', presetOptions(BigInt(5))); + const { wallet, id, agent } = await setup(first.rules[0]); + const weth = getAddress(first.rules[0].allowedCalls[0].target); + const pool = getAddress(first.rules[0].allowedCalls[2].target); + const supply = (amount: bigint, asset = weth, onBehalfOf = CEA) => [ + wrapCall(weth, amount), + approveCall(weth, pool, amount), + { + to: pool, + value: BigInt(0), + data: encodeFunctionData({ + abi: PRESET_ABI, + functionName: 'supply', + args: [asset, amount, onBehalfOf, 0], + }), + }, + ]; + const send = (data: ReturnType, amount: bigint) => + agent.universal.sendTransaction({ + to: { address: pool, chain: CHAIN_NS }, + funds: { amount }, + data, }); - expect(first.readiness.chains[0].configurationReady).toBe(true); - const before = await h.publicClient.readContract({ + await waitPush(send(supply(BigInt(5)), BigInt(5))); + await expect( + send(supply(BigInt(5), DEST), BigInt(5)) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect( + send(supply(BigInt(5), weth, DEST), BigInt(5)) + ).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + const w = owner.agentic.wallet(wallet); + const changed = agenticPresets.get('LENDING', presetOptions(BigInt(2))); + const before = await h.publicClient.readContract({ + address: wallet, + abi: h.generation.contracts.abis.wallet, + functionName: 'checkpointCount', + }); + const replaced = await w.rules.update({ + rules: [{ rulesId: id, rule: changed.rules[0] }], + }); + expect( + await h.publicClient.readContract({ address: wallet, abi: h.generation.contracts.abis.wallet, functionName: 'checkpointCount', - }); - const added = await w.rules.update({ - rules: [{ rulesId: id, rule: { ...first.rules[0], assets: [] } }], - }); - expect( - await h.publicClient.readContract({ - address: wallet, - abi: h.generation.contracts.abis.wallet, - functionName: 'checkpointCount', - }) - ).toBe(before + BigInt(5)); - await waitPush(send(firstToken, BigInt(5))); - // Per-call MAX permits two calls of 5 in one outbound; it is not a batch-total cap. - await waitPush(send(firstToken, BigInt(5), 2)); - await expect(send(firstToken, BigInt(6))).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, - }); - await expect(send(nextToken, BigInt(1))).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, - }); - const changed = agenticPresets.get(category, { - ...opts, - assets: [ - { token: nextToken, maxPerCall: BigInt(2), maxTotal: BigInt(10) }, - ], - }); - const replaced = await w.rules.update({ - rules: [ - { - rulesId: added.rules[0].rulesId, - rule: { ...changed.rules[0], assets: [] }, - }, - ], - }); - expect(replaced.rules[0].rulesId).not.toBe(added.rules[0].rulesId); - await expect(send(firstToken, BigInt(1))).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, - }); - await expect(send(nextToken, BigInt(3))).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, - }); - await waitPush(send(nextToken, BigInt(2))); - // Calls using destination-held capital do not consume the Push bridge counter. - expect( - (await stored(wallet, replaced.rules[0].rulesId)).assets[0].spent - ).toBe(BigInt(0)); - } - ); + }) + ).toBe(before + BigInt(5)); + expect(replaced.rules[0].rulesId).not.toBe(id); + await expect(send(supply(BigInt(3)), BigInt(3))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(AmountExceedsCap)' }, + }); + await waitPush(send(supply(BigInt(2)), BigInt(2))); + expect( + (await stored(wallet, replaced.rules[0].rulesId)).assets[0].spent + ).toBe(BigInt(2)); + }); it('a fixture preset produces rules accepted by public create and enforced by real contracts', async () => { const presets = buildPresetApi([ @@ -517,7 +438,7 @@ describe('v6 multi-asset SDK management and execution', () => { assets: [ { chainNamespace: CHAIN_NS, - token: a, + token: zeroAddress, maxPerCall: BigInt(5), maxTotal: BigInt(7), }, @@ -531,6 +452,55 @@ describe('v6 multi-asset SDK management and execution', () => { }); }); + it('a tokens-only rule lets the agent transfer each token only back to the owner, capped per transfer', async () => { + const ownerAddress = h.wallets[0].account!.address; + const { wallet, id, agent } = await setup({ + agent: h.wallets[1].account!.address, + chainNamespace: CHAIN_NS, + assets: [{ token: a, maxPerCall: BigInt(5), maxTotal: BigInt(20) }], + validUntil: Math.floor(Date.now() / 1000) + 3600, + maxGasPerCall: BigInt(10) ** BigInt(16), + } as unknown as Rule); + const record = await owner.agentic.wallet(wallet).rules.get(id); + if (!('allowedCalls' in record.rule)) throw new Error('Expected EVM rule'); + expect(record.rule.allowedCalls).toHaveLength(1); + const ERC20 = parseAbi([ + 'function transfer(address,uint256) returns (bool)', + ]); + const send = (to: Address, amount: bigint, funded = BigInt(5)) => + agent.universal.sendTransaction({ + to: { address: a, chain: CHAIN_NS }, + funds: { + amount: funded, + token: { + symbol: 'fixture', + decimals: 6, + address: a, + mechanism: 'approve', + }, + }, + data: [ + { + to: a, + value: BigInt(0), + data: encodeFunctionData({ + abi: ERC20, + functionName: 'transfer', + args: [to, amount], + }), + }, + ], + }); + await waitPush(send(ownerAddress, BigInt(5))); + expect((await stored(wallet, id)).assets[0].spent).toBe(BigInt(5)); + await expect(send(DEST, BigInt(5))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + await expect(send(ownerAddress, BigInt(6))).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, + }); + }); + it('public create encodes two ordered assets; public get reconstructs source-token caps', async () => { const { wallet, id } = await setup(); expect((await stored(wallet, id)).assets.map((x) => x.token)).toEqual([ diff --git a/packages/core/__e2e__/agw/presets-acceptance.spec.ts b/packages/core/__e2e__/agw/presets-acceptance.spec.ts index e8a1644f0..76459777c 100644 --- a/packages/core/__e2e__/agw/presets-acceptance.spec.ts +++ b/packages/core/__e2e__/agw/presets-acceptance.spec.ts @@ -1,7 +1,6 @@ /** Release acceptance: EXACT shipped preset -> create -> readback -> two agent sends. - * Missing routes, protocols or exact PRC20 balances fail before any funding. This is - * deliberately separate from - * the prefunded protocol fixtures; no asset, cap, call or pin is removed here. + * Every preset sends native ETH from Push; the agent wraps it on arrival and uses + * the app. No asset, cap, call or pin is removed or substituted here. */ import '@e2e/shared/setup'; import { @@ -11,6 +10,8 @@ import { erc20Abi, parseAbi, parseEther, + getAddress, + zeroAddress, type ContractFunctionReturnType, type Address, type Hex, @@ -32,34 +33,42 @@ const endpoints: Record = { 'eip155:11155111': 'https://ethereum-sepolia-rpc.publicnode.com', 'eip155:84532': 'https://base-sepolia-rpc.publicnode.com', 'eip155:421614': 'https://arbitrum-sepolia-rpc.publicnode.com', - 'eip155:97': 'https://bsc-testnet-rpc.publicnode.com', }; const ABI = parseAbi([ + 'function deposit() payable', 'function factory() view returns(address)', 'function getPool(address,address,uint24) view returns(address)', 'function liquidity() view returns(uint128)', 'function supply(address,uint256,address,uint16)', - 'function mint(uint256) returns(uint256)', 'function balanceOf(address) view returns(uint256)', 'function getReserveData(address) view returns ((uint256 configuration,uint128 liquidityIndex,uint128 currentLiquidityRate,uint128 variableBorrowIndex,uint128 currentVariableBorrowRate,uint128 currentStableBorrowRate,uint40 lastUpdateTimestamp,uint16 id,address aTokenAddress,address stableDebtTokenAddress,address variableDebtTokenAddress,address interestRateStrategyAddress,uint128 accruedToTreasury,uint128 unbacked,uint128 isolationModeTotalDebt))', - 'function depositIntoStrategy(address,address,uint256) returns(uint256)', - 'function stakerDepositShares(address,address) view returns(uint256)', 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns(uint256)', ]); -// Concrete calls supported by the current catalog. A newly introduced protocol -// requires its own operation/receipt assertion rather than a false generic pass. -// `input` selects which preset asset funds the operation; swaps can trade either way. -async function operation(rule: Rule, cea: Address, input = 0) { +// Uniswap V3 QuoterV2 on each testnet. Declared view so eth_call returns its quote. +const QUOTERS: Record = { + 'eip155:11155111': '0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3', + 'eip155:84532': '0xC5290058841028F1614F3A6F0F5816cAd0df5E27', + 'eip155:421614': '0x2779a0CC1c3e0E44D2542EC3e79e3864Ae93Ef0B', +}; +const QUOTER_ABI = parseAbi([ + 'function quoteExactInputSingle((address tokenIn,address tokenOut,uint256 amountIn,uint24 fee,uint160 sqrtPriceLimitX96)) view returns (uint256 amountOut,uint160 sqrtPriceX96After,uint32 initializedTicksCrossed,uint256 gasEstimate)', +]); +// Concrete operations for the shipped catalog: a WETH -> USDC swap or an Aave +// WETH supply. A new protocol needs its own receipt assertion, not a generic pass. +async function operation(rule: Rule, cea: Address) { const rpc = createPublicClient({ transport: http(endpoints[rule.chainNamespace!], { timeout: 15000, - retryCount: 1, + // Public testnet RPCs drop requests; a dropped receipt read is not a failure. + retryCount: 3, }), }); - const asset = rule.assets![input], - token = ( - typeof asset.token === 'string' ? asset.token : asset.token.address - ) as Address; + const asset = rule.assets![0]; + if ( + (typeof asset.token === 'string' ? asset.token : asset.token.address) !== + zeroAddress + ) + throw new Error('Presets must send only native ETH'); const amount = asset.maxPerCall / BigInt(1000); if (amount <= BigInt(0) || (asset.maxTotal ?? BigInt(0)) < amount * BigInt(2)) throw new Error('Default caps cannot cover two bounded acceptance sends'); @@ -70,7 +79,6 @@ async function operation(rule: Rule, cea: Address, input = 0) { | 'liquidity' | 'getReserveData' | 'balanceOf' - | 'stakerDepositShares' >( address: Address, functionName: F, @@ -82,43 +90,62 @@ async function operation(rule: Rule, cea: Address, input = 0) { functionName: functionName as never, args: args as never, }) as Promise>; - const swap = rule.allowedCalls.find((c) => - c.selector.toString().startsWith('exactInputSingle(') + const wrap = rule.allowedCalls.find((c) => c.selector === 'deposit()'); + if (!wrap || wrap.maxValue !== asset.maxPerCall) + throw new Error('Preset must wrap ETH up to its per-call cap'); + const weth = getAddress(wrap.target); + const swap = rule.allowedCalls.find( + (c) => + c.selector.toString().startsWith('exactInputSingle(') && + c.pins?.some( + (p) => + 'expected' in p && p.expected === weth && p.arg?.toString() === '0,0' + ) ); - let target: Address, - data: Hex, - outputToken: Address | undefined, - shares: (() => Promise) | undefined; + let target: Address, data: Hex, resultBalance: () => Promise; if (swap) { - target = swap.target as Address; - const output = rule.assets![input === 0 ? 1 : 0]; - outputToken = ( - typeof output.token === 'string' ? output.token : output.token.address - ) as Address; - const factory = await read(target, 'factory'); - let fee: number | undefined; - for (const tier of [100, 500, 2500, 3000, 10000]) { - const pool: Address = await read(factory, 'getPool', [ - token, - outputToken, - tier, - ]); - if (!/^0x0+$/.test(pool) && (await read(pool, 'liquidity')) > BigInt(0)) { + target = getAddress(swap.target); + const usdc = getAddress( + swap.pins!.find((p) => p.arg?.toString() === '0,1')!.expected as Address + ); + // Testnet pools are thin and often mispriced, so the first tier with + // liquidity can return nothing. Uniswap's quoter simulates each swap. + let fee: number | undefined, + best = BigInt(0); + for (const tier of [100, 500, 3000, 10000]) { + let quoted: bigint; + try { + [quoted] = await rpc.readContract({ + address: QUOTERS[rule.chainNamespace!], + abi: QUOTER_ABI, + functionName: 'quoteExactInputSingle', + args: [ + { + tokenIn: weth, + tokenOut: usdc, + amountIn: amount, + fee: tier, + sqrtPriceLimitX96: BigInt(0), + }, + ], + }); + } catch { + continue; // no pool at this tier + } + if (quoted > best) { + best = quoted; fee = tier; - break; } } if (!fee) - throw new Error( - 'PRESET_PROTOCOL_UNAVAILABLE: routed default pair has no active pool' - ); + throw new Error('PRESET_PROTOCOL_UNAVAILABLE: no active WETH/USDC pool'); data = encodeFunctionData({ abi: ABI, functionName: 'exactInputSingle', args: [ { - tokenIn: token, - tokenOut: outputToken, + tokenIn: weth, + tokenOut: usdc, fee, recipient: cea, amountIn: amount, @@ -127,65 +154,41 @@ async function operation(rule: Rule, cea: Address, input = 0) { }, ], }); + resultBalance = () => read(usdc, 'balanceOf', [cea]); } else { - const call = rule.allowedCalls.find( - (c) => - c.selector === 'supply(address,uint256,address,uint16)' || - c.selector === 'mint(uint256)' || - c.selector === 'depositIntoStrategy(address,address,uint256)' + const supply = rule.allowedCalls.find( + (c) => c.selector === 'supply(address,uint256,address,uint16)' ); - if (!call) + if (!supply) throw new Error( 'PRESET_ACCEPTANCE_UNIMPLEMENTED: no verified operation for this catalog entry' ); - target = call.target as Address; - if (call.selector === 'supply(address,uint256,address,uint16)') { - const reserve = await read(target, 'getReserveData', [token]); - if (/^0x0+$/.test(reserve.aTokenAddress)) - throw new Error( - 'PRESET_PROTOCOL_UNAVAILABLE: token is not an Aave reserve' - ); - shares = () => read(reserve.aTokenAddress, 'balanceOf', [cea]); - data = encodeFunctionData({ - abi: ABI, - functionName: 'supply', - args: [token, amount, cea, 0], - }); - } else if (call.selector === 'mint(uint256)') { - shares = () => read(target, 'balanceOf', [cea]); - data = encodeFunctionData({ - abi: ABI, - functionName: 'mint', - args: [amount], - }); - } else { - const strategy = '0x424246eF71b01ee33aA33aC590fd9a0855F5eFbc' as Address; - shares = () => read(target, 'stakerDepositShares', [cea, strategy]); - data = encodeFunctionData({ - abi: ABI, - functionName: 'depositIntoStrategy', - args: [strategy, token, amount], - }); - } + target = getAddress(supply.target); + const reserve = await read(target, 'getReserveData', [weth]); + if (/^0x0+$/.test(reserve.aTokenAddress)) + throw new Error( + 'PRESET_PROTOCOL_UNAVAILABLE: WETH is not an Aave reserve' + ); + data = encodeFunctionData({ + abi: ABI, + functionName: 'supply', + args: [weth, amount, cea, 0], + }); + resultBalance = () => read(reserve.aTokenAddress, 'balanceOf', [cea]); } - const resultBalance = - shares ?? - (() => - rpc.readContract({ - address: outputToken!, - abi: erc20Abi, - functionName: 'balanceOf', - args: [cea], - })); return { rpc, - token, amount, target, resultBalance, calls: [ { - to: token, + to: weth, + value: amount, + data: encodeFunctionData({ abi: ABI, functionName: 'deposit' }), + }, + { + to: weth, value: BigInt(0), data: encodeFunctionData({ abi: erc20Abi, @@ -199,21 +202,11 @@ async function operation(rule: Rule, cea: Address, input = 0) { } d('agw unchanged preset acceptance', () => { - // Explicit titles also allow CI's scenario selector to enumerate all nine. + // Explicit titles also allow CI's scenario selector to enumerate every case. const run = async ( - category: 'DEXES' | 'LENDING' | 'STAKING', + category: 'DEXES' | 'LENDING', chainNamespace: `eip155:${string}` ) => { - const preview = PushChain.utils.agentic.presets.get(category, { - chainNamespace, - agent: '0x0000000000000000000000000000000000005678', - }); - if (!preview.readiness.ready) - throw new Error( - `PRESET_BLOCKED_BEFORE_FUNDING ${category} ${chainNamespace}: ${JSON.stringify( - preview.readiness.chains[0].issues - )}` - ); const f = await setupAgw({ deferAgentFunding: true }); try { const generated = PushChain.utils.agentic.presets.get(category, { @@ -231,34 +224,10 @@ d('agw unchanged preset acceptance', () => { endpoints[chainNamespace] ) ).cea as Address; - // A swap may start from whichever default asset the owner holds as a PRC20. - const swapPreset = generated.rules[0].allowedCalls.some((c) => - c.selector.toString().startsWith('exactInputSingle(') - ); - let input = 0; - if (swapPreset) - for (const [i, a] of generated.rules[0].assets!.entries()) { - const prc20 = resolveAgenticPrc20( - { - chain: chainNamespace, - address: typeof a.token === 'string' ? a.token : a.token.address, - }, - f.manifest.network - ).address; - const held = await f.push.readContract({ - address: prc20, - abi: erc20Abi, - functionName: 'balanceOf', - args: [f.ownerAddress], - }); - if (held >= (a.maxPerCall / BigInt(1000)) * BigInt(2)) { - input = i; - break; - } - } - const op = await operation(generated.rules[0], cea, input); + const op = await operation(generated.rules[0], cea); + // Native ETH leaves Push as the chain's gas PRC20 (pETH, pETH_BASE, pETH_ARB). const mapped = resolveAgenticPrc20( - { chain: chainNamespace, address: op.token }, + { chain: chainNamespace, address: zeroAddress }, f.manifest.network ); if ( @@ -270,7 +239,9 @@ d('agw unchanged preset acceptance', () => { })) < op.amount * BigInt(2) ) - throw new Error('PRESET_PREREQUISITE: owner lacks exact default PRC20'); + throw new Error( + `PRESET_PREREQUISITE: owner lacks ${mapped.symbol} for two sends` + ); await f.fundAgent(); const made = await f.owner.agentic.create('preset-acceptance', { rules: generated.rules, @@ -329,15 +300,7 @@ d('agw unchanged preset acceptance', () => { const tx = await agent.universal.sendTransaction({ to: { address: op.target, chain: chainNamespace as CHAIN }, data: op.calls, - funds: { - amount: op.amount, - token: { - symbol: mapped.symbol, - decimals: mapped.decimals, - address: op.token, - mechanism: 'approve', - }, - }, + funds: { amount: op.amount }, }); f.evidence('unchanged-preset-submitted', { category, @@ -369,7 +332,7 @@ d('agw unchanged preset acceptance', () => { .rules.get(made.rulesIds[0]); if (stored.spent?.kind !== 'universal') throw new Error('Missing universal spend'); - expect(stored.spent.assets[input].spent).toBe( + expect(stored.spent.assets[0].spent).toBe( op.amount * BigInt(stage === 'first-use' ? 1 : 2) ); } finally { @@ -387,22 +350,15 @@ d('agw unchanged preset acceptance', () => { () => run(...PRESET_ACCEPTANCE_CASES[2]), 600000 ); - it('4. DEXES BNB testnet', () => run(...PRESET_ACCEPTANCE_CASES[3]), 600000); - it('5. LENDING Sepolia', () => run(...PRESET_ACCEPTANCE_CASES[4]), 600000); + it('4. LENDING Sepolia', () => run(...PRESET_ACCEPTANCE_CASES[3]), 600000); it( - '6. LENDING Base Sepolia', - () => run(...PRESET_ACCEPTANCE_CASES[5]), + '5. LENDING Base Sepolia', + () => run(...PRESET_ACCEPTANCE_CASES[4]), 600000 ); it( - '7. LENDING Arbitrum Sepolia', - () => run(...PRESET_ACCEPTANCE_CASES[6]), - 600000 - ); - it( - '8. LENDING BNB testnet', - () => run(...PRESET_ACCEPTANCE_CASES[7]), + '6. LENDING Arbitrum Sepolia', + () => run(...PRESET_ACCEPTANCE_CASES[5]), 600000 ); - it('9. STAKING Sepolia', () => run(...PRESET_ACCEPTANCE_CASES[8]), 600000); }); diff --git a/packages/core/__e2e__/agw/presets-expanded-live.spec.ts b/packages/core/__e2e__/agw/presets-expanded-live.spec.ts deleted file mode 100644 index 59470e62b..000000000 --- a/packages/core/__e2e__/agw/presets-expanded-live.spec.ts +++ /dev/null @@ -1,819 +0,0 @@ -/** Real protocol tests on supported testnets only. All signing uses Donut. - * Destination-only assets are prefunded; generated preset calls/pins are retained. - */ -import { - createPublicClient, - encodeFunctionData, - erc20Abi, - http, - parseAbi, - parseEther, - parseEventLogs, - type Address, - type Hex, -} from 'viem'; -import { PushChain, CHAIN, type Rule } from '../../src'; -import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; -import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; -import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; -import { - AGW_E2E_ENABLED, - setupAgw, - evmClient, - inSeconds, - type AgwFixture, -} from './_fixture'; -const d = AGW_E2E_ENABLED ? describe : describe.skip; -const ZERO = BigInt(0), - SMALL = BigInt(10) ** BigInt(12), - MAX_PC = parseEther('20'); -const rpc = { - [CHAIN.ETHEREUM_SEPOLIA]: 'https://ethereum-sepolia-rpc.publicnode.com', - [CHAIN.BASE_SEPOLIA]: 'https://base-sepolia-rpc.publicnode.com', - [CHAIN.ARBITRUM_SEPOLIA]: 'https://arbitrum-sepolia-rpc.publicnode.com', - [CHAIN.BNB_TESTNET]: 'https://bsc-testnet-rpc.publicnode.com', -}; -type Destination = keyof typeof rpc; -type Call = { to: Address; value: bigint; data: Hex }; -const ERC20 = erc20Abi; -const EXECUTED = parseAbi([ - 'event UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data)', -]); -const WETH = parseAbi(['function deposit() payable']); -const AAVE = parseAbi([ - 'function supply(address,uint256,address,uint16)', - 'function withdraw(address,uint256,address) returns(uint256)', -]); -const MARKET = parseAbi([ - 'function mint(uint256) returns(uint256)', - 'function redeemUnderlying(uint256) returns(uint256)', - 'function exchangeRateStored() view returns(uint256)', - 'function balanceOf(address) view returns(uint256)', -]); -const read = (chain: Destination) => - createPublicClient({ transport: http(rpc[chain], { timeout: 20_000 }) }); -const call = (to: Address, data: Hex, value = ZERO): Call => ({ - to, - data, - value, -}); -const approve = (token: Address, spender: Address, amount: bigint) => - call( - token, - encodeFunctionData({ - abi: ERC20, - functionName: 'approve', - args: [spender, amount], - }) - ); -const balance = (chain: Destination, token: Address, owner: Address) => - read(chain).readContract({ - address: token, - abi: ERC20, - functionName: 'balanceOf', - args: [owner], - }); -const preset = ( - category: 'DEXES' | 'LENDING' | 'STAKING', - chain: Destination, - agent: Address, - tokens: { token: Address; maxPerCall: bigint; maxTotal: bigint }[] -): Rule => ({ - ...PushChain.utils.agentic.presets.get(category, { - chainNamespace: chain, - agent, - validUntil: inSeconds(7200), - maxGasPerCall: MAX_PC, - assets: tokens, - }).rules[0], - assets: [], -}); - -async function walletFor( - f: AgwFixture, - chain: Destination, - rule: Rule, - label: string -) { - const { wallet, rulesIds } = await f.owner.agentic.create(label, { - rules: [rule], - }); - const cea = (await getCEAAddress(wallet, chain)).cea as Address; - await f.fundPC(wallet, parseEther('100')); - const owner = await evmClient( - process.env['PUSH_PRIVATE_KEY'] as Hex, - CHAIN.PUSH_TESTNET_DONUT, - f.manifest.network, - wallet - ); - const agent = await f.agent(wallet); - f.evidence('preset-expanded-wallet', { - label, - chain, - wallet, - cea, - rulesId: rulesIds[0], - }); - return { wallet, cea, owner, agent, rulesId: rulesIds[0] }; -} -async function send( - f: AgwFixture, - w: Awaited>, - chain: Destination, - client: PushChain, - calls: Call[], - stage: string, - funds?: { - token: typeof MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH; - amount: bigint; - } -) { - const tx = await client.universal.sendTransaction({ - to: { address: calls[0].to, chain }, - data: calls, - gasLimit: BigInt(2_000_000), - ...(funds ? { funds } : {}), - }); - f.evidence('preset-expanded-stage', { - chain, - wallet: w.wallet, - cea: w.cea, - stage: `${stage}-submitted`, - pushTx: tx.hash, - }); - const receipt = await tx.wait(); - expect(receipt.externalTxHash).toBeTruthy(); - const dest = await read(chain).getTransactionReceipt({ - hash: receipt.externalTxHash as Hex, - }); - f.evidence('preset-expanded-stage', { - chain, - wallet: w.wallet, - cea: w.cea, - stage: `${stage}-completed`, - pushTx: tx.hash, - externalTx: receipt.externalTxHash, - status: dest.status, - }); - expect(dest.status).toBe('success'); - const events = parseEventLogs({ abi: EXECUTED, logs: dest.logs }); - for (const c of calls) - expect( - events.some( - (e) => - e.address.toLowerCase() === w.cea.toLowerCase() && - e.args.originCaller.toLowerCase() === w.wallet.toLowerCase() && - e.args.target.toLowerCase() === c.to.toLowerCase() && - e.args.data === c.data - ) - ).toBe(true); - return dest; -} -// Moonwell and Venus use different Redeem event signatures. Require the -// protocol's entire burned-share amount and payout to match the CEA balances. -function assertRedemption( - receipt: Awaited>, - market: Address, - cea: Address, - shares: bigint, - received: bigint -) { - const events = parseEventLogs({ - abi: parseAbi([ - 'event Redeem(address redeemer,uint256 redeemAmount,uint256 redeemTokens)', - 'event Redeem(address redeemer,uint256 redeemAmount,uint256 redeemTokens,uint256 totalSupply)', - ]), - logs: receipt.logs, - }); - const event = events.find( - (e) => - e.address.toLowerCase() === market.toLowerCase() && - e.args.redeemer.toLowerCase() === cea.toLowerCase() - ); - expect(event?.args.redeemTokens).toBe(shares); - expect(event?.args.redeemAmount).toBe(received); -} - -async function underlyingRedemption( - chain: Destination, - market: Address, - shares: bigint, - cap: bigint -) { - const rate = await read(chain).readContract({ - address: market, - abi: MARKET, - functionName: 'exchangeRateStored', - }); - const amount = (shares * rate) / BigInt(10) ** BigInt(18); - const bounded = amount < cap ? amount : cap; - expect(bounded).toBeGreaterThan(ZERO); - return bounded; -} -async function wrap( - f: AgwFixture, - w: Awaited>, - chain: Destination, - token: Address, - amount: bigint -) { - const prc20 = getNativePRC20ForChain(chain, f.manifest.network); - if ( - (await f.push.readContract({ - address: prc20, - abi: ERC20, - functionName: 'balanceOf', - args: [f.ownerAddress], - })) < amount - ) - throw new Error( - `PRESET_PREREQUISITE: master lacks native PRC20 for ${chain}` - ); - await ( - await f.owner.universal.sendTransaction({ - to: prc20, - data: encodeFunctionData({ - abi: ERC20, - functionName: 'transfer', - args: [w.wallet, amount], - }), - }) - ).wait(); - const native = - chain === CHAIN.BNB_TESTNET - ? MOVEABLE_TOKEN_CONSTANTS.BNB_TESTNET.BNB - : chain === CHAIN.BASE_SEPOLIA - ? MOVEABLE_TOKEN_CONSTANTS.BASE_SEPOLIA.ETH - : chain === CHAIN.ARBITRUM_SEPOLIA - ? MOVEABLE_TOKEN_CONSTANTS.ARBITRUM_SEPOLIA.ETH - : MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH; - await send( - f, - w, - chain, - w.owner, - [ - call( - token, - encodeFunctionData({ abi: WETH, functionName: 'deposit' }), - amount - ), - ], - 'wrap', - { token: native, amount } - ); - expect(await balance(chain, token, w.cea)).toBe(amount); -} - -d('agw presets expanded', () => { - let f: AgwFixture; - afterEach(() => f?.teardown()); - it('1. EigenLayer Sepolia deposit credits shares to the wallet', async () => { - const chain = CHAIN.ETHEREUM_SEPOLIA; - const manager = '0x2E3D6c0744b10eb0A4e6F679F71554a39Ec47a5D' as Address; - const strategy = '0x424246eF71b01ee33aA33aC590fd9a0855F5eFbc' as Address; - const abi = parseAbi([ - 'function strategyIsWhitelistedForDeposit(address) view returns(bool)', - 'function paused() view returns(uint256)', - 'function depositIntoStrategy(address,address,uint256) returns(uint256)', - 'function stakerDepositShares(address,address) view returns(uint256)', - ]); - expect( - await read(chain).readContract({ - address: manager, - abi, - functionName: 'strategyIsWhitelistedForDeposit', - args: [strategy], - }) - ).toBe(true); - expect( - (await read(chain).readContract({ - address: manager, - abi, - functionName: 'paused', - })) & BigInt(1) - ).toBe(ZERO); - const token = await read(chain).readContract({ - address: strategy, - abi: parseAbi(['function underlyingToken() view returns(address)']), - functionName: 'underlyingToken', - }); - f = await setupAgw(); - const rule = preset('STAKING', chain, f.agentAddress, [ - { token, maxPerCall: SMALL, maxTotal: SMALL }, - ]); - const w = await walletFor(f, chain, rule, 'e2e-preset-eigen'); - await wrap(f, w, chain, token, SMALL); - const before = await read(chain).readContract({ - address: manager, - abi, - functionName: 'stakerDepositShares', - args: [w.cea, strategy], - }); - for (const [inputToken, amount, error] of [ - [token, SMALL + BigInt(1), 'ArgPinAboveMax'], - [strategy, SMALL, 'ArgPinMismatch'], - ] as const) { - await expect( - w.agent.universal.sendTransaction({ - to: { address: manager, chain }, - gasLimit: BigInt(2_000_000), - data: [ - call( - manager, - encodeFunctionData({ - abi, - functionName: 'depositIntoStrategy', - args: [strategy, inputToken, amount], - }) - ), - ], - }) - ).rejects.toMatchObject({ - decodedError: { name: `PolicyCheckReverted(${error})` }, - }); - } - await send( - f, - w, - chain, - w.agent, - [ - approve(token, manager, SMALL), - call( - manager, - encodeFunctionData({ - abi, - functionName: 'depositIntoStrategy', - args: [strategy, token, SMALL], - }) - ), - ], - 'eigen-deposit' - ); - const after = await read(chain).readContract({ - address: manager, - abi, - functionName: 'stakerDepositShares', - args: [w.cea, strategy], - }); - expect(after).toBeGreaterThan(before); - expect(await balance(chain, token, w.cea)).toBe(ZERO); - f.evidence('preset-eigen-live', { - wallet: w.wallet, - cea: w.cea, - strategy, - amount: SMALL, - shares: after - before, - }); - }, 900_000); - const dexCases = [ - { - id: 2, - chain: CHAIN.BASE_SEPOLIA, - wrapped: '0x4200000000000000000000000000000000000006', - output: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', - fee: 3000, - }, - { - id: 3, - chain: CHAIN.ARBITRUM_SEPOLIA, - wrapped: '0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', - output: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', - fee: 3000, - }, - { - id: 4, - chain: CHAIN.BNB_TESTNET, - wrapped: '0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', - output: '0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c', - fee: 100, - }, - ] as const; - async function dexAndLend({ - chain, - wrapped, - output, - fee, - }: (typeof dexCases)[number]) { - const discovery = PushChain.utils.agentic.presets.get('DEXES', { - chainNamespace: chain, - }).rules[0]; - const router = discovery.allowedCalls[0].target as Address; - const factory = await read(chain).readContract({ - address: router, - abi: parseAbi(['function factory() view returns(address)']), - functionName: 'factory', - }); - const pool = await read(chain).readContract({ - address: factory, - abi: parseAbi([ - 'function getPool(address,address,uint24) view returns(address)', - ]), - functionName: 'getPool', - args: [wrapped, output, fee], - }); - if (/^0x0+$/.test(pool)) - throw new Error(`PRESET_PREREQUISITE: ${chain} pool missing`); - expect( - await read(chain).readContract({ - address: pool, - abi: parseAbi(['function liquidity() view returns(uint128)']), - functionName: 'liquidity', - }) - ).toBeGreaterThan(ZERO); - f = await setupAgw(); - const w = await walletFor( - f, - chain, - preset('DEXES', chain, f.agentAddress, [ - { token: wrapped, maxPerCall: SMALL, maxTotal: SMALL }, - { token: output, maxPerCall: SMALL, maxTotal: SMALL }, - ]), - `e2e-preset-dex-${chain}` - ); - await wrap(f, w, chain, wrapped, SMALL); - const abi = parseAbi([ - 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns(uint256)', - ]); - const swap = (recipient: Address, amountIn = SMALL) => - call( - router, - encodeFunctionData({ - abi, - functionName: 'exactInputSingle', - args: [ - { - tokenIn: wrapped, - tokenOut: output, - fee, - recipient, - amountIn, - amountOutMinimum: BigInt(1), - sqrtPriceLimitX96: ZERO, - }, - ], - }) - ); - await expect( - w.agent.universal.sendTransaction({ - to: { address: router, chain }, - gasLimit: BigInt(2_000_000), - data: [swap(f.agentAddress)], - }) - ).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, - }); - await expect( - w.agent.universal.sendTransaction({ - to: { address: router, chain }, - gasLimit: BigInt(2_000_000), - data: [swap(w.cea, SMALL + BigInt(1))], - }) - ).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, - }); - const before = await balance(chain, output, w.cea); - await send( - f, - w, - chain, - w.agent, - [approve(wrapped, router, SMALL), swap(w.cea)], - 'dex-swap' - ); - const received = (await balance(chain, output, w.cea)) - before; - expect(received).toBeGreaterThan(ZERO); - expect(await balance(chain, wrapped, w.cea)).toBe(ZERO); - f.evidence('preset-expanded-dex', { - chain, - wallet: w.wallet, - cea: w.cea, - pool, - router, - amountIn: SMALL, - amountOut: received, - }); - if (chain === CHAIN.BASE_SEPOLIA) return; // its Aave reserve uses a different test token; exercised in case 5. - const rule = preset('LENDING', chain, f.agentAddress, [ - { token: output, maxPerCall: received, maxTotal: received }, - ]); - await f.owner.agentic - .wallet(w.wallet) - .rules.update({ rules: [{ rulesId: w.rulesId, rule }] }); - if (chain === CHAIN.ARBITRUM_SEPOLIA) { - const aave = rule.allowedCalls.find((c) => c.app === 'Aave V3')! - .target as Address; - await send( - f, - w, - chain, - w.agent, - [ - approve(output, aave, received), - call( - aave, - encodeFunctionData({ - abi: AAVE, - functionName: 'supply', - args: [output, received, w.cea, 0], - }) - ), - ], - 'aave-supply' - ); - expect(await balance(chain, output, w.cea)).toBe(before); - await send( - f, - w, - chain, - w.agent, - [ - call( - aave, - encodeFunctionData({ - abi: AAVE, - functionName: 'withdraw', - args: [output, received, w.cea], - }) - ), - ], - 'aave-withdraw' - ); - expect(await balance(chain, output, w.cea)).toBe(before + received); - f.evidence('preset-expanded-aave', { - chain, - wallet: w.wallet, - cea: w.cea, - amount: received, - asset: output, - }); - } else { - const venus = rule.allowedCalls.find((c) => c.app === 'Venus vUSDT')! - .target as Address; - await send( - f, - w, - chain, - w.agent, - [ - approve(output, venus, received), - call( - venus, - encodeFunctionData({ - abi: MARKET, - functionName: 'mint', - args: [received], - }) - ), - ], - 'venus-mint' - ); - const shares = await balance(chain, venus, w.cea); - expect(shares).toBeGreaterThan(ZERO); - expect(await balance(chain, output, w.cea)).toBe(before); - const redeemAmount = await underlyingRedemption( - chain, - venus, - shares, - received - ); - const redemption = await send( - f, - w, - chain, - w.agent, - [ - call( - venus, - encodeFunctionData({ - abi: MARKET, - functionName: 'redeemUnderlying', - args: [redeemAmount], - }) - ), - ], - 'venus-redeem' - ); - const redeemed = (await balance(chain, output, w.cea)) - before; - const remainingShares = await balance(chain, venus, w.cea); - expect(remainingShares).toBeLessThan(shares); - expect(redeemed).toBe(redeemAmount); - assertRedemption( - redemption, - venus, - w.cea, - shares - remainingShares, - redeemed - ); - expect(redeemed).toBeGreaterThanOrEqual( - (received * BigInt(99)) / BigInt(100) - ); - f.evidence('preset-expanded-venus', { - chain, - wallet: w.wallet, - cea: w.cea, - deposited: received, - redeemed, - shares, - remainingShares, - asset: output, - }); - } - } - it('2. DEX swap on eip155:84532', () => dexAndLend(dexCases[0]), 1_200_000); - it( - '3. DEX swap and Aave round trip on eip155:421614', - () => dexAndLend(dexCases[1]), - 1_200_000 - ); - it( - '4. DEX swap and Venus round trip on eip155:97', - () => dexAndLend(dexCases[2]), - 1_200_000 - ); - - it('5. Base Aave and both Moonwell markets supply and redeem wallet positions', async () => { - const chain = CHAIN.BASE_SEPOLIA; - const link = '0x810D46F9a9027E28F9B01F75E2bdde839dA61115' as Address; - const usdbc = '0x4616975f7E6045FD9737e95bC527e9e14c727BD2' as Address; - const weth = '0x663A6F02d547dFd639BF689FED298712f2FB69Fb' as Address; - const faucet = '0xD9145b5F45Ad4519c7ACcD6E0A4A82e83bB8A6Dc' as Address; - const amountUSDBC = BigInt(10_000); - expect(await read(chain).getCode({ address: faucet })).not.toBeUndefined(); - f = await setupAgw(); - const rule = preset('LENDING', chain, f.agentAddress, [ - { token: link, maxPerCall: SMALL, maxTotal: SMALL }, - { token: usdbc, maxPerCall: amountUSDBC, maxTotal: amountUSDBC }, - { token: weth, maxPerCall: SMALL, maxTotal: SMALL }, - ]); - const w = await walletFor(f, chain, rule, 'e2e-preset-base-lending'); - const aave = rule.allowedCalls.find((c) => c.app === 'Aave V3')! - .target as Address; - const mUSDBC = rule.allowedCalls.find((c) => c.app === 'Moonwell mUSDbC')! - .target as Address; - const mWETH = rule.allowedCalls.find((c) => c.app === 'Moonwell mWETH')! - .target as Address; - await send( - f, - w, - chain, - w.owner, - [ - call( - faucet, - encodeFunctionData({ - abi: parseAbi(['function mint(address,address,uint256)']), - functionName: 'mint', - args: [link, w.cea, SMALL], - }) - ), - call( - usdbc, - encodeFunctionData({ - abi: parseAbi(['function allocateTo(address,uint256)']), - functionName: 'allocateTo', - args: [w.cea, amountUSDBC], - }) - ), - call( - weth, - encodeFunctionData({ - abi: parseAbi(['function mint(address,uint256)']), - functionName: 'mint', - args: [w.cea, SMALL], - }) - ), - ], - 'faucets' - ); - expect(await balance(chain, link, w.cea)).toBe(SMALL); - expect(await balance(chain, usdbc, w.cea)).toBe(amountUSDBC); - expect(await balance(chain, weth, w.cea)).toBe(SMALL); - await send( - f, - w, - chain, - w.agent, - [ - approve(link, aave, SMALL), - call( - aave, - encodeFunctionData({ - abi: AAVE, - functionName: 'supply', - args: [link, SMALL, w.cea, 0], - }) - ), - approve(usdbc, mUSDBC, amountUSDBC), - call( - mUSDBC, - encodeFunctionData({ - abi: MARKET, - functionName: 'mint', - args: [amountUSDBC], - }) - ), - approve(weth, mWETH, SMALL), - call( - mWETH, - encodeFunctionData({ - abi: MARKET, - functionName: 'mint', - args: [SMALL], - }) - ), - ], - 'lending-supply' - ); - const sharesUSDBC = await balance(chain, mUSDBC, w.cea), - sharesWETH = await balance(chain, mWETH, w.cea); - expect(sharesUSDBC).toBeGreaterThan(ZERO); - expect(sharesWETH).toBeGreaterThan(ZERO); - expect(await balance(chain, link, w.cea)).toBe(ZERO); - expect(await balance(chain, usdbc, w.cea)).toBe(ZERO); - expect(await balance(chain, weth, w.cea)).toBe(ZERO); - const redeemUSDBC = await underlyingRedemption( - chain, - mUSDBC, - sharesUSDBC, - amountUSDBC - ); - const redeemWETH = await underlyingRedemption( - chain, - mWETH, - sharesWETH, - SMALL - ); - const redemption = await send( - f, - w, - chain, - w.agent, - [ - call( - aave, - encodeFunctionData({ - abi: AAVE, - functionName: 'withdraw', - args: [link, SMALL, w.cea], - }) - ), - call( - mUSDBC, - encodeFunctionData({ - abi: MARKET, - functionName: 'redeemUnderlying', - args: [redeemUSDBC], - }) - ), - call( - mWETH, - encodeFunctionData({ - abi: MARKET, - functionName: 'redeemUnderlying', - args: [redeemWETH], - }) - ), - ], - 'lending-withdraw' - ); - expect(await balance(chain, link, w.cea)).toBe(SMALL); - expect(await balance(chain, usdbc, w.cea)).toBeGreaterThanOrEqual( - (amountUSDBC * BigInt(99)) / BigInt(100) - ); - expect(await balance(chain, weth, w.cea)).toBeGreaterThanOrEqual( - (SMALL * BigInt(99)) / BigInt(100) - ); - const remainingUSDBC = await balance(chain, mUSDBC, w.cea), - remainingWETH = await balance(chain, mWETH, w.cea); - expect(remainingUSDBC).toBeLessThan(sharesUSDBC); - expect(remainingWETH).toBeLessThan(sharesWETH); - expect(await balance(chain, usdbc, w.cea)).toBe(redeemUSDBC); - expect(await balance(chain, weth, w.cea)).toBe(redeemWETH); - assertRedemption( - redemption, - mUSDBC, - w.cea, - sharesUSDBC - remainingUSDBC, - await balance(chain, usdbc, w.cea) - ); - assertRedemption( - redemption, - mWETH, - w.cea, - sharesWETH - remainingWETH, - await balance(chain, weth, w.cea) - ); - f.evidence('preset-expanded-base-lending', { - chain, - wallet: w.wallet, - cea: w.cea, - link: SMALL, - usdbc: amountUSDBC, - weth: SMALL, - sharesUSDBC, - sharesWETH, - }); - }, 1_200_000); -}); diff --git a/packages/core/__e2e__/agw/presets-live.spec.ts b/packages/core/__e2e__/agw/presets-live.spec.ts deleted file mode 100644 index ae685f1ff..000000000 --- a/packages/core/__e2e__/agw/presets-live.spec.ts +++ /dev/null @@ -1,556 +0,0 @@ -/** Real Donut -> Sepolia preset execution. No mainnet transactions. - * Destination WETH/Circle USDC are intentionally prefunded, not bridged assets: - * those two tokens have no usable SDK PRC20 pair. Generated call pins are unchanged. - */ -import { - createPublicClient, - encodeFunctionData, - erc20Abi, - http, - parseAbi, - parseEther, - parseEventLogs, - type Address, - type Hex, -} from 'viem'; -import { sepolia } from 'viem/chains'; -import { PushChain, CHAIN } from '../../src'; -import { MOVEABLE_TOKEN_CONSTANTS } from '../../src/lib/constants/tokens'; -import { getNativePRC20ForChain } from '../../src/lib/orchestrator/internals/helpers'; -import { getCEAAddress } from '../../src/lib/orchestrator/cea-utils'; -import { - AGW_E2E_ENABLED, - setupAgw, - evmClient, - inSeconds, - type AgwFixture, -} from './_fixture'; - -const d = AGW_E2E_ENABLED ? describe : describe.skip; -const WETH = '0xfff9976782d46cc05630d1f6ebab18b2324d6b14' as Address; -const USDC = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' as Address; -const FACTORY = '0x0227628f3F023bb0B980b67D528571c95c6DaC1c' as Address; -const AMOUNT = BigInt(10) ** BigInt(12); -const MAX_PC = parseEther('20'); -const SWAP = parseAbi([ - 'function exactInputSingle((address tokenIn,address tokenOut,uint24 fee,address recipient,uint256 amountIn,uint256 amountOutMinimum,uint160 sqrtPriceLimitX96)) returns(uint256)', -]); -const EXECUTED = parseAbi([ - 'event UniversalTxExecuted(bytes32 indexed subTxId, bytes32 indexed universalTxId, address indexed originCaller, address target, bytes data)', -]); -const sep = createPublicClient({ - chain: sepolia, - transport: http('https://ethereum-sepolia-rpc.publicnode.com'), -}); - -d('agw presets live', () => { - let f: AgwFixture; - afterAll(() => f?.teardown()); - it('1. Uniswap preset swaps from the wallet CEA and rejects a different recipient', async () => { - // Read-only protocol prerequisites run before any funding. - const block = await sep.getBlockNumber(); - const pool = await sep.readContract({ - address: FACTORY, - abi: parseAbi([ - 'function getPool(address,address,uint24) view returns(address)', - ]), - functionName: 'getPool', - args: [WETH, USDC, 500], - blockNumber: block, - }); - if (/^0x0+$/.test(pool)) - throw new Error( - 'PRESET_PREREQUISITE: Sepolia WETH/Circle USDC pool is absent' - ); - const liquidity = await sep.readContract({ - address: pool, - abi: parseAbi(['function liquidity() view returns(uint128)']), - functionName: 'liquidity', - blockNumber: block, - }); - if (!liquidity) - throw new Error( - 'PRESET_PREREQUISITE: Sepolia pool has no active liquidity' - ); - f = await setupAgw(); - const defaults = PushChain.utils.agentic.presets.get('DEXES', { - chainNamespace: CHAIN.ETHEREUM_SEPOLIA, - agent: f.agentAddress, - }); - expect(defaults.readiness.chains[0].configurationReady).toBe(true); - expect(defaults.rules[0].maxGasPerCall).toBe(MAX_PC); - expect(defaults.rules[0].validUntil).toBeGreaterThan(inSeconds(6 * 86400)); - expect( - defaults.readiness.chains[0].selectedAssets.map((a) => - a.token.toString().toLowerCase() - ) - ).toEqual([WETH.toLowerCase(), USDC.toLowerCase()]); - expect( - defaults.readiness.chains[0].selectedAssets.every( - (a) => a.maxPerCall > BigInt(0) - ) - ).toBe(true); - const { rules } = PushChain.utils.agentic.presets.get('DEXES', { - chainNamespace: CHAIN.ETHEREUM_SEPOLIA, - agent: f.agentAddress, - validUntil: inSeconds(3600), - maxGasPerCall: MAX_PC, - assets: [ - { token: WETH, maxPerCall: AMOUNT, maxTotal: AMOUNT * BigInt(2) }, - { - token: USDC, - maxPerCall: BigInt(1_000_000), - maxTotal: BigInt(2_000_000), - }, - ], - }); - // assets[] budgets bridge burns on Push. This test spends already-held CEA - // tokens, so omit bridge assets without changing any generated call or pin. - // Prefunded CEA fixture: bypass bridge budgets only in this protocol test. - const { wallet, rulesIds } = await f.owner.agentic.create( - 'e2e-preset-uniswap', - { rules: rules.map((rule) => ({ ...rule, assets: [] })) } - ); - const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) - .cea as Address; - const router = rules[0].allowedCalls[0].target as Address; - await f.fundPC(wallet, parseEther('65')); - const peth = getNativePRC20ForChain( - CHAIN.ETHEREUM_SEPOLIA, - f.manifest.network - ); - await ( - await f.owner.universal.sendTransaction({ - to: peth, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [wallet, AMOUNT * BigInt(2)], - }), - }) - ).wait(); - const owner = await evmClient( - process.env['PUSH_PRIVATE_KEY'] as Hex, - CHAIN.PUSH_TESTNET_DONUT, - f.manifest.network, - wallet - ); - const wrap = await owner.universal.sendTransaction({ - to: { address: WETH, chain: CHAIN.ETHEREUM_SEPOLIA }, - data: [ - { - to: WETH, - value: AMOUNT * BigInt(2), - data: encodeFunctionData({ - abi: parseAbi(['function deposit() payable']), - functionName: 'deposit', - }), - }, - ], - funds: { - token: MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH, - amount: AMOUNT * BigInt(2), - }, - gasLimit: BigInt(2_000_000), - }); - const wrapped = await wrap.wait(); - expect(wrapped.externalTxHash).toBeTruthy(); - expect( - (await sep.getTransactionReceipt({ hash: wrapped.externalTxHash as Hex })) - .status - ).toBe('success'); - const balance = (token: Address) => - sep.readContract({ - address: token, - abi: erc20Abi, - functionName: 'balanceOf', - args: [cea], - }); - expect(await balance(WETH)).toBe(AMOUNT * BigInt(2)); - const before = await balance(USDC); - const agent = await f.agent(wallet); - const swap = (recipient: Address) => - encodeFunctionData({ - abi: SWAP, - functionName: 'exactInputSingle', - args: [ - { - tokenIn: WETH, - tokenOut: USDC, - fee: 500, - recipient, - amountIn: AMOUNT, - amountOutMinimum: BigInt(1), - sqrtPriceLimitX96: BigInt(0), - }, - ], - }); - await expect( - agent.universal.sendTransaction({ - to: { address: router, chain: CHAIN.ETHEREUM_SEPOLIA }, - data: [{ to: router, value: BigInt(0), data: swap(f.agentAddress) }], - gasLimit: BigInt(700_000), - }) - ).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, - }); - const tx = await agent.universal.sendTransaction({ - to: { address: router, chain: CHAIN.ETHEREUM_SEPOLIA }, - gasLimit: BigInt(700_000), - data: [ - { - to: WETH, - value: BigInt(0), - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [router, AMOUNT], - }), - }, - { to: router, value: BigInt(0), data: swap(cea) }, - ], - }); - const receipt = await tx.wait(); - expect(receipt.externalTxHash).toBeTruthy(); - const dest = await sep.getTransactionReceipt({ - hash: receipt.externalTxHash as Hex, - }); - expect(dest.status).toBe('success'); - expect(await balance(WETH)).toBe(AMOUNT); - const after = await balance(USDC); - expect(after).toBeGreaterThan(before); - expect( - parseEventLogs({ abi: EXECUTED, logs: dest.logs }).some( - (e) => - e.address.toLowerCase() === cea.toLowerCase() && - e.args.originCaller.toLowerCase() === wallet.toLowerCase() && - e.args.target.toLowerCase() === router.toLowerCase() && - e.args.data === swap(cea) - ) - ).toBe(true); - f.evidence('preset-uniswap-live', { - wallet, - cea, - rulesId: rulesIds[0], - pool, - prerequisiteBlock: block, - wrapTx: wrap.hash, - wrapExternal: wrapped.externalTxHash, - pushTx: tx.hash, - externalTx: receipt.externalTxHash, - amountIn: AMOUNT, - amountOut: after - before, - }); - }, 900_000); - it('2. Aave preset supplies and withdraws the wallet position', async () => { - const mockLINK = '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5' as Address; - const faucet = '0xC959483DBa39aa9E78757139af0e9a2EDEb3f42D' as Address; - const amount = BigInt(10) ** BigInt(12); - const faucetAbi = parseAbi(['function mint(address,address,uint256)']); - // Testnet USDC/DAI/USDT supply caps are exhausted; use faucet LINK. - // Mock LINK is a destination-only asset, not an SDK bridge token. - await sep.simulateContract({ - address: faucet, - abi: faucetAbi, - functionName: 'mint', - args: [mockLINK, '0x0000000000000000000000000000000000001234', amount], - account: '0x0000000000000000000000000000000000001234', - }); - const readinessPool = PushChain.utils.agentic.presets.get('LENDING', { - chainNamespace: CHAIN.ETHEREUM_SEPOLIA, - }).rules[0].allowedCalls[0].target as Address; - // Fail before funding if protocol gates (cap, pause, freeze) reject supply. - // An unfunded probe reaching token transfer is the expected result. - let readiness = ''; - try { - await sep.simulateContract({ - address: readinessPool, - abi: parseAbi(['function supply(address,uint256,address,uint16)']), - functionName: 'supply', - args: [ - mockLINK, - amount, - '0x0000000000000000000000000000000000001234', - 0, - ], - account: '0x0000000000000000000000000000000000001234', - }); - } catch (error) { - readiness = String( - (error as { shortMessage?: string }).shortMessage ?? error - ); - } - if (!readiness.includes('transfer amount exceeds balance')) - throw new Error( - `PRESET_PREREQUISITE: Aave reserve rejected unfunded supply probe: ${readiness}` - ); - f?.teardown(); - f = await setupAgw(); - const defaults = PushChain.utils.agentic.presets.get('LENDING', { - chainNamespace: CHAIN.ETHEREUM_SEPOLIA, - agent: f.agentAddress, - }); - expect(defaults.readiness.chains[0].configurationReady).toBe(true); - expect(defaults.readiness.chains[0].selectedAssets[0].maxPerCall).toBe( - parseEther('1') - ); - expect(defaults.rules[0].maxGasPerCall).toBe(MAX_PC); - expect( - defaults.readiness.chains[0].selectedAssets.map((a) => - a.token.toString().toLowerCase() - ) - ).toEqual([mockLINK.toLowerCase()]); - const { rules } = PushChain.utils.agentic.presets.get('LENDING', { - chainNamespace: CHAIN.ETHEREUM_SEPOLIA, - agent: f.agentAddress, - validUntil: inSeconds(3600), - maxGasPerCall: MAX_PC, - assets: defaults.readiness.chains[0].selectedAssets.map((a) => ({ - ...a, - maxPerCall: amount, - maxTotal: amount, - })), - }); - // Prefunded CEA fixture: bypass bridge budgets only in this protocol test. - const { wallet, rulesIds } = await f.owner.agentic.create( - 'e2e-preset-aave', - { rules: rules.map((rule) => ({ ...rule, assets: [] })) } - ); - const cea = (await getCEAAddress(wallet, CHAIN.ETHEREUM_SEPOLIA)) - .cea as Address; - const pool = rules[0].allowedCalls[0].target as Address; - await f.fundPC(wallet, parseEther('65')); - const owner = await evmClient( - process.env['PUSH_PRIVATE_KEY'] as Hex, - CHAIN.PUSH_TESTNET_DONUT, - f.manifest.network, - wallet - ); - const mint = await owner.universal.sendTransaction({ - to: { address: faucet, chain: CHAIN.ETHEREUM_SEPOLIA }, - gasLimit: BigInt(2_000_000), - data: [ - { - to: faucet, - value: BigInt(0), - data: encodeFunctionData({ - abi: faucetAbi, - functionName: 'mint', - args: [mockLINK, cea, amount], - }), - }, - ], - }); - f.evidence('preset-aave-stage', { - stage: 'mint-submitted', - wallet, - cea, - pushTx: mint.hash, - }); - const minted = await mint.wait(); - f.evidence('preset-aave-stage', { - stage: 'mint-completed', - wallet, - cea, - externalTx: minted.externalTxHash, - }); - expect( - (await sep.getTransactionReceipt({ hash: minted.externalTxHash as Hex })) - .status - ).toBe('success'); - const balance = () => - sep.readContract({ - address: mockLINK, - abi: erc20Abi, - functionName: 'balanceOf', - args: [cea], - }); - expect(await balance()).toBe(amount); - const abi = parseAbi([ - 'function supply(address,uint256,address,uint16)', - 'function withdraw(address,uint256,address) returns(uint256)', - 'event Supply(address indexed reserve,address user,address indexed onBehalfOf,uint256 amount,uint16 indexed referralCode)', - 'event Withdraw(address indexed reserve,address indexed user,address indexed to,uint256 amount)', - ]); - const agent = await f.agent(wallet); - const supplyData = encodeFunctionData({ - abi, - functionName: 'supply', - args: [mockLINK, amount, cea, 0], - }); - await expect( - agent.universal.sendTransaction({ - to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, - gasLimit: BigInt(900_000), - data: [ - { - to: pool, - value: BigInt(0), - data: encodeFunctionData({ - abi, - functionName: 'supply', - args: [mockLINK, amount + BigInt(1), cea, 0], - }), - }, - ], - }) - ).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, - }); - const supply = await agent.universal.sendTransaction({ - to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, - gasLimit: BigInt(900_000), - data: [ - { - to: mockLINK, - value: BigInt(0), - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [pool, amount], - }), - }, - { to: pool, value: BigInt(0), data: supplyData }, - ], - }); - f.evidence('preset-aave-stage', { - stage: 'supply-submitted', - wallet, - cea, - pushTx: supply.hash, - }); - const supplied = await supply.wait(); - f.evidence('preset-aave-stage', { - stage: 'supply-completed', - wallet, - cea, - externalTx: supplied.externalTxHash, - }); - const supplyReceipt = await sep.getTransactionReceipt({ - hash: supplied.externalTxHash as Hex, - }); - expect(supplyReceipt.status).toBe('success'); - expect(await balance()).toBe(BigInt(0)); - const events = parseEventLogs({ - abi, - eventName: 'Supply', - logs: supplyReceipt.logs, - }); - expect( - events.some( - (e) => - e.address.toLowerCase() === pool.toLowerCase() && - e.args.user.toLowerCase() === cea.toLowerCase() && - e.args.onBehalfOf.toLowerCase() === cea.toLowerCase() && - e.args.amount === amount - ) - ).toBe(true); - const half = amount / BigInt(2); - const regenerated = PushChain.utils.agentic.presets.get('LENDING', { - chainNamespace: CHAIN.ETHEREUM_SEPOLIA, - agent: f.agentAddress, - validUntil: inSeconds(3600), - maxGasPerCall: MAX_PC, - assets: [{ token: mockLINK, maxPerCall: half, maxTotal: amount }], - }); - expect(regenerated.readiness.chains[0].configurationReady).toBe(true); - const changed = await f.owner.agentic.wallet(wallet).rules.update({ - rules: [ - { - rulesId: rulesIds[0], - rule: { ...regenerated.rules[0], assets: [] }, - }, - ], - }); - await expect( - agent.universal.sendTransaction({ - to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, - gasLimit: BigInt(900_000), - data: [ - { - to: pool, - value: BigInt(0), - data: encodeFunctionData({ - abi, - functionName: 'withdraw', - args: [mockLINK, amount, cea], - }), - }, - ], - }) - ).rejects.toMatchObject({ - decodedError: { name: 'PolicyCheckReverted(ArgPinAboveMax)' }, - }); - // The cap applies to each allowed call, not their aggregate in a batch. - const withdraw = await agent.universal.sendTransaction({ - to: { address: pool, chain: CHAIN.ETHEREUM_SEPOLIA }, - gasLimit: BigInt(900_000), - data: [0, 1].map(() => ({ - to: pool, - value: BigInt(0), - data: encodeFunctionData({ - abi, - functionName: 'withdraw', - args: [mockLINK, half, cea], - }), - })), - }); - f.evidence('preset-aave-stage', { - stage: 'withdraw-submitted', - wallet, - cea, - pushTx: withdraw.hash, - }); - const withdrawn = await withdraw.wait(); - f.evidence('preset-aave-stage', { - stage: 'withdraw-completed', - wallet, - cea, - externalTx: withdrawn.externalTxHash, - }); - const withdrawalReceipt = await sep.getTransactionReceipt({ - hash: withdrawn.externalTxHash as Hex, - }); - expect(withdrawalReceipt.status).toBe('success'); - expect(await balance()).toBe(amount); - const withdrawals = parseEventLogs({ - abi, - eventName: 'Withdraw', - logs: withdrawalReceipt.logs, - }).filter( - (e) => - e.address.toLowerCase() === pool.toLowerCase() && - e.args.user.toLowerCase() === cea.toLowerCase() && - e.args.to.toLowerCase() === cea.toLowerCase() - ); - expect(withdrawals).toHaveLength(2); - expect(withdrawals.every((e) => e.args.amount === half)).toBe(true); - expect(withdrawals.reduce((sum, e) => sum + e.args.amount, BigInt(0))).toBe( - amount - ); - f.evidence('preset-limits-regeneration-live', { - wallet, - cea, - oldRulesId: rulesIds[0], - newRulesId: changed.rules[0].rulesId, - oldCap: amount, - newCap: half, - rejectedAboveCap: true, - destinationTx: withdrawn.externalTxHash, - }); - f.evidence('preset-aave-live', { - wallet, - cea, - rulesId: rulesIds[0], - mockLINK, - pool, - mintTx: mint.hash, - mintExternal: minted.externalTxHash, - supplyTx: supply.hash, - supplyExternal: supplied.externalTxHash, - withdrawTx: withdraw.hash, - withdrawExternal: withdrawn.externalTxHash, - amount, - }); - }, 900_000); -}); diff --git a/packages/core/__e2e__/agw/tokens-only-live.spec.ts b/packages/core/__e2e__/agw/tokens-only-live.spec.ts new file mode 100644 index 000000000..386dcd3b8 --- /dev/null +++ b/packages/core/__e2e__/agw/tokens-only-live.spec.ts @@ -0,0 +1,220 @@ +/** Live tokens-only rules and MOVEABLE ALL (Harsh/Zaryab decisions, 2026-10-23). + * 1: a rule with Circle USDC and no app lets the agent transfer it back to the + * owner on Sepolia; another recipient is refused before broadcast. + * 2: ALL expands on create and reads back every routable token with defaults. + */ +import '@e2e/shared/setup'; +import { + createPublicClient, + http, + encodeFunctionData, + erc20Abi, + getAddress, + parseEther, + zeroAddress, + type Hex, +} from 'viem'; +import { CHAIN, PushChain } from '../../src'; +import { PUSH_NETWORK } from '../../src/lib/constants/enums'; +import { exactAgenticRoutes } from '../../src/lib/agentic/token-routes'; +import { AGW_E2E_ENABLED, inSeconds, setupAgw } from './_fixture'; + +const d = AGW_E2E_ENABLED ? describe : describe.skip; +const AMOUNT = BigInt(10_000); // 0.01 USDC +const SEPOLIA_RPC = 'https://ethereum-sepolia-rpc.publicnode.com'; +const OTHER = getAddress('0x000000000000000000000000000000000000dEaD'); + +d('agw tokens-only live', () => { + it('1. Sepolia Circle USDC goes back to the owner only', async () => { + const [route] = exactAgenticRoutes( + CHAIN.ETHEREUM_SEPOLIA, + PUSH_NETWORK.TESTNET_DONUT + ); + const rpc = createPublicClient({ + transport: http(SEPOLIA_RPC, { timeout: 15000, retryCount: 1 }), + }); + const f = await setupAgw({ deferAgentFunding: true }); + try { + if ( + (await f.push.readContract({ + address: route.prc20, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + })) < + AMOUNT * BigInt(2) + ) + throw new Error(`TOKENS_ONLY_PREREQUISITE: owner lacks ${route.prc20}`); + await f.fundAgent(); + const made = await f.owner.agentic.create('tokens-only', { + rules: [ + { + agent: f.agentAddress, + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + validUntil: inSeconds(3600), + assets: [ + { + token: route.source, + maxPerCall: AMOUNT, + maxTotal: AMOUNT * BigInt(2), + }, + ], + maxGasPerCall: parseEther('20'), + }, + ], + }); + const record = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (!('allowedCalls' in record.rule)) + throw new Error('Expected an EVM rule'); + expect(record.rule.allowedCalls).toEqual([ + { + target: getAddress(route.source), + selector: '0xa9059cbb', + maxValue: BigInt(0), + pins: [ + { + arg: 0, + expected: `0x${f.ownerAddress + .slice(2) + .padStart(64, '0')}`.toLowerCase(), + }, + { arg: 1, max: AMOUNT }, + ], + }, + ]); + f.evidence('tokens-only-created', { + wallet: made.wallet, + hash: made.tx.hash, + }); + await f.fundPC(made.wallet, parseEther('25')); + await ( + await f.owner.universal.sendTransaction({ + to: route.prc20, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [made.wallet, AMOUNT * BigInt(2)], + }), + }) + ).wait(); + const agent = await f.agent(made.wallet); + const send = (to: `0x${string}`) => + agent.universal.sendTransaction({ + to: { chain: CHAIN.ETHEREUM_SEPOLIA, address: route.source }, + funds: { + amount: AMOUNT, + token: { + symbol: route.symbol, + decimals: route.decimals, + address: route.source, + mechanism: 'approve', + }, + }, + data: [ + { + to: route.source, + value: BigInt(0), + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [to, AMOUNT], + }), + }, + ], + }); + // Refused by the policy simulation, so nothing is spent. + await expect(send(OTHER)).rejects.toMatchObject({ + decodedError: { name: 'PolicyCheckReverted(ArgPinMismatch)' }, + }); + const delivered = () => + rpc.readContract({ + address: route.source, + abi: erc20Abi, + functionName: 'balanceOf', + args: [f.ownerAddress], + }); + const before = await delivered(); + const tx = await send(f.ownerAddress); + f.evidence('tokens-only-submitted', { + wallet: made.wallet, + hash: tx.hash, + }); + const receipt = await tx.wait({ outboundTimeoutMs: 180000 }); + expect(receipt.externalStatus).toBe('success'); + const dest = await rpc.getTransactionReceipt({ + hash: receipt.externalTxHash as Hex, + }); + expect(dest.status).toBe('success'); + expect(await delivered()).toBe(before + AMOUNT); + const after = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (after.spent?.kind !== 'universal') + throw new Error('Missing universal spend'); + expect(after.spent.assets[0].spent).toBe(AMOUNT); + f.evidence('tokens-only-delivered', { + hash: tx.hash, + destinationHash: receipt.externalTxHash, + destinationGasUsed: dest.gasUsed, + }); + } finally { + f.teardown(); + } + }, 600000); + + it('2. ALL expands on create to every routable Sepolia token', async () => { + const f = await setupAgw({ deferAgentFunding: true }); + try { + const expected = PushChain.utils.agentic.assets.all( + CHAIN.ETHEREUM_SEPOLIA + ); + const made = await f.owner.agentic.create('all-tokens', { + rules: [ + { + agent: f.agentAddress, + chainNamespace: CHAIN.ETHEREUM_SEPOLIA, + validUntil: inSeconds(3600), + assets: [ + { + token: PushChain.CONSTANTS.MOVEABLE.TOKEN.ETHEREUM_SEPOLIA.ALL, + }, + ], + maxGasPerCall: parseEther('20'), + allowedCalls: [ + { + target: '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14', + selector: 'deposit()', + maxValue: BigInt(1), + }, + ], + }, + ], + }); + const record = await f.owner.agentic + .wallet(made.wallet) + .rules.get(made.rulesIds[0]); + if (!('allowedCalls' in record.rule)) + throw new Error('Expected an EVM rule'); + expect(record.rule.assets).toEqual( + expected.map((a) => ({ + token: getAddress( + typeof a.token === 'string' ? a.token : a.token.address + ), + maxPerCall: a.maxPerCall, + maxTotal: a.maxTotal, + })) + ); + expect(record.rule.assets).toHaveLength(6); + expect(record.rule.assets![0].token).toBe(zeroAddress); + f.evidence('all-tokens-created', { + wallet: made.wallet, + hash: made.tx.hash, + assets: record.rule.assets, + }); + } finally { + f.teardown(); + } + }, 600000); +}); diff --git a/packages/core/__e2e__/ci/preflight.ts b/packages/core/__e2e__/ci/preflight.ts index 208c594ac..cb315df91 100644 --- a/packages/core/__e2e__/ci/preflight.ts +++ b/packages/core/__e2e__/ci/preflight.ts @@ -352,16 +352,12 @@ function renderTable(rows: Row[]): void { if (presetCases.length) { const { PRESET_ACCEPTANCE_CASES } = await import('../shared/agw-preset-acceptance'); // Only the selected cases; their ids end in the spec's case number. - const selected = presetCases.map((n) => { + for (const n of presetCases) { const entry = PRESET_ACCEPTANCE_CASES[n - 1]; if (!entry) throw new Error(`Unknown preset acceptance case ${n}`); - return entry; - }); - const blocked = selected.flatMap(([category, chainNamespace]) => { - const result = PushChain.utils.agentic.presets.get(category, { chainNamespace, agent: '0x0000000000000000000000000000000000005678' }); - return result.readiness.ready ? [] : [{ category, chainNamespace, issues: result.readiness.chains[0].issues }]; - }); - if (blocked.length) throw new Error(`PRESET_BLOCKED_BEFORE_FUNDING: ${JSON.stringify(blocked)}`); + // Every shipped preset is routable; get throws if its catalog entry is gone. + PushChain.utils.agentic.presets.get(entry[0], { chainNamespace: entry[1] }); + } } if (scenarios.some((s) => s.group === 'agw-svm-wire' || s.group === 'agw-svm-agent-spl' || s.file === '__e2e__/agw/svm-public.spec.ts')) { const { inspectSvmWirePrograms, svmWireConnection } = await import('../shared/agw-svm-preflight'); diff --git a/packages/core/__e2e__/ci/suite.ts b/packages/core/__e2e__/ci/suite.ts index a95d91549..41f04e17a 100644 --- a/packages/core/__e2e__/ci/suite.ts +++ b/packages/core/__e2e__/ci/suite.ts @@ -37,11 +37,9 @@ export const GROUPS = [ 'agw', 'agw-svm-wire', 'agw-svm-replay', - 'agw-presets', - 'agw-presets-expanded', 'agw-presets-acceptance', - 'agw-presets-routed', 'agw-exact-routes', + 'agw-tokens-only', 'agw-svm-memo', 'agw-svm-agent-spl', 'agw-gas', @@ -1002,64 +1000,14 @@ export const SCENARIOS: Scenario[] = [ needs: { masterPC: '0.5' }, env: { AGW_E2E: '1' }, }, - { - id: 'agw-preset-eigen-sepolia', - group: 'agw-presets-expanded', - file: '__e2e__/agw/presets-expanded-live.spec.ts', - grep: 'agw presets expanded 1', - needs: { masterPC: '110', masterPETH: '0.000001' }, - env: { AGW_E2E: '1' }, - }, - { - id: 'agw-preset-dex-base', - group: 'agw-presets-expanded', - file: '__e2e__/agw/presets-expanded-live.spec.ts', - grep: 'agw presets expanded 2', - needs: { masterPC: '110' }, - env: { AGW_E2E: '1' }, - }, - { - id: 'agw-preset-dex-aave-arbitrum', - group: 'agw-presets-expanded', - file: '__e2e__/agw/presets-expanded-live.spec.ts', - grep: 'agw presets expanded 3', - needs: { masterPC: '110' }, - env: { AGW_E2E: '1' }, - }, - { - id: 'agw-preset-dex-venus-bnb', - group: 'agw-presets-expanded', - file: '__e2e__/agw/presets-expanded-live.spec.ts', - grep: 'agw presets expanded 4', - needs: { masterPC: '110' }, - env: { AGW_E2E: '1' }, - }, - { - id: 'agw-preset-base-aave-moonwell', - group: 'agw-presets-expanded', - file: '__e2e__/agw/presets-expanded-live.spec.ts', - grep: 'agw presets expanded 5', - needs: { masterPC: '110' }, - env: { AGW_E2E: '1' }, - }, - ...Array.from({ length: 9 }, (_, i) => ({ + ...Array.from({ length: 6 }, (_, i) => ({ id: `agw-unchanged-preset-${i + 1}`, group: 'agw-presets-acceptance' as const, file: '__e2e__/agw/presets-acceptance.spec.ts', grep: `agw unchanged preset acceptance ${i + 1}\\.`, needs: { masterPC: '55' }, env: { AGW_E2E: '1' }, - note: 'Exact public defaults; unavailable routes fail before funding. Exact default PRC20 funds are checked in the scenario.', - })), - // The subset whose default assets all have registered routes (case numbers above). - ...[1, 7].map((n) => ({ - id: `agw-routed-preset-${n}`, - group: 'agw-presets-routed' as const, - file: '__e2e__/agw/presets-acceptance.spec.ts', - grep: `agw unchanged preset acceptance ${n}\\.`, - needs: { masterPC: '55' }, - env: { AGW_E2E: '1' }, - note: 'DEXES Sepolia and LENDING Arbitrum Sepolia; the owner must hold the exact default PRC20s.', + note: 'Exact public defaults: the owner sends 2 bounded ETH amounts as the chain gas PRC20, and the agent wraps and uses them.', })), ...Array.from({ length: 4 }, (_, i) => ({ id: `agw-evm-gas-${i + 1}`, @@ -1079,6 +1027,24 @@ export const SCENARIOS: Scenario[] = [ env: { AGW_E2E: '1' }, note: `${name} Circle USDC node route; the owner must hold its exact PRC20.`, })), + { + id: 'agw-tokens-only-owner', + group: 'agw-tokens-only', + file: '__e2e__/agw/tokens-only-live.spec.ts', + grep: 'agw tokens-only live 1\\.', + needs: { masterPC: '30' }, + env: { AGW_E2E: '1' }, + note: 'Sepolia Circle USDC back to the owner; the owner must hold its exact PRC20.', + }, + { + id: 'agw-tokens-only-all', + group: 'agw-tokens-only', + file: '__e2e__/agw/tokens-only-live.spec.ts', + grep: 'agw tokens-only live 2\\.', + needs: { masterPC: '2' }, + env: { AGW_E2E: '1' }, + note: 'Create only: MOVEABLE ALL expands to every routable Sepolia token.', + }, ...[1, 2].map((n) => ({ id: `agw-svm-memo-${n}`, group: 'agw-svm-memo' as const, @@ -1095,22 +1061,6 @@ export const SCENARIOS: Scenario[] = [ needs: { masterPC: '45', masterUsdtSol: '0.002' }, env: { AGW_E2E: '1' }, }, - { - id: 'agw-preset-uniswap-live', - group: 'agw-presets', - file: '__e2e__/agw/presets-live.spec.ts', - grep: 'agw presets live 1', - needs: { masterPC: '75', masterPETH: '0.000002' }, - env: { AGW_E2E: '1' }, - }, - { - id: 'agw-preset-aave-live', - group: 'agw-presets', - file: '__e2e__/agw/presets-live.spec.ts', - grep: 'agw presets live 2', - needs: { masterPC: '75' }, - env: { AGW_E2E: '1' }, - }, { id: 'agw-v6-destination-pins', group: 'agw', diff --git a/packages/core/__e2e__/shared/agw-preset-acceptance.ts b/packages/core/__e2e__/shared/agw-preset-acceptance.ts index 683b60bb5..4df73f476 100644 --- a/packages/core/__e2e__/shared/agw-preset-acceptance.ts +++ b/packages/core/__e2e__/shared/agw-preset-acceptance.ts @@ -16,12 +16,9 @@ export const PRESET_ACCEPTANCE_CASES = [ ['DEXES', 'eip155:11155111'], ['DEXES', 'eip155:84532'], ['DEXES', 'eip155:421614'], - ['DEXES', 'eip155:97'], ['LENDING', 'eip155:11155111'], ['LENDING', 'eip155:84532'], ['LENDING', 'eip155:421614'], - ['LENDING', 'eip155:97'], - ['STAKING', 'eip155:11155111'], ] as const satisfies readonly (readonly [PresetCategory, `eip155:${string}`])[]; function words(param: AbiParameter): number { diff --git a/packages/core/scripts/check-agw-package.js b/packages/core/scripts/check-agw-package.js index 99267d60d..18b70977a 100644 --- a/packages/core/scripts/check-agw-package.js +++ b/packages/core/scripts/check-agw-package.js @@ -40,18 +40,11 @@ async function main() { agent: '0x0000000000000000000000000000000000005678', chainNamespace: 'eip155:11155111', }); - assert.equal(preset.readiness.ready, false); - assert.deepEqual( - preset.rules[0].assets, - preset.readiness.chains[0].selectedAssets - ); - assert(preset.readiness.chains[0].selectedAssets.length > 0); + assert.deepEqual(Object.keys(preset), ['rules']); + assert.equal(preset.rules[0].assets.length, 1); assert.equal( - cjs.PushChain.utils.agentic.presets.get('LENDING', { - agent: '0x0000000000000000000000000000000000005678', - chainNamespace: 'eip155:11155111', - }).readiness.ready, - false + preset.rules[0].assets[0].token, + '0x0000000000000000000000000000000000000000' ); const guide = fs.readFileSync(path.join(built, 'AGW.md'), 'utf8'); const source = [...guide.matchAll(/```ts\n([\s\S]*?)```/g)] diff --git a/packages/core/scripts/check-agw-preset-compatibility.js b/packages/core/scripts/check-agw-preset-compatibility.js index f746d0447..62c32a9dc 100644 --- a/packages/core/scripts/check-agw-preset-compatibility.js +++ b/packages/core/scripts/check-agw-preset-compatibility.js @@ -1,323 +1,155 @@ -/** Read-only protocol compatibility of the exact routed testnet tokens. +/** Read-only protocol compatibility of the shipped testnet presets. * Build core first. No signer, environment keys, funding or transactions. + * For every preset chain: the wrap target accepts native ETH (simulated with a + * balance override), the Uniswap router wraps into that WETH and a WETH/USDC pool + * quotes USDC for a dust swap, and the Aave pool lists that WETH as a reserve. * Usage: node packages/core/scripts/check-agw-preset-compatibility.js [report.json] */ const fs = require('node:fs'); const path = require('node:path'); -const { createPublicClient, http, parseAbi, zeroAddress } = require('viem'); +const { + createPublicClient, + encodeFunctionData, + getAddress, + http, + parseAbi, +} = require('viem'); const built = path.resolve(__dirname, '../../../dist/packages/core/src'); -const { PushChain } = require(built); -const { MOVEABLE_TOKENS } = require(path.join(built, 'lib/constants/tokens')); -const { exactAgenticRoutes, resolveAgenticPrc20 } = require(path.join( - built, - 'lib/agentic/token-routes' -)); const { PRESET_CATALOG } = require(path.join( built, 'lib/agentic/presets/catalog' )); -const DONUT = PushChain.CONSTANTS.PUSH_NETWORK.TESTNET_DONUT; const abi = parseAbi([ - 'function factory() view returns (address)', - 'function getPool(address,address,uint24) view returns (address)', - 'function liquidity() view returns (uint128)', + 'function deposit() payable', + 'function WETH9() view returns (address)', + // QuoterV2 is non-view; declared view so eth_call returns the quote. + 'function quoteExactInputSingle((address tokenIn,address tokenOut,uint256 amountIn,uint24 fee,uint160 sqrtPriceLimitX96)) view returns (uint256,uint160,uint32,uint256)', 'function getReservesList() view returns (address[])', - 'function underlying() view returns (address)', - 'function underlyingToken() view returns (address)', - 'function SOURCE_CHAIN_NAMESPACE() view returns (string)', - 'function SOURCE_TOKEN_ADDRESS() view returns (string)', ]); const endpoints = { 'eip155:11155111': 'https://ethereum-sepolia-rpc.publicnode.com', 'eip155:84532': 'https://base-sepolia-rpc.publicnode.com', 'eip155:421614': 'https://arbitrum-sepolia-rpc.publicnode.com', - 'eip155:97': 'https://bsc-testnet-rpc.publicnode.com', }; +const PROBE = '0x000000000000000000000000000000000000dEaD'; +// Uniswap V3 QuoterV2 per testnet; the acceptance spec sends 1e12 wei. +const QUOTERS = { + 'eip155:11155111': '0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3', + 'eip155:84532': '0xC5290058841028F1614F3A6F0F5816cAd0df5E27', + 'eip155:421614': '0x2779a0CC1c3e0E44D2542EC3e79e3864Ae93Ef0B', +}; +const QUOTE_AMOUNT = BigInt(10) ** BigInt(12); +const callTo = (def, selector) => + def.allowedCalls.find((c) => c.selector.startsWith(selector)); async function main() { - const report = { at: new Date().toISOString(), readOnly: true, chains: [] }; - const push = createPublicClient({ - transport: http('https://evm.donut.rpc.push.org/', { - timeout: 15000, - retryCount: 1, - }), - }); - report.pushBlock = String(await push.getBlockNumber()); - if ((await push.getChainId()) !== 42101) throw new Error('Expected Donut'); - for (const [chain, endpoint] of Object.entries(endpoints)) { - const entry = { chain, candidates: [], protocols: [], presets: [] }; - report.chains.push(entry); - const rpc = createPublicClient({ - transport: http(endpoint, { timeout: 12000, retryCount: 1 }), - }); - try { - const block = await rpc.getBlockNumber(); - entry.block = String(block); - if ((await rpc.getChainId()) !== Number(chain.split(':')[1])) - throw new Error('Destination chain mismatch'); - const read = (address, functionName, args = []) => - rpc.readContract({ - address, - abi, - functionName, - args, - blockNumber: block, - }); - // Registry tokens plus exact node routes the symbol registry cannot hold. - const sources = new Map(); - for (const token of MOVEABLE_TOKENS[chain] || []) - if (token.mechanism !== 'native') - sources.set(token.address.toLowerCase(), token); - for (const route of exactAgenticRoutes(chain, DONUT)) - sources.set(route.source.toLowerCase(), { - address: route.source, - symbol: route.symbol, - }); - for (const token of sources.values()) { - let mapped; - try { - mapped = resolveAgenticPrc20( - { chain, address: token.address }, - DONUT - ); - } catch (e) { - entry.candidates.push({ - token: token.address, - symbol: token.symbol, - unavailable: e.shortMessage || e.message, - }); - continue; - } - try { - const [sourceChain, source] = await Promise.all([ - push.readContract({ - address: mapped.address, - abi, - functionName: 'SOURCE_CHAIN_NAMESPACE', - blockNumber: BigInt(report.pushBlock), - }), - push - .readContract({ - address: mapped.address, - abi, - functionName: 'SOURCE_TOKEN_ADDRESS', - blockNumber: BigInt(report.pushBlock), - }) - .catch(() => - push.readContract({ - address: mapped.address, - abi: parseAbi([ - 'function SOURCE_TOKEN_ADDRESS() view returns (address)', - ]), - functionName: 'SOURCE_TOKEN_ADDRESS', - blockNumber: BigInt(report.pushBlock), - }) - ), - ]); - entry.candidates.push({ - token: token.address, - symbol: token.symbol, - prc20: mapped.address, - identityVerified: - sourceChain === chain && - source.toLowerCase() === token.address.toLowerCase(), - }); - } catch (e) { - // An RPC failure is unknown, not unsupported. - entry.candidates.push({ - token: token.address, - symbol: token.symbol, - prc20: mapped.address, - readError: e.shortMessage || e.message, - }); - } + const report = { at: new Date().toISOString(), readOnly: true, cases: [] }; + for (const preset of PRESET_CATALOG) + for (const def of preset.chains) { + const result = { preset: preset.constant, chain: def.chainNamespace }; + report.cases.push(result); + const endpoint = endpoints[def.chainNamespace]; + if (!endpoint) { + result.error = 'no read-only endpoint configured'; + process.exitCode = 1; + continue; } - const routed = entry.candidates.filter((c) => c.identityVerified); - for (const preset of PRESET_CATALOG) { - const def = preset.chains.find((c) => c.chainNamespace === chain); - if (!def) continue; - const generated = PushChain.utils.agentic.presets.get(preset.constant, { - chainNamespace: chain, - agent: '0x0000000000000000000000000000000000005678', + try { + const rpc = createPublicClient({ + transport: http(endpoint, { timeout: 12000, retryCount: 1 }), }); - entry.presets.push({ - category: preset.constant, - readiness: generated.readiness, - assets: generated.rules[0].assets, + const block = await rpc.getBlockNumber(); + result.block = String(block); + if ( + (await rpc.getChainId()) !== Number(def.chainNamespace.split(':')[1]) + ) + throw new Error('Destination chain mismatch'); + const read = (address, functionName, args = []) => + rpc.readContract({ + address, + abi, + functionName, + args, + blockNumber: block, + }); + const weth = getAddress(callTo(def, 'deposit(').target); + await rpc.call({ + account: PROBE, + to: weth, + data: encodeFunctionData({ abi, functionName: 'deposit' }), + value: BigInt(1), + blockNumber: block, + stateOverride: [ + { address: PROBE, balance: BigInt(10) ** BigInt(18) }, + ], }); - if (def.swapRouters) - for (const router of def.swapRouters) { - const result = { - app: router.app, - kind: 'dex', - router: router.address, - pairs: [], - }; - entry.protocols.push(result); + result.wrapsNativeEth = true; + if (preset.constant === 'DEXES') { + const swap = callTo(def, 'exactInputSingle('); + const router = getAddress(swap.target); + const usdc = getAddress( + def.allowedCalls.find( + (c) => + c.selector === 'approve(address,uint256)' && + getAddress(c.target) !== weth + ).target + ); + result.routerWeth9 = getAddress(await read(router, 'WETH9')); + if (result.routerWeth9 !== weth) + throw new Error(`router wraps into ${result.routerWeth9}`); + // Liquidity alone is not enough: thin, mispriced testnet pools can + // return nothing. Quote a dust swap at every tier instead. + result.quotes = []; + for (const fee of [100, 500, 3000, 10000]) { try { - result.factory = await read(router.address, 'factory'); - // The preset's own pair: one routed side is enough to trade into the other. - const pair = generated.rules[0].assets.map((a) => - typeof a.token === 'string' ? a.token : a.token.address + const [out] = await read( + QUOTERS[def.chainNamespace], + 'quoteExactInputSingle', + [ + { + tokenIn: weth, + tokenOut: usdc, + amountIn: QUOTE_AMOUNT, + fee, + sqrtPriceLimitX96: BigInt(0), + }, + ] ); - if (pair.length === 2) { - result.defaultPair = { - tokens: pair, - routed: pair.map((t) => - routed.some( - (c) => c.token.toLowerCase() === t.toLowerCase() - ) - ), - pools: [], - }; - for (const fee of [100, 500, 2500, 3000, 10000]) { - try { - const pool = await read(result.factory, 'getPool', [ - ...pair, - fee, - ]); - if (pool === zeroAddress) continue; - result.defaultPair.pools.push({ - fee, - pool, - liquidity: String(await read(pool, 'liquidity')), - }); - } catch (e) { - result.defaultPair.pools.push({ - fee, - error: e.shortMessage || e.message, - }); - } - } - } - for (let i = 0; i < routed.length; i++) - for (let j = i + 1; j < routed.length; j++) { - const pair = { - tokens: [routed[i].token, routed[j].token], - pools: [], - }; - result.pairs.push(pair); - for (const fee of [100, 500, 2500, 3000, 10000]) { - try { - const pool = await read(result.factory, 'getPool', [ - ...pair.tokens, - fee, - ]); - if (pool === zeroAddress) continue; - pair.pools.push({ - fee, - pool, - liquidity: String(await read(pool, 'liquidity')), - }); - } catch (e) { - pair.pools.push({ - fee, - error: e.shortMessage || e.message, - }); - } - } - } - } catch (e) { - result.error = e.shortMessage || e.message; + result.quotes.push({ fee, usdcOut: String(out) }); + } catch { + // no pool at this tier } } - const aave = def.allowedCalls.find((c) => c.app === 'Aave V3'); - if (aave) { - const result = { app: 'Aave V3', kind: 'lending', pool: aave.target }; - entry.protocols.push(result); - try { - result.reserves = await read(aave.target, 'getReservesList'); - result.routedReserves = routed.filter((c) => - result.reserves.some( - (a) => a.toLowerCase() === c.token.toLowerCase() - ) - ); - } catch (e) { - result.error = e.shortMessage || e.message; - } + if (!result.quotes.some((q) => BigInt(q.usdcOut) > BigInt(0))) + throw new Error('no WETH/USDC tier returns USDC for a dust swap'); } - const markets = [ - ...new Map( - def.allowedCalls - .filter((c) => c.budget?.kind === 'fixed-token') - .map((c) => [c.target, c]) - ).values(), - ]; - for (const market of markets) { - const result = { - app: market.app, - kind: 'market', - market: market.target, - }; - entry.protocols.push(result); - try { - result.underlying = await read(market.target, 'underlying'); - result.routed = routed.some( - (c) => c.token.toLowerCase() === result.underlying.toLowerCase() - ); - } catch (e) { - result.error = e.shortMessage || e.message; - } - } - if (preset.constant === 'STAKING') { - const strategy = '0x424246eF71b01ee33aA33aC590fd9a0855F5eFbc'; - const result = { app: 'EigenLayer', kind: 'strategy', strategy }; - entry.protocols.push(result); - try { - result.underlying = await read(strategy, 'underlyingToken'); - result.routed = routed.some( - (c) => c.token.toLowerCase() === result.underlying.toLowerCase() - ); - } catch (e) { - result.error = e.shortMessage || e.message; - } + if (preset.constant === 'LENDING') { + const pool = getAddress(callTo(def, 'supply(').target); + const reserves = (await read(pool, 'getReservesList')).map((r) => + getAddress(r) + ); + result.wethIsReserve = reserves.includes(weth); + if (!result.wethIsReserve) + throw new Error(`${weth} is not an Aave reserve`); } + console.log(`${preset.constant} ${def.chainNamespace}: compatible`); + } catch (error) { + // An RPC failure is unknown, not incompatible; both fail the check. + result.error = error.shortMessage || error.message; + process.exitCode = 1; + console.error( + `${preset.constant} ${def.chainNamespace}: ${result.error}` + ); } - } catch (e) { - entry.error = e.shortMessage || e.message; } - } - const output = process.argv[2]; - if (output) - fs.writeFileSync( - output, - JSON.stringify( - report, - (_k, v) => (typeof v === 'bigint' ? String(v) : v), - 2 - ) - ); - for (const c of report.chains) - console.log( - JSON.stringify( - { - chain: c.chain, - block: c.block, - error: c.error, - routed: c.candidates - .filter((x) => x.identityVerified) - .map((x) => x.symbol), - protocols: c.protocols, - }, - null, - 2 - ) - ); - if ( - report.chains.some( - (c) => - c.error || - c.candidates.some((x) => x.readError) || - c.protocols.some( - (p) => - p.error || - p.pairs?.some((x) => x.pools.some((y) => y.error)) || - p.defaultPair?.pools.some((y) => y.error) - ) - ) - ) - process.exitCode = 1; + const output = process.argv[2] || '/tmp/agw-preset-compatibility.json'; + fs.writeFileSync(output, JSON.stringify(report, null, 2)); + console.log( + `Read-only report: ${output}; ${ + report.cases.filter((c) => !c.error).length + }/${report.cases.length} compatible.` + ); } -main().catch((e) => { - console.error(e.shortMessage || e.message); +main().catch((error) => { + console.error(error.shortMessage || error.message); process.exitCode = 1; }); diff --git a/packages/core/scripts/check-agw-preset-routes.js b/packages/core/scripts/check-agw-preset-routes.js index 53bf5ba2c..557056373 100644 --- a/packages/core/scripts/check-agw-preset-routes.js +++ b/packages/core/scripts/check-agw-preset-routes.js @@ -123,9 +123,7 @@ async function main() { agent, chainNamespace, }); - result.pushReadiness = push.readiness; // Explicit prefunded fixture, not public preset acceptance or bridge proof. - assert.equal(push.readiness.chains[0].configurationReady, true); const fixtureRules = push.rules.map((rule) => ({ ...rule, assets: [], @@ -154,21 +152,17 @@ async function main() { }); assert(simulated.data && simulated.data !== '0x'); result.prefundedFixtureFactorySimulation = 'passed'; - result.selectedAssets = push.readiness.chains[0].selectedAssets; - // If static registry support is advertised, verify preparation against real metadata too. - if (push.readiness.ready) { - await prepareWalletRules( - runtime, - gen, - owner, - wallet, - publicRules(push.rules), - snap - ); - result.pushPreparation = 'passed'; - } else { - result.pushPreparation = 'unavailable-in-registry'; - } + result.selectedAssets = push.rules[0].assets; + // Every shipped preset is routable, so preparation must verify against real metadata. + await prepareWalletRules( + runtime, + gen, + owner, + wallet, + publicRules(push.rules), + snap + ); + result.pushPreparation = 'passed'; console.log( `${category} ${chainNamespace}: destination factory eth_call passed; Push ${result.pushPreparation}` ); diff --git a/packages/core/scripts/check-agw-route-registry.js b/packages/core/scripts/check-agw-route-registry.js index f084db1b1..c9e320bbf 100644 --- a/packages/core/scripts/check-agw-route-registry.js +++ b/packages/core/scripts/check-agw-route-registry.js @@ -163,7 +163,8 @@ async function main() { for (const preset of PRESET_CATALOG) for (const chain of preset.chains) { if (!enabled.some((c) => c.chain === chain.chainNamespace)) continue; - for (const token of chain.defaultTokens || []) { + // Presets send only native ETH; its route is the chain's gas token config. + for (const token of ['0x0000000000000000000000000000000000000000']) { const route = enabled.find( (c) => c.chain === chain.chainNamespace && diff --git a/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts index c7f4d0520..9b9effd16 100644 --- a/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/creation-errors.spec.ts @@ -22,74 +22,86 @@ const options = { beforeEach(registerFakeGeneration); afterEach(resetAgenticGenerations); -it.each(['LENDING', 'STAKING'] as const)( - 'public %s create gives a typed token-route error before any transaction', - async (category) => { - const cause = new Error( - 'Unable to infer origin chain or token symbol for token' - ); - const rt = mockRuntime(new FakeChain(), { - signer: ADDR.owner, - resolvePrc20: () => { - throw cause; - }, - }); - const sdk = new AgenticNamespaceImpl(rt); - const { rules } = agenticPresets.get(category, options); - let failure: unknown; - try { - await sdk.create('Test', { rules }); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(AgenticError); - expect(failure).toMatchObject({ - code: 'TOKEN_ROUTE_UNAVAILABLE', - cause, - details: { - ruleIndex: 0, - assetIndex: 0, - chainNamespace, - field: 'assets[0].token', +it('an edited preset with an unrouted token gives a typed token-route error before any transaction', async () => { + const cause = new Error( + 'Unable to infer origin chain or token symbol for token' + ); + const rt = mockRuntime(new FakeChain(), { + signer: ADDR.owner, + resolvePrc20: () => { + throw cause; + }, + }); + const sdk = new AgenticNamespaceImpl(rt); + const { rules } = agenticPresets.get('LENDING', options); + // Aave's faucet LINK has no Push route; presets themselves send only ETH. + const edited = { + ...rules[0], + assets: [ + { + token: '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5', + maxPerCall: BigInt(1), + maxTotal: BigInt(2), }, - userMessage: expect.stringContaining( - 'Rule 1: This token cannot be sent from Push to Ethereum Sepolia' - ), - hint: expect.stringContaining('verified PRC20 route'), - }); - expect(rt.executeMock).not.toHaveBeenCalled(); - expect(rt.atomicMock).not.toHaveBeenCalled(); + ], + }; + let failure: unknown; + try { + await sdk.create('Test', { rules: [edited] }); + } catch (error) { + failure = error; } -); + expect(failure).toBeInstanceOf(AgenticError); + expect(failure).toMatchObject({ + code: 'TOKEN_ROUTE_UNAVAILABLE', + cause, + details: { + ruleIndex: 0, + assetIndex: 0, + chainNamespace, + field: 'assets[0].token', + }, + userMessage: expect.stringContaining( + 'Rule 1: This token cannot be sent from Push to Ethereum Sepolia' + ), + hint: expect.stringContaining('verified PRC20 route'), + }); + expect(rt.executeMock).not.toHaveBeenCalled(); + expect(rt.atomicMock).not.toHaveBeenCalled(); +}); -it('reports the complete 40-condition count for duplicated DEX entries, without dropping them', async () => { +it('reports the complete 42-condition count for duplicated DEX entries, without dropping them', async () => { const rt = mockRuntime(new FakeChain(), { signer: ADDR.owner }); const sdk = new AgenticNamespaceImpl(rt); const { rules } = agenticPresets.get('DEXES', { ...options, }); const calls = rules[0].allowedCalls; - const duplicated = { ...rules[0], allowedCalls: [...calls, ...calls] }; + // The unpinned wrap cannot repeat (a bare alternative is refused first). + const duplicated = { + ...rules[0], + allowedCalls: [...calls, ...calls.filter((c) => c.pins?.length)], + }; await expect( sdk.create('Test', { rules: [duplicated] }) ).rejects.toMatchObject({ code: 'INVALID_RULE', details: { reason: 'RULE_CONSTRAINT_LIMIT', - actual: 40, + actual: 42, limit: 32, - callCount: 12, + callCount: 15, ruleIndex: 0, chainNamespace, }, - message: expect.stringContaining('40 conditions'), + message: expect.stringContaining('42 conditions'), userMessage: expect.stringContaining('Rule 1:'), }); - expect(duplicated.allowedCalls).toHaveLength(12); + expect(duplicated.allowedCalls).toHaveLength(15); expect(rt.executeMock).not.toHaveBeenCalled(); }); -it('validates stock DEX rules independently: two 20-condition rules do not become a 40-condition rule', async () => { +it('validates stock DEX rules independently: two 21-condition rules do not become a 42-condition rule', async () => { const rt = mockRuntime(new FakeChain()); for (const chain of ['eip155:11155111', 'eip155:84532'] as const) { const { rules } = agenticPresets.get('DEXES', { @@ -103,7 +115,7 @@ it('validates stock DEX rules independently: two 20-condition rules do not becom )) as UniversalRule; expect( normalized.allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) - ).toBe(20); + ).toBe(21); expect(() => validateUniversalRuleShape(normalized, rt.nowSeconds()) ).not.toThrow(); @@ -166,6 +178,32 @@ it('explains the create and native action ceilings using the available structure ).toContain('at most eight functions'); }); +it.each([9, 33])( + 'reports the eight-function Push-chain limit for %i functions, not the generic 1..32', + async (count) => { + const rt = mockRuntime(new FakeChain(), { signer: ADDR.owner }); + const sdk = new AgenticNamespaceImpl(rt); + await expect( + sdk.create('Too many', { + rules: [ + { + agent: ADDR.agent, + validUntil: 2_000_000_000, + allowedCalls: Array.from({ length: count }, (_, i) => ({ + target: `0x${(i + 16).toString(16).padStart(40, '0')}`, + selector: 'increment()' as const, + })), + }, + ], + }) + ).rejects.toMatchObject({ + code: 'CAPABILITY_UNAVAILABLE', + details: { capability: 'nativeActions' }, + userMessage: expect.stringContaining('at most eight functions'), + }); + } +); + it('preserves decoded contract failures and cancellation causes with UI messages', () => { const decodedError = { name: 'PolicyCheckReverted(ArgPinAboveMax)', @@ -203,23 +241,36 @@ it.each([ } ); -it.each([undefined, []])( - 'rejects a tokens-only rule with actionable guidance before signing (%j)', - async (allowedCalls) => { +it.each([ + ['a Push-chain rule', {}], + ['an EVM rule without tokens', { chainNamespace, assets: [] as never[] }], +])( + 'rejects %s without allowed functions before signing', + async (_name, shape) => { const rt = mockRuntime(new FakeChain(), { signer: ADDR.owner }); const sdk = new AgenticNamespaceImpl(rt); - const { rules } = agenticPresets.get('LENDING', options); - await expect( - sdk.create('Tokens only', { - rules: [{ ...rules[0], allowedCalls } as never], - }) - ).rejects.toMatchObject({ - code: 'INVALID_RULE', - details: { reason: 'ALLOWED_FUNCTION_REQUIRED', field: 'allowedCalls' }, - userMessage: expect.stringContaining( - 'Selecting tokens and limits alone does not authorize transfers' - ), - }); + for (const allowedCalls of [undefined, []]) + await expect( + sdk.create('No functions', { + rules: [ + { + agent: ADDR.agent, + validUntil: 2_000_000_000, + ...shape, + allowedCalls, + }, + ], + }) + ).rejects.toMatchObject({ + code: 'INVALID_RULE', + details: { + reason: 'ALLOWED_FUNCTION_REQUIRED', + field: 'allowedCalls', + }, + userMessage: expect.stringContaining( + 'allow transfers back to the owner' + ), + }); expect(rt.executeMock).not.toHaveBeenCalled(); expect(rt.atomicMock).not.toHaveBeenCalled(); } diff --git a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts index 25b479219..4c2ff330b 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-catalog.spec.ts @@ -1,4 +1,10 @@ -import { encodeFunctionData, parseAbi, getAddress, type Address } from 'viem'; +import { + encodeFunctionData, + parseAbi, + parseUnits, + getAddress, + zeroAddress, +} from 'viem'; import { agenticPresets } from '../presets'; import { AGENTIC } from '../constants'; import { evmPinOffset, normalizePublicRule, publicRules } from '../public-rule'; @@ -6,99 +12,188 @@ import { parseSelector } from '../codec/selectors'; import type { Rule, UniversalRule } from '../agentic.types'; import { FakeChain, ADDR } from './fake-chain'; import { mockRuntime } from './mock-runtime'; -const tokenA = ADDR.target, - tokenB = ADDR.other; -const chain = 'eip155:11155111'; -const input: import('../presets/types').ReadyPresetOptions = { - chainNamespace: chain, + +const TESTNETS = ['eip155:11155111', 'eip155:84532', 'eip155:421614'] as const; +const DEX = { + 'eip155:11155111': { + router: '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', + weth: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + usdc: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + }, + 'eip155:84532': { + router: '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4', + weth: '0x4200000000000000000000000000000000000006', + usdc: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + }, + 'eip155:421614': { + router: '0x101F443B4d1b059569D643917553c771E1b9663E', + weth: '0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', + usdc: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + }, +} as const; +const AAVE = { + 'eip155:11155111': { + pool: '0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951', + weth: '0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c', + }, + 'eip155:84532': { + pool: '0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27', + weth: '0x4200000000000000000000000000000000000006', + }, + 'eip155:421614': { + pool: '0xBfC91D59fdAA134A4ED45f7B584cAf96D7792Eff', + weth: '0x1dF462e2712496373A347f8ad10802a5E95f053D', + }, +} as const; +const cap = parseUnits('0.002', 18); +const input = (chainNamespace: (typeof TESTNETS)[number]) => ({ + chainNamespace, agent: ADDR.agent, validUntil: 2_000_000_000, - maxGasPerCall: BigInt(1), - assets: [ - { token: tokenA, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, - { token: tokenB, maxPerCall: BigInt(7), maxTotal: BigInt(14) }, - ], -}; -it('expands both token directions and exact-input/output with wallet and amount pins', () => { - const { rules } = agenticPresets.get('DEXES', input); - expect(rules).toHaveLength(1); - expect(rules[0].allowedCalls).toHaveLength(6); - const swaps = rules[0].allowedCalls.filter((c) => - c.selector.startsWith('exact') - ); - expect(swaps).toHaveLength(4); - expect(swaps[0].pins).toEqual([ - { arg: [0, 0], expected: tokenA }, - { arg: [0, 1], expected: tokenB }, - { arg: [0, 3], expected: AGENTIC.WALLET }, - { arg: [0, 4], max: BigInt(5) }, + assets: [{ token: zeroAddress, maxPerCall: cap, maxTotal: cap * BigInt(5) }], +}); +const pinTotal = (r: { allowedCalls: { pins?: unknown[] }[] }) => + r.allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0); + +it('ships only the testnet presets that ran live: Uniswap V3 and Aave V3 on three chains', () => { + expect(Object.values(AGENTIC.PRESETS.CATEGORY)).toEqual([ + 'DEXES', + 'LENDING', + 'STAKING', ]); - expect(swaps[1].pins?.at(-1)).toEqual({ arg: [0, 5], max: BigInt(5) }); - expect(swaps[2].pins?.at(-1)).toEqual({ arg: [0, 4], max: BigInt(7) }); expect( - rules[0].allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) - ).toBe(20); + agenticPresets.list().presets.map((p) => [p.constant, p.chains]) + ).toEqual([ + ['DEXES', [...TESTNETS]], + ['LENDING', [...TESTNETS]], + ['STAKING', []], + ]); + expect(agenticPresets.get('STAKING')).toEqual({ rules: [] }); + for (const category of ['DEXES', 'LENDING'] as const) + for (const rule of agenticPresets.get(category).rules) { + expect(rule.assets).toEqual([ + { + token: zeroAddress, + maxPerCall: parseUnits('0.001', 18), + maxTotal: parseUnits('0.01', 18), + }, + ]); + expect(rule.maxGasPerCall).toBe(parseUnits('20', 18)); + expect(rule).not.toHaveProperty('readiness'); + } }); -it('rejects native-token markers and oversized pin expansion', () => { - expect(() => - agenticPresets.get('DEXES', { - ...input, - assets: [...input.assets!, { token: ADDR.owner }], - }) - ).toThrow(/maximum is 32/); - expect(() => - agenticPresets.get('DEXES', { - ...input, - assets: [ - { token: '0x0000000000000000000000000000000000000000' }, - input.assets![1], +it.each(TESTNETS)( + 'DEXES %s wraps ETH and swaps WETH and Circle USDC with wallet-pinned recipients', + (chainNamespace) => { + const { router, weth, usdc } = DEX[chainNamespace]; + const r = agenticPresets.get('DEXES', input(chainNamespace)).rules[0]; + const W = getAddress(weth), + U = getAddress(usdc), + R = getAddress(router); + expect( + r.allowedCalls.map((c) => [getAddress(c.target), c.selector]) + ).toEqual([ + [W, 'deposit()'], + [W, 'approve(address,uint256)'], + [R, expect.stringMatching(/^exactInputSingle\(/)], + [R, expect.stringMatching(/^exactOutputSingle\(/)], + [U, 'approve(address,uint256)'], + [R, expect.stringMatching(/^exactInputSingle\(/)], + [R, expect.stringMatching(/^exactOutputSingle\(/)], + [W, 'withdraw(uint256)'], + ]); + const [wrap, approveWeth, sellIn, sellOut, approveUsdc, buyIn, buyOut] = + r.allowedCalls; + expect(wrap.maxValue).toBe(cap); + expect(approveWeth.pins).toEqual([ + { arg: 0, expected: R }, + { arg: 1, max: cap }, + ]); + expect(sellIn.pins).toEqual([ + { arg: [0, 0], expected: W }, + { arg: [0, 1], expected: U }, + { arg: [0, 3], expected: AGENTIC.WALLET }, + { arg: [0, 4], max: cap }, + ]); + expect(sellOut.pins?.at(-1)).toEqual({ arg: [0, 5], max: cap }); + const usdcCap = parseUnits('10', 6); + expect(approveUsdc.pins).toEqual([ + { arg: 0, expected: R }, + { arg: 1, max: usdcCap }, + ]); + expect(buyIn.pins).toEqual([ + { arg: [0, 0], expected: U }, + { arg: [0, 1], expected: W }, + { arg: [0, 3], expected: AGENTIC.WALLET }, + { arg: [0, 4], max: usdcCap }, + ]); + expect(buyOut.pins?.at(-1)).toEqual({ arg: [0, 5], max: usdcCap }); + expect(r.allowedCalls.at(-1)?.pins).toEqual([{ arg: 0, max: cap }]); + expect(pinTotal(r)).toBeLessThanOrEqual(32); + } +); +it.each(TESTNETS)( + 'LENDING %s wraps ETH into the Aave WETH reserve and pins every position to the wallet', + (chainNamespace) => { + const { pool, weth } = AAVE[chainNamespace]; + const r = agenticPresets.get('LENDING', input(chainNamespace)).rules[0]; + const W = getAddress(weth), + P = getAddress(pool); + expect( + r.allowedCalls.map((c) => [getAddress(c.target), c.selector, c.pins]) + ).toEqual([ + [W, 'deposit()', undefined], + [ + W, + 'approve(address,uint256)', + [ + { arg: 0, expected: P }, + { arg: 1, max: cap }, + ], ], - }) - ).toThrow(/ERC20/); -}); -it('defaults omitted caps to zero without introducing an unlimited approval', () => { - const r = agenticPresets.get('DEXES', { - ...input, - assets: [{ token: tokenA }, { token: tokenB }], - }).rules[0]; - expect( - r.assets?.every( - (a) => a.maxPerCall === BigInt(0) && a.maxTotal === BigInt(0) - ) - ).toBe(true); - expect( - r.allowedCalls.every((c) => c.pins?.some((p) => p.max === BigInt(0))) - ).toBe(true); -}); -it('scopes money-market approvals to their actual underlying token', () => { - const underlying = '0x4616975f7E6045FD9737e95bC527e9e14c727BD2' as Address; - const r = agenticPresets.get('LENDING', { - chainNamespace: 'eip155:84532', - assets: [{ token: underlying, maxPerCall: BigInt(2) }], - }).rules[0]; - const approvals = r.allowedCalls.filter( - (c) => c.selector === 'approve(address,uint256)' - ); - expect(approvals).toHaveLength(1); // Only mUSDbC: this mock token is not an Aave reserve. - expect(approvals.flatMap((c) => c.pins ?? [])).not.toContainEqual({ - arg: 0, - expected: getAddress('0x2F39a349A79492a70E152760ce7123A1933eCf28'), - }); -}); -it.each([ - 'eip155:1', - 'eip155:8453', - 'eip155:42161', - 'eip155:56', - 'eip155:11155111', - 'eip155:84532', - 'eip155:421614', - 'eip155:97', -] as const)( - 'normalizes every DEX call on %s to the exact ABI bytes', + ...( + [ + ['supply(address,uint256,address,uint16)', 2], + ['withdraw(address,uint256,address)', 2], + ['borrow(address,uint256,uint256,uint16,address)', 4], + ['repay(address,uint256,uint256,address)', 3], + ] as const + ).map(([selector, beneficiary]) => [ + P, + selector, + [ + { arg: 0, expected: W }, + { arg: beneficiary, expected: AGENTIC.WALLET }, + { arg: 1, max: cap }, + ], + ]), + [W, 'withdraw(uint256)', [{ arg: 0, max: cap }]], + ]); + expect(r.allowedCalls[0].maxValue).toBe(cap); + } +); +it.each( + TESTNETS.flatMap((chain) => + (['DEXES', 'LENDING'] as const).map((p) => [p, chain] as const) + ) +)( + '%s %s compiles with the deployed v6 policy within its limits', + async (preset, chainNamespace) => { + const r = agenticPresets.get(preset, input(chainNamespace)).rules[0]; + const out = (await normalizePublicRule( + publicRules([r])[0], + ADDR.owner, + mockRuntime(new FakeChain()) + )) as UniversalRule; + expect(out.allowedCalls).toHaveLength(r.allowedCalls.length); + expect(out.allowedCalls[0].maxValue).toBe(cap); + expect(out.allowedCalls.slice(1).every((c) => !c.maxValue)).toBe(true); + } +); +it.each(TESTNETS)( + 'normalizes every DEX swap on %s to the exact ABI bytes', async (chainNamespace) => { - const r = agenticPresets.get('DEXES', { ...input, chainNamespace }) - .rules[0]; + const r = agenticPresets.get('DEXES', input(chainNamespace)).rules[0]; const rt = mockRuntime(new FakeChain()); const out = (await normalizePublicRule( publicRules([r])[0], @@ -155,7 +250,7 @@ it('supports tuple/fixed-array positions but never descends into dynamic storage expect(() => evmPinOffset('0x12345678', [0, 1])).toThrow(/scalar/); }); it('tuple-authored pins re-encode using the raw-selector word indexes returned by reads', async () => { - const r = agenticPresets.get('DEXES', input).rules[0]; + const r = agenticPresets.get('DEXES', input('eip155:11155111')).rules[0]; const rt = mockRuntime(new FakeChain()); const first = (await normalizePublicRule( publicRules([r])[0], @@ -184,144 +279,15 @@ it('tuple-authored pins re-encode using the raw-selector word indexes returned b first.allowedCalls.map((c) => c.pins) ); }); -it('mutating a returned tuple path cannot change a later preset', () => { - const r = agenticPresets.get('DEXES', input).rules[0]; - (r.allowedCalls[0].pins![0].arg as number[])[1] = 99; - expect( - agenticPresets.get('DEXES', input).rules[0].allowedCalls[0].pins![0].arg - ).toEqual([0, 0]); -}); - -it('includes a curated capped token pair and exact pair pins on every DEX chain', () => { - const { rules, readiness } = agenticPresets.get('DEXES'); - expect(rules.map((r) => r.chainNamespace)).toEqual( - agenticPresets.list().presets.find((p) => p.constant === 'DEXES')!.chains - ); - for (const rule of rules) { - expect(rule).not.toHaveProperty('agent'); - expect(rule.validUntil).toBeGreaterThan(Math.floor(Date.now() / 1000)); - expect(rule.assets).toHaveLength(2); - expect( - rule.assets!.every( - (a) => - a.maxPerCall > BigInt(0) && (a.maxTotal ?? BigInt(0)) >= a.maxPerCall - ) - ).toBe(true); - expect(rule.allowedCalls).toHaveLength(6); - const swaps = rule.allowedCalls.filter((c) => - c.selector.startsWith('exact') - ); - expect(swaps).toHaveLength(4); - for (const call of swaps) { - expect(call.pins).toHaveLength(4); - expect(call.pins).toContainEqual({ - arg: [0, 3], - expected: AGENTIC.WALLET, - }); - expect(call.pins?.at(-1)?.max).toBeGreaterThan(BigInt(0)); - expect(call.label).not.toContain('select two tokens'); - } - } - expect(readiness.chains.every((c) => c.knownTokens.length >= 2)).toBe(true); - expect( - readiness.chains.every( - (c) => !c.issues.some((i) => i.code === 'TOKEN_PAIR_REQUIRED') - ) - ).toBe(true); -}); -it('keeps single-token swaps disabled and generates approvals only for the supplied asset', () => { - const rule = agenticPresets.get('DEXES', { - ...input, - assets: [input.assets![0]], - }).rules[0]; - expect(rule.assets).toEqual([input.assets![0]]); - expect(rule.allowedCalls).toHaveLength(3); - for (const call of rule.allowedCalls.slice(0, 2)) { - expect(call.pins).toContainEqual({ arg: [0, 0], expected: tokenA }); - expect(call.pins?.at(-1)?.max).toBe(BigInt(0)); - } - expect(rule.allowedCalls[2].selector).toBe('approve(address,uint256)'); - expect(rule.allowedCalls[2].target).toBe(tokenA); -}); -it('keeps chains without supplied assets discoverable and regenerates complete pairs without disabled alternatives', () => { - const { rules } = agenticPresets.get('DEXES', { - assets: input.assets!.map((a) => ({ ...a, chainNamespace: chain })), - }); - expect(rules).toHaveLength(8); - expect( - rules.find((r) => r.chainNamespace === chain)?.allowedCalls - ).toHaveLength(6); - for (const r of rules.filter((r) => r.chainNamespace !== chain)) { - expect(r.assets).toEqual([]); - expect(r.allowedCalls).toHaveLength(2); - expect(r.allowedCalls.every((c) => c.pins?.at(-1)?.max === BigInt(0))).toBe( - true - ); - } - const preview = agenticPresets.get('DEXES', { - chainNamespace: chain, - assets: [], +it('mutating a returned rule cannot change a later preset', () => { + const r = agenticPresets.get('DEXES', input('eip155:84532')).rules[0]; + (r.allowedCalls[2].pins![0].arg as number[])[1] = 99; + r.allowedCalls[0].maxValue = BigInt(0); + r.assets![0].maxPerCall = BigInt(0); + const again = agenticPresets.get('DEXES', { + chainNamespace: 'eip155:84532', }).rules[0]; - const ready = agenticPresets.get('DEXES', input).rules[0]; - expect(preview.allowedCalls).toHaveLength(2); - expect(ready.allowedCalls).toHaveLength(6); - expect( - ready.allowedCalls.every((c) => !c.label?.includes('select two tokens')) - ).toBe(true); -}); - -it.each(['eip155:1', 'eip155:8453', 'eip155:42161', 'eip155:56'] as const)( - 'mainnet %s templates preserve one rule per chain, selectors and wallet pins', - (chainNamespace) => { - for (const category of ['DEXES', 'LENDING'] as const) { - const { rules } = agenticPresets.get(category, { chainNamespace }); - expect(rules).toHaveLength(1); - expect(rules[0].chainNamespace).toBe(chainNamespace); - expect(rules[0].allowedCalls.length).toBeLessThanOrEqual(32); - expect(rules[0].allowedCalls.every((c) => c.selector.includes('('))).toBe( - true - ); - expect( - rules[0].allowedCalls.some((c) => - c.pins?.some((p) => p.expected === AGENTIC.WALLET) - ) - ).toBe(true); - expect(rules[0]).not.toHaveProperty('agent'); - } - } -); - -it('mainnet Lido uses the selected token units and excludes unbounded withdrawal/claim methods', () => { - const stETH = getAddress('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'); - const { rules, readiness } = agenticPresets.get('STAKING', { - chainNamespace: 'eip155:1', - assets: [{ token: stETH, maxPerCall: BigInt(5), maxTotal: BigInt(10) }], - }); - const calls = rules[0].allowedCalls; - expect( - calls.find((c) => c.selector === 'wrap(uint256)')?.pins - ).toContainEqual({ arg: 0, max: BigInt(5) }); - expect(calls.some((c) => c.selector === 'submit(address)')).toBe(false); - expect( - calls.some( - (c) => - c.selector.startsWith('requestWithdrawals') || - c.selector.startsWith('claimWithdraw') - ) - ).toBe(false); - expect(readiness.chains[0].excludedCalls).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - selector: 'requestWithdrawals(uint256[],address)', - reason: 'DYNAMIC_AMOUNT', - }), - expect.objectContaining({ - selector: 'claimWithdrawal(uint256)', - reason: 'POSITION_AMOUNT', - }), - ]) - ); - expect( - calls.filter((c) => c.selector === 'approve(address,uint256)') - ).toHaveLength(2); + expect(again.allowedCalls[2].pins![0].arg).toEqual([0, 0]); + expect(again.allowedCalls[0].maxValue).toBe(parseUnits('0.001', 18)); + expect(again.assets![0].maxPerCall).toBe(parseUnits('0.001', 18)); }); diff --git a/packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts index 5e72d2bd4..f3bd97467 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-default-policy.spec.ts @@ -1,168 +1,38 @@ -import { getAddress, parseEther, zeroAddress } from 'viem'; +import { parseUnits, zeroAddress } from 'viem'; import { agenticPresets } from '../presets'; -import { defaultPresetAsset } from '../presets/default-policy'; -import type { Rule } from '../agentic.types'; -import { ADDR } from './fake-chain'; - -const chainNamespace = 'eip155:11155111' as const; -const agent = ADDR.agent; - -it.each(['DEXES', 'LENDING', 'STAKING'] as const)( - 'an agent alone produces typed, locally complete %s rules on all catalog chains', - (category) => { - const { rules, readiness } = agenticPresets.get(category, { agent }); - const typed: Rule[] = rules; - expect(typed.length).toBeGreaterThan(0); - expect(readiness.chains.every((c) => c.configurationReady)).toBe(true); - for (const rule of typed) { - expect(rule.agent).toBe(agent); - expect(rule.maxGasPerCall).toBe(parseEther('20')); - expect(rule.validUntil).toBeGreaterThan(Math.floor(Date.now() / 1000)); - expect(rule.assets?.every((a) => a.maxPerCall > BigInt(0))).toBe(true); - } - } -); - -it('generates one expiry seven days from this get call, refreshing on the next call', () => { - const clock = jest.spyOn(Date, 'now'); - try { - const now = 1_800_000_000; - clock.mockReturnValue(now * 1000); - const first = agenticPresets.get('DEXES', { agent }); - expect(new Set(first.rules.map((r) => r.validUntil))).toEqual( - new Set([now + 604800]) - ); - clock.mockReturnValue((now + 60) * 1000); - expect(agenticPresets.get('DEXES', { agent }).rules[0].validUntil).toBe( - now + 60 + 604800 - ); - expect(first.rules[0].validUntil).toBe(now + 604800); - } finally { - clock.mockRestore(); - } -}); - -it('uses verified raw-unit limits for stablecoins with different decimals', () => { - const mainnet = agenticPresets.get('DEXES', { - agent, - chainNamespace: 'eip155:56', - }).rules[0]; - const testnet = agenticPresets.get('DEXES', { - agent, - chainNamespace: 'eip155:97', - }).rules[0]; - expect(mainnet.assets?.[1]).toMatchObject({ - maxPerCall: BigInt('10000000000000000000'), - maxTotal: BigInt('100000000000000000000'), - }); - expect(testnet.assets?.[1]).toMatchObject({ - maxPerCall: BigInt('10000000'), - maxTotal: BigInt('100000000'), - }); - const aave = agenticPresets.get('LENDING', { - agent, - chainNamespace: 'eip155:1', - }).rules[0]; - expect(aave.assets?.[1]).toMatchObject({ - token: getAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'), - maxPerCall: BigInt('10000000'), - maxTotal: BigInt('100000000'), - }); -}); - -it('uses token quantities for native, wrapped-native and LINK defaults', () => { - const eth = agenticPresets.get('STAKING', { - agent, - chainNamespace: 'eip155:1', - }).rules[0]; - expect(eth.assets?.[0]).toEqual({ +import { + defaultPresetAsset, + PRESET_GAS_BUDGETS, + PRESET_VALIDITY_SECONDS, +} from '../presets/default-policy'; + +const chainNamespace = 'eip155:84532' as const; +it('defaults to a small ETH budget, a 20 PC gas ceiling and seven days', () => { + const before = Math.floor(Date.now() / 1000); + const [rule] = agenticPresets.get('LENDING', { chainNamespace }).rules; + expect(rule.assets).toEqual([defaultPresetAsset()]); + expect(defaultPresetAsset()).toEqual({ token: zeroAddress, - maxPerCall: parseEther('0.001'), - maxTotal: parseEther('0.01'), - }); - expect( - eth.allowedCalls.find((c) => c.selector === 'submit(address)')?.maxValue - ).toBe(parseEther('0.001')); - const link = agenticPresets.get('LENDING', { agent, chainNamespace }) - .rules[0]; - expect(link.assets?.[0]).toMatchObject({ - maxPerCall: parseEther('1'), - maxTotal: parseEther('10'), - }); - const bnb = agenticPresets.get('DEXES', { - agent, - chainNamespace: 'eip155:97', - }).rules[0]; - expect(bnb.assets?.[0]).toMatchObject({ - maxPerCall: parseEther('0.01'), - maxTotal: parseEther('0.1'), + maxPerCall: parseUnits('0.001', 18), + maxTotal: parseUnits('0.01', 18), }); -}); - -it('preserves explicit expiry and gas, including a zero gas ceiling', () => { - const validUntil = Math.floor(Date.now() / 1000) + 600; - const disabled = agenticPresets.get('LENDING', { - agent, - chainNamespace, - validUntil, - maxGasPerCall: BigInt(0), - }); - expect(disabled.rules[0].validUntil).toBe(validUntil); - expect(disabled.rules[0].maxGasPerCall).toBe(BigInt(0)); - expect(disabled.readiness.ready).toBe(false); - expect(disabled.readiness.chains[0].issues.map((i) => i.code)).toEqual( - expect.arrayContaining(['GAS_BUDGET_REQUIRED', 'BRIDGE_ASSET_UNAVAILABLE']) + expect(rule.maxGasPerCall).toBe(PRESET_GAS_BUDGETS[chainNamespace]); + expect(PRESET_GAS_BUDGETS[chainNamespace]).toBe(parseUnits('20', 18)); + expect(rule.validUntil).toBeGreaterThanOrEqual( + before + PRESET_VALIDITY_SECONDS ); - expect( - agenticPresets.get('LENDING', { - agent, - chainNamespace, - maxGasPerCall: BigInt(123), - }).rules[0].maxGasPerCall - ).toBe(BigInt(123)); }); - -it('never invents caps for explicit assets, even when their address is a default', () => { - const defaults = agenticPresets.get('LENDING', { agent, chainNamespace }) - .rules[0]; - const token = defaults.assets?.[0].token; - if (!token) throw new Error('missing default asset'); - const supplied = agenticPresets.get('LENDING', { - agent, - chainNamespace, - assets: [{ token }], - }); - expect(supplied.rules[0].assets).toEqual([ - { token, maxPerCall: BigInt(0), maxTotal: BigInt(0) }, - ]); - expect(supplied.readiness.ready).toBe(false); - const totalZero = agenticPresets.get('LENDING', { - agent, - chainNamespace, - assets: [{ token, maxPerCall: BigInt(5), maxTotal: BigInt(0) }], - }); - expect(totalZero.rules[0].assets?.[0].maxTotal).toBe(BigInt(0)); - expect(totalZero.readiness.chains[0].zeroBridgeLimits).toEqual([token]); +it('preserves an explicit gas budget, including zero', () => { + for (const maxGasPerCall of [BigInt(0), parseUnits('5', 18)]) + expect( + agenticPresets.get('DEXES', { chainNamespace, maxGasPerCall }).rules[0] + .maxGasPerCall + ).toBe(maxGasPerCall); }); - -it('does not grant the default tokens on a different chain when assets is explicit', () => { - const res = agenticPresets.get('LENDING', { - agent, - assets: [{ chainNamespace, token: ADDR.other, maxPerCall: BigInt(5) }], - }); +it('returns fresh default assets so callers cannot change the policy', () => { + const a = agenticPresets.get('DEXES', { chainNamespace }).rules[0]; + a.assets![0].maxTotal = BigInt(1); expect( - res.rules - .filter((r) => r.chainNamespace !== chainNamespace) - .every((r) => r.assets?.length === 0) - ).toBe(true); - expect(res.readiness.ready).toBe(false); -}); - -it('refuses unreviewed default tokens instead of inferring permissions from symbols', () => { - expect(() => defaultPresetAsset('eip155:999', ADDR.other)).toThrow( - /No reviewed default spending policy/ - ); - expect(() => defaultPresetAsset(chainNamespace, ADDR.other)).toThrow( - /No reviewed default spending policy/ - ); + agenticPresets.get('DEXES', { chainNamespace }).rules[0].assets![0].maxTotal + ).toBe(parseUnits('0.01', 18)); }); diff --git a/packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts deleted file mode 100644 index d84126609..000000000 --- a/packages/core/src/lib/agentic/__tests__/preset-default-tokens.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { getAddress } from 'viem'; -import { agenticPresets } from '../presets'; -import { buildPresetApi } from '../presets'; -import type { PresetDefinition } from '../presets/catalog'; -import { ADDR } from './fake-chain'; - -const options = { - agent: ADDR.agent, - validUntil: 2_000_000_000, - maxGasPerCall: BigInt(1), -}; - -it.each(['DEXES', 'LENDING', 'STAKING'] as const)( - '%s includes destination tokens independently on every supported network within policy limits', - (category) => { - const result = agenticPresets.get(category, options); - expect(result.rules.length).toBeGreaterThan(0); - for (const rule of result.rules) { - expect(rule.assets!.length).toBeGreaterThan(0); - expect(rule.assets!.length).toBeLessThanOrEqual(8); - expect(rule.allowedCalls.length).toBeLessThanOrEqual(32); - expect( - rule.allowedCalls.reduce((n, c) => n + (c.pins?.length ?? 0), 0) - ).toBeLessThanOrEqual(32); - expect( - rule.assets!.every( - (a) => - a.maxPerCall > BigInt(0) && - (a.maxTotal ?? BigInt(0)) >= a.maxPerCall - ) - ).toBe(true); - } - expect(result.readiness.chains.every((c) => c.configurationReady)).toBe( - true - ); - expect(result.readiness.chains.every((c) => c.configurationReady)).toBe( - true - ); - } -); - -it('selects protocol-specific testnet tokens rather than reusing the DEX pair for lending', () => { - const lending = agenticPresets.get('LENDING', { - chainNamespace: 'eip155:11155111', - }).rules[0]; - const dex = agenticPresets.get('DEXES', { chainNamespace: 'eip155:11155111' }) - .rules[0]; - expect(lending.assets!.map((a) => a.token)).toEqual([ - getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), - ]); - expect(dex.assets!.map((a) => a.token)).toEqual([ - getAddress('0xfff9976782d46cc05630d1f6ebab18b2324d6b14'), - getAddress('0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'), - ]); - expect( - agenticPresets.get('STAKING', { chainNamespace: 'eip155:11155111' }) - .rules[0].assets![0].token - ).toBe(getAddress('0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9')); -}); - -it('preserves an explicit empty list instead of silently repopulating tokens', () => { - const result = agenticPresets.get('DEXES', { ...options, assets: [] }); - expect( - result.rules.every( - (r) => r.assets?.length === 0 && r.allowedCalls.length === 2 - ) - ).toBe(true); - expect( - result.readiness.chains.every((c) => - c.issues.some((i) => i.code === 'ASSETS_REQUIRED') - ) - ).toBe(true); -}); - -it('an explicit selection replaces defaults on all chains without filling other networks', () => { - const assets = [ - { - chainNamespace: 'eip155:11155111' as const, - token: ADDR.target, - maxPerCall: BigInt(2), - }, - ]; - const result = agenticPresets.get('STAKING', { ...options, assets }); - expect( - result.rules.find((r) => r.chainNamespace === 'eip155:11155111')!.assets - ).toEqual([ - { token: ADDR.target, maxPerCall: BigInt(2), maxTotal: BigInt(0) }, - ]); - expect( - result.rules.find((r) => r.chainNamespace === 'eip155:1')!.assets - ).toEqual([]); - expect(assets).toHaveLength(1); -}); - -it('regenerates token constraints and approvals from returned default assets with supplied caps', () => { - const request = { ...options, chainNamespace: 'eip155:11155111' as const }; - const preview = agenticPresets.get('LENDING', request); - const result = agenticPresets.get('LENDING', { - ...request, - assets: preview.rules[0].assets!.map((a) => ({ - ...a, - maxPerCall: BigInt(5), - maxTotal: BigInt(10), - })), - }); - expect(result.readiness.chains.every((c) => c.configurationReady)).toBe(true); - expect( - result.rules[0].allowedCalls.every((c) => - c.pins?.some((p) => p.max === BigInt(5)) - ) - ).toBe(true); - expect(preview.rules[0].assets![0].maxPerCall).toBe(BigInt(10) ** BigInt(18)); -}); - -it('isolates defaults from returned assets, caller mutations and input catalog mutations', () => { - const catalog: PresetDefinition[] = [ - { - constant: 'STAKING', - name: 'Fixture', - chains: [ - { - chainNamespace: 'eip155:11155111', - defaultTokens: [ - getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), - ], - allowedCalls: [ - { - target: ADDR.other, - selector: 'deposit(uint256)', - budget: { - kind: 'fixed-token', - token: getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), - amountArg: 0, - }, - }, - ], - }, - ], - }, - ]; - const api = buildPresetApi(catalog); - const first = api.get('STAKING'); - first.rules[0].assets![0].token = ADDR.other; - first.rules[0].assets!.push({ token: ADDR.agent, maxPerCall: BigInt(1) }); - first.readiness.chains[0].knownTokens.length = 0; - catalog[0].chains[0].defaultTokens = [ADDR.other]; - expect(api.get('STAKING').rules[0].assets).toEqual([ - { - token: getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), - maxPerCall: BigInt(10) ** BigInt(18), - maxTotal: BigInt(10) ** BigInt(19), - }, - ]); -}); diff --git a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts index d10a4c0a6..2f2240aa3 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-operation-limits.spec.ts @@ -1,226 +1,58 @@ import { getAddress, zeroAddress } from 'viem'; -import { agenticPresets } from '../presets'; -import { AGENTIC } from '../constants'; -import { ADDR } from './fake-chain'; -const LINK = getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'); -const WBTC = getAddress('0x29f2D40B0605204364af54EC677bD022dA425d03'); -const chainNamespace = 'eip155:11155111' as const; -const options = { - chainNamespace, - agent: ADDR.agent, - validUntil: 2_000_000_000, - maxGasPerCall: BigInt(1), -}; -const asset = (token = LINK, limit = BigInt(5)) => ({ - token, - maxPerCall: limit, - maxTotal: BigInt(10), -}); +import { applyOperationLimits, nativeAsset } from '../presets/operation-limits'; +import type { PresetCallDefinition } from '../presets/catalog'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../constants/tokens'; -it('Aave pins the selected reserve, operation amount and wallet beneficiary for every method', () => { - const { rules, readiness } = agenticPresets.get('LENDING', { - ...options, - assets: [asset(), asset(WBTC, BigInt(7))], - }); - expect(readiness.chains[0].configurationReady).toBe(true); - expect(rules).toHaveLength(1); - for (const [selector, walletArg] of [ - ['supply(address,uint256,address,uint16)', 2], - ['withdraw(address,uint256,address)', 2], - ['borrow(address,uint256,uint256,uint16,address)', 4], - ['repay(address,uint256,uint256,address)', 3], - ] as const) { - const calls = rules[0].allowedCalls.filter((c) => c.selector === selector); - expect(calls).toHaveLength(2); - for (const [i, token] of [LINK, WBTC].entries()) { - expect(calls[i].pins).toEqual( - expect.arrayContaining([ - { arg: 0, expected: token }, - { arg: 1, max: i ? BigInt(7) : BigInt(5) }, - { arg: walletArg, expected: AGENTIC.WALLET }, - ]) - ); - expect(calls[i]).not.toHaveProperty('budget'); - } - } -}); -it('default lending policy needs only the agent to become locally ready', () => { - const result = agenticPresets.get('LENDING', { chainNamespace }); - expect(result.readiness.ready).toBe(false); - expect(result.readiness.chains[0].issues.map((i) => i.code)).toEqual( - expect.arrayContaining(['AGENT_REQUIRED', 'BRIDGE_ASSET_UNAVAILABLE']) - ); - expect(result.rules[0].assets).toEqual([ +const target = getAddress('0x0000000000000000000000000000000000001234'); +it('maps the native cap onto maxValue and amount pins and leaves fixed calls unchanged', () => { + const definitions: PresetCallDefinition[] = [ + { target, selector: 'deposit()', budget: { kind: 'native-value' } }, + { + target, + selector: 'approve(address,uint256)', + pins: [{ arg: 0, expected: target }], + budget: { kind: 'native-amount', amountArg: 1 }, + }, + { + target, + selector: 'swap((address,uint256))', + budget: { kind: 'native-amount', amountArg: [0, 1] }, + }, + { + target, + selector: 'fixed(uint256)', + pins: [{ arg: 0, max: BigInt(9) }], + }, + ]; + const calls = applyOperationLimits(definitions, BigInt(7)); + expect(calls).toEqual([ + { target, selector: 'deposit()', maxValue: BigInt(7) }, + { + target, + selector: 'approve(address,uint256)', + pins: [ + { arg: 0, expected: target }, + { arg: 1, max: BigInt(7) }, + ], + }, { - token: LINK, - maxPerCall: BigInt(10) ** BigInt(18), - maxTotal: BigInt(10) ** BigInt(19), + target, + selector: 'swap((address,uint256))', + pins: [{ arg: [0, 1], max: BigInt(7) }], }, + { target, selector: 'fixed(uint256)', pins: [{ arg: 0, max: BigInt(9) }] }, ]); - expect(result.rules[0].validUntil).toBeGreaterThan( - Math.floor(Date.now() / 1000) - ); - expect(result.rules[0].maxGasPerCall).toBe( - BigInt(20) * BigInt(10) ** BigInt(18) - ); - expect(result.readiness.chains[0].knownTokens).toContain(LINK); - expect( - agenticPresets.get('LENDING', { - chainNamespace, - agent: ADDR.agent, - }).readiness.chains[0].configurationReady - ).toBe(true); -}); -it('zero operation caps remain valid disabled configurations, while zero bridge totals are separately reported', () => { - const disabled = agenticPresets.get('LENDING', { - ...options, - assets: [{ token: LINK }], - }); - expect(disabled.readiness.ready).toBe(false); - expect( - disabled.readiness.chains[0].disabledCalls.every( - (c) => c.reason === 'ZERO_CAP' - ) - ).toBe(true); - const enabled = agenticPresets.get('LENDING', { - ...options, - assets: [{ token: LINK, maxPerCall: BigInt(5) }], - }); - expect(enabled.readiness.chains[0].configurationReady).toBe(true); - expect(enabled.readiness.chains[0].zeroBridgeLimits).toEqual([LINK]); - expect(enabled.rules[0].assets?.[0].maxTotal).toBe(BigInt(0)); -}); -it('does not approve or grant a positive amount for an unlisted Aave asset', () => { - const { rules, readiness } = agenticPresets.get('LENDING', { - ...options, - assets: [asset(ADDR.other)], - }); - expect(readiness.ready).toBe(false); - expect( - rules[0].allowedCalls.some((c) => c.selector.startsWith('approve')) - ).toBe(false); - expect( - rules[0].allowedCalls.every((c) => c.pins?.some((p) => p.max === BigInt(0))) - ).toBe(true); + expect(calls.some((c) => 'budget' in c)).toBe(false); + expect(definitions[1].pins).toHaveLength(1); }); -it('regenerating a token/limit replaces old operation and approval constraints without mutating the old template', () => { - const first = agenticPresets.get('LENDING', { - ...options, - assets: [asset()], - }); - const next = agenticPresets.get('LENDING', { - ...options, - assets: [asset(WBTC, BigInt(2))], - }); +it('recognizes the zero address and native moveable tokens as ETH', () => { + const max = BigInt(1); + expect(nativeAsset({ token: zeroAddress, maxPerCall: max })).toBe(true); expect( - next.rules[0].allowedCalls.some( - (c) => c.target === LINK || c.pins?.some((p) => p.expected === LINK) - ) - ).toBe(false); - expect( - next.rules[0].allowedCalls.every((c) => - c.pins?.some((p) => p.max === BigInt(2)) - ) - ).toBe(true); - expect( - first.rules[0].allowedCalls.every((c) => - c.pins?.some((p) => p.max === BigInt(5)) - ) - ).toBe(true); -}); -it('Moonwell applies underlying units and reports the share-based redeem alternative', () => { - const token = getAddress('0x4616975f7E6045FD9737e95bC527e9e14c727BD2'); - const { rules, readiness } = agenticPresets.get('LENDING', { - ...options, - chainNamespace: 'eip155:84532', - assets: [asset(token)], - }); - const calls = rules[0].allowedCalls.filter( - (c) => c.app === 'Moonwell mUSDbC' && !c.selector.startsWith('approve') - ); - expect(calls.map((c) => c.selector)).toEqual([ - 'mint(uint256)', - 'redeemUnderlying(uint256)', - 'borrow(uint256)', - 'repayBorrow(uint256)', - ]); - expect( - calls.every((c) => c.pins?.some((p) => p.arg === 0 && p.max === BigInt(5))) - ).toBe(true); - expect(readiness.chains[0].excludedCalls).toContainEqual( - expect.objectContaining({ - selector: 'redeem(uint256)', - reason: 'SHARE_AMOUNT', - alternative: 'redeemUnderlying(uint256)', + nativeAsset({ + token: MOVEABLE_TOKEN_CONSTANTS.ARBITRUM_SEPOLIA.ETH, + maxPerCall: max, }) - ); -}); -it('EigenLayer pins the chosen token and deposit amount', () => { - const { rules } = agenticPresets.get('STAKING', { - ...options, - assets: [asset()], - }); - const deposit = rules[0].allowedCalls.find((c) => - c.selector.startsWith('depositIntoStrategy') - )!; - expect(deposit.pins).toEqual([ - { arg: 1, expected: LINK }, - { arg: 2, max: BigInt(5) }, - ]); -}); -it('Lido distinguishes stETH, wstETH and native value units', () => { - const stETH = getAddress('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'), - wstETH = getAddress('0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'); - const { rules } = agenticPresets.get('STAKING', { - ...options, - chainNamespace: 'eip155:1', - assets: [ - asset(stETH, BigInt(2)), - asset(wstETH, BigInt(3)), - asset(zeroAddress, BigInt(4)), - ], - }); - expect( - rules[0].allowedCalls.find((c) => c.selector === 'wrap(uint256)')?.pins - ).toEqual([{ arg: 0, max: BigInt(2) }]); - expect( - rules[0].allowedCalls.find((c) => c.selector === 'unwrap(uint256)')?.pins - ).toEqual([{ arg: 0, max: BigInt(3) }]); - expect( - rules[0].allowedCalls.find((c) => c.selector === 'submit(address)') - ?.maxValue - ).toBe(BigInt(4)); -}); -it('readiness is per chain and cannot be mutated to change later results', () => { - const res = agenticPresets.get('LENDING', { - agent: options.agent, - validUntil: options.validUntil, - maxGasPerCall: options.maxGasPerCall, - assets: [{ ...asset(), chainNamespace }], - }); - expect(res.readiness.ready).toBe(false); - expect( - res.readiness.chains.find((c) => c.chainNamespace === chainNamespace) - ?.configurationReady ).toBe(true); - const expected = agenticPresets.get('LENDING', { chainNamespace }).readiness; - res.readiness.chains.forEach((c) => { - c.knownTokens.length = 0; - c.disabledCalls.length = 0; - }); - expect(agenticPresets.get('LENDING', { chainNamespace }).readiness).toEqual( - expected - ); -}); -it('refuses an overlarge token expansion without dropping restrictions', () => { - expect(() => - agenticPresets.get('LENDING', { - ...options, - assets: [ - asset(), - asset(WBTC), - asset(getAddress('0x88541670E55cC00bEEFD87eB59EDd1b7C511AC9a')), - ], - }) - ).toThrow(/maximum is 32/); + expect(nativeAsset({ token: target, maxPerCall: max })).toBe(false); }); diff --git a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts index c64ccee59..73ff0e81c 100644 --- a/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/preset-routes.spec.ts @@ -1,153 +1,43 @@ +import { getAddress } from 'viem'; import { agenticPresets } from '../presets'; -import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; -import { MOVEABLE_TOKEN_CONSTANTS } from '../../constants/tokens'; -import { ADDR } from './fake-chain'; +import { PUSH_NETWORK } from '../../constants/enums'; +import { SYNTHETIC_PUSH_ERC20 } from '../../constants/chain'; +import { resolveAgenticPrc20 } from '../token-routes'; +import { assetAddress } from '../presets/operation-limits'; -const options = { chainNamespace: CHAIN.ETHEREUM_SEPOLIA, agent: ADDR.agent }; -it('reports unmapped source tokens without silently changing the default Push grant', () => { - const res = agenticPresets.get('LENDING', options); - expect(res.readiness.ready).toBe(false); - const state = res.readiness.chains[0]; - expect(state.configurationReady).toBe(true); - expect(state.issues).toContainEqual( - expect.objectContaining({ - code: 'BRIDGE_ASSET_UNAVAILABLE', - token: '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5', - }) +// Presets ship only what Push can send today, so this offline check replaces +// the removed readiness report: every default asset resolves to a Push PRC20. +const GAS_PRC20 = { + 'eip155:11155111': 'pETH', + 'eip155:84532': 'pETH_BASE', + 'eip155:421614': 'pETH_ARB', +} as const; +it.each( + agenticPresets + .list() + .presets.flatMap((p) => p.chains.map((c) => [p.constant, c] as const)) +)('%s %s sends only ETH, routed as the chain gas PRC20', (preset, chain) => { + const { rules } = agenticPresets.get(preset, { chainNamespace: chain }); + expect(rules[0].assets).toHaveLength(1); + const prc20 = resolveAgenticPrc20( + { chain, address: assetAddress(rules[0].assets![0]) }, + PUSH_NETWORK.TESTNET_DONUT ); - expect(res.rules[0].assets).toEqual(state.selectedAssets); - expect(res.rules[0].assets).toHaveLength(1); -}); -it('preserves assets and rejects the removed funding option instead of silently changing permissions', () => { - const result = agenticPresets.get('DEXES', options); - // WETH and Circle USDC both have exact node routes on Sepolia. - expect(result.readiness.ready).toBe(true); - expect(result.rules[0].assets).toEqual( - result.readiness.chains[0].selectedAssets + expect(prc20.address).toBe( + getAddress( + SYNTHETIC_PUSH_ERC20[PUSH_NETWORK.TESTNET_DONUT][ + GAS_PRC20[chain as keyof typeof GAS_PRC20] + ] + ) ); - result.readiness.chains[0].selectedAssets[0].maxPerCall = BigInt(0); - expect(result.rules[0].assets?.[0].maxPerCall).toBeGreaterThan(BigInt(0)); - for (const funding of ['push', 'destination', 'auto']) { +}); +it('rejects the removed funding option instead of silently changing permissions', () => { + for (const funding of ['push', 'destination', 'auto']) expect(() => agenticPresets.get('DEXES', { - ...options, + chainNamespace: 'eip155:11155111', // @ts-expect-error funding is not part of the public preset API funding, }) ).toThrow(/funding option is no longer supported/); - } -}); -it('an explicitly selected mapped source token can be ready for Push funding', () => { - const token = MOVEABLE_TOKEN_CONSTANTS.BNB_TESTNET.USDT; - const res = agenticPresets.get('DEXES', { - chainNamespace: CHAIN.BNB_TESTNET, - agent: ADDR.agent, - assets: [ - { - token: MOVEABLE_TOKEN_CONSTANTS.BNB_TESTNET.USDC, - maxPerCall: BigInt(5), - maxTotal: BigInt(10), - }, - { token, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, - ], - }); - expect(res.readiness.ready).toBe(true); - expect( - res.readiness.chains[0].bridgeAssets.every((a) => a.status === 'registered') - ).toBe(true); -}); -it('never treats a mainnet template or undeployed Push generation as executable', () => { - const mainnet = agenticPresets.get('DEXES', { - agent: ADDR.agent, - chainNamespace: CHAIN.ETHEREUM_MAINNET, - }); - expect(mainnet.readiness.ready).toBe(false); - expect(mainnet.readiness.chains[0].issues).toContainEqual( - expect.objectContaining({ code: 'DESTINATION_UNAVAILABLE' }) - ); - const missing = agenticPresets.get('LENDING', { - ...options, - network: PUSH_NETWORK.MAINNET, - }); - expect(missing.readiness.ready).toBe(false); - expect(missing.readiness.chains[0].issues).toContainEqual( - expect.objectContaining({ code: 'AGW_NETWORK_UNAVAILABLE' }) - ); -}); -it('supports the TESTNET alias, rejects invalid modes and preserves empty selections', () => { - expect( - agenticPresets.get('LENDING', { - ...options, - network: PUSH_NETWORK.TESTNET, - }).readiness - ).toEqual(agenticPresets.get('LENDING', options).readiness); - expect(() => - agenticPresets.get('LENDING', { ...options, network: 'OTHER' as never }) - ).toThrow(/network/); - const empty = agenticPresets.get('LENDING', { - ...options, - assets: [], - }); - expect(empty.readiness.chains[0].selectedAssets).toEqual([]); - expect(empty.readiness.ready).toBe(false); -}); - -it('explains empty tokens without expanding them into unrestricted permissions', () => { - const res = agenticPresets.get('LENDING', { ...options, assets: [] }); - expect(res.rules[0].assets).toEqual([]); - expect(res.readiness.ready).toBe(false); - expect(res.readiness.chains[0].issues).toContainEqual( - expect.objectContaining({ - code: 'ASSETS_REQUIRED', - message: expect.stringContaining( - 'transfers from Push are disabled, not unrestricted' - ), - }) - ); -}); -it('reports exact unavailable assets across all nine testnet preset combinations', () => { - const chains = [ - CHAIN.ETHEREUM_SEPOLIA, - CHAIN.BASE_SEPOLIA, - CHAIN.ARBITRUM_SEPOLIA, - CHAIN.BNB_TESTNET, - ]; - const routed = new Set([ - `DEXES:${CHAIN.ETHEREUM_SEPOLIA}`, - `LENDING:${CHAIN.ARBITRUM_SEPOLIA}`, - ]); - let checked = 0; - for (const category of ['DEXES', 'LENDING', 'STAKING'] as const) { - const entry = agenticPresets - .list() - .presets.find((p) => p.constant === category)!; - for (const chainNamespace of entry.chains.filter((c) => - chains.includes(c as never) - )) { - const res = agenticPresets.get(category, { - agent: ADDR.agent, - chainNamespace, - }); - expect(res.readiness.ready).toBe( - routed.has(`${category}:${chainNamespace}`) - ); - const state = res.readiness.chains[0]; - expect(res.rules[0].assets).toEqual(state.selectedAssets); - for (const asset of state.bridgeAssets.filter( - (a) => a.status === 'unavailable' - )) { - expect(state.issues).toContainEqual( - expect.objectContaining({ - code: 'BRIDGE_ASSET_UNAVAILABLE', - token: asset.token, - message: expect.stringContaining( - 'only if the selected app supports that exact token address' - ), - }) - ); - } - checked++; - } - } - expect(checked).toBe(9); }); diff --git a/packages/core/src/lib/agentic/__tests__/presets.spec.ts b/packages/core/src/lib/agentic/__tests__/presets.spec.ts index 9ea62badd..d8bc29f2b 100644 --- a/packages/core/src/lib/agentic/__tests__/presets.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/presets.spec.ts @@ -1,8 +1,9 @@ import { getAddress, zeroAddress } from 'viem'; import { AGENTIC } from '../constants'; -import { agenticPresets, buildPresetApi } from '../presets'; +import { buildPresetApi } from '../presets'; import type { PresetDefinition } from '../presets/catalog'; import type { Rule } from '../agentic.types'; +import { MOVEABLE_TOKEN_CONSTANTS } from '../../constants/tokens'; import { normalizePublicRule, publicRules } from '../public-rule'; import { FakeChain, ADDR } from './fake-chain'; import { mockRuntime } from './mock-runtime'; @@ -12,6 +13,11 @@ const target = getAddress('0x0000000000000000000000000000000000001234'); const token = getAddress('0x000000000000000000000000000000000000abcd'); const agent = getAddress('0x000000000000000000000000000000000000a999'); const until = () => Math.floor(Date.now() / 1000) + 3600; +const eth = (maxPerCall = BigInt(5), maxTotal = BigInt(10)) => ({ + token: zeroAddress, + maxPerCall, + maxTotal, +}); function catalog(): PresetDefinition[] { return [ { @@ -23,13 +29,18 @@ function catalog(): PresetDefinition[] { allowedCalls: [ { target, - selector: 'deposit(address,uint256)', + selector: 'deposit()', + budget: { kind: 'native-value' }, + }, + { + target, + selector: 'lend(address,uint256)', pins: [{ arg: 0, expected: AGENTIC.WALLET }], app: 'Fixture', - label: 'Deposit', + label: 'Lend', + budget: { kind: 'native-amount', amountArg: 1 }, }, ], - approvals: [{ spender: target, app: 'Fixture' }], }, { chainNamespace: other, @@ -45,91 +56,52 @@ function catalog(): PresetDefinition[] { }, ]; } -it('ships verified mainnet and testnet templates and keeps prediction empty', () => { - expect(Object.values(AGENTIC.PRESETS.CATEGORY)).toEqual([ - 'DEXES', - 'LENDING', - 'STAKING', - 'PREDICTION', - ]); - const entries = agenticPresets.list().presets; - expect(entries.map((p) => [p.constant, p.chains.length])).toEqual([ - ['DEXES', 8], - ['LENDING', 8], - ['STAKING', 2], - ['PREDICTION', 0], - ]); - expect( - entries - .flatMap((p) => p.chains) - .every((c) => - [ - 'eip155:1', - 'eip155:8453', - 'eip155:42161', - 'eip155:56', - 'eip155:11155111', - 'eip155:84532', - 'eip155:421614', - 'eip155:97', - ].includes(c) - ) - ).toBe(true); - expect(agenticPresets.get('PREDICTION')).toEqual({ - rules: [], - readiness: { ready: false, chains: [] }, - }); - expect(agenticPresets.get('DEXES').rules).toHaveLength(8); - expect(() => - agenticPresets.get('LENDING', { chainNamespace: 'eip155:999999' }) - ).toThrow(/no approved entry/); -}); -it('groups apps on a chain into one editable template with default expiry and omitted identity', () => { - const data = catalog(); - data[0].chains[0].allowedCalls = [ - ...data[0].chains[0].allowedCalls, - { - target: ADDR.other, - selector: 'lend(address)', - pins: [{ arg: 0, expected: AGENTIC.WALLET }], - }, - ]; - const api = buildPresetApi(data), +it('returns one editable template per chain with default ETH limits, expiry and no identity', () => { + const api = buildPresetApi(catalog()), res = api.get('DEXES'); + expect(Object.keys(res)).toEqual(['rules']); expect(res.rules.map((r) => r.chainNamespace)).toEqual([chain, other]); - expect(res.rules[0].allowedCalls).toHaveLength(2); for (const rule of res.rules) { expect(rule).not.toHaveProperty('agent'); - expect(rule).not.toHaveProperty('assets'); + expect(rule.assets).toHaveLength(1); + expect(rule.assets![0].token).toBe(zeroAddress); expect(rule.validUntil).toBeGreaterThan(Math.floor(Date.now() / 1000)); } + const max = res.rules[0].assets![0].maxPerCall; + expect(res.rules[0].allowedCalls[0].maxValue).toBe(max); + expect(res.rules[0].allowedCalls[1].pins).toEqual([ + { arg: 0, expected: AGENTIC.WALLET }, + { arg: 1, max }, + ]); expect(api.list({ type: 'CATEGORY' }).presets[0].chains).toEqual([ chain, other, ]); }); -it('returns ready Rule types with identity and expiry; routes assets and defaults missing caps to zero', () => { - const api = buildPresetApi(catalog()); - const { rules } = api.get('DEXES', { +it('returns ready Rule types with identity and expiry and places each ETH asset on its chain', () => { + const { rules } = buildPresetApi(catalog()).get('DEXES', { agent, validUntil: until(), assets: [ - { chainNamespace: chain, token }, + { chainNamespace: chain, ...eth(BigInt(2), BigInt(3)) }, { chainNamespace: other, - token: zeroAddress, - maxPerCall: BigInt(2), - maxTotal: BigInt(3), + token: MOVEABLE_TOKEN_CONSTANTS.BASE_SEPOLIA.ETH, + maxPerCall: BigInt(4), + maxTotal: BigInt(8), }, ], }); const typed: Rule[] = rules; - expect(typed[0].assets).toEqual([ - { token, maxPerCall: BigInt(0), maxTotal: BigInt(0) }, - ]); + expect(typed[0].assets).toEqual([eth(BigInt(2), BigInt(3))]); expect(typed[1].assets).toEqual([ - { token: zeroAddress, maxPerCall: BigInt(2), maxTotal: BigInt(3) }, + { + token: { ...MOVEABLE_TOKEN_CONSTANTS.BASE_SEPOLIA.ETH }, + maxPerCall: BigInt(4), + maxTotal: BigInt(8), + }, ]); + expect(typed[0].allowedCalls[0].maxValue).toBe(BigInt(2)); expect(typed.every((r) => r.agent === agent && r.validUntil > 0)).toBe(true); }); it('does not type an incomplete template as a ready rule', () => { @@ -138,72 +110,65 @@ it('does not type an incomplete template as a ready rule', () => { const ready: Rule[] = rules; expect(ready[0].agent).toBeUndefined(); }); -it('adds only spender-pinned and amount-bounded ERC20 approvals; native assets have none', () => { - const { rules } = buildPresetApi(catalog()).get('DEXES', { - chainNamespace: chain, - assets: [ - { token, maxPerCall: BigInt(5), maxTotal: BigInt(10) }, - { token: zeroAddress, maxPerCall: BigInt(1) }, - ], - }); - expect(rules).toHaveLength(1); - expect(rules[0].allowedCalls).toHaveLength(2); - expect(rules[0].allowedCalls[1]).toMatchObject({ - target: token, - selector: 'approve(address,uint256)', - pins: [ - { arg: 0, expected: target }, - { arg: 1, max: BigInt(5) }, - ], - }); - expect(rules[0].assets![0]).not.toHaveProperty('chainNamespace'); -}); it('does not mutate caller assets, returned records, or the bundled catalog', () => { const data = catalog(); const api = buildPresetApi(data); const asset = { - token: { - address: token, - symbol: 'TEST', - decimals: 6, - mechanism: 'approve' as const, - }, + token: { ...MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA.ETH }, + maxPerCall: BigInt(5), + maxTotal: BigInt(10), }; const first = api.get('DEXES', { chainNamespace: chain, assets: [asset] }); - first.rules[0].allowedCalls[0].pins![0] = { arg: 0, expected: zeroAddress }; - (first.rules[0].assets![0].token as { address: string }).address = - zeroAddress; + first.rules[0].allowedCalls[1].pins![0] = { arg: 0, expected: zeroAddress }; + (first.rules[0].assets![0].token as { symbol: string }).symbol = 'X'; api.list().presets[0].chains.length = 0; - data[0].chains[0].allowedCalls[0].target = zeroAddress; - expect(asset.token.address).toBe(token); - expect(api.get('DEXES').rules[0].allowedCalls[0]).toMatchObject({ + data[0].chains[0].allowedCalls[1].target = zeroAddress; + expect(asset.token.symbol).toBe('ETH'); + expect(api.get('DEXES').rules[0].allowedCalls[1]).toMatchObject({ target, - pins: [{ arg: 0, expected: AGENTIC.WALLET }], + pins: [{ arg: 0, expected: AGENTIC.WALLET }, { arg: 1 }], }); expect(api.list().presets[0].chains).toHaveLength(2); }); it.each([ - { assets: [{ token }] }, - { chainNamespace: chain, assets: [{ chainNamespace: other, token }] }, - { - chainNamespace: chain, - assets: [{ token }, { token: token.toUpperCase().replace('0X', '0x') }], - }, - { chainNamespace: chain, assets: [{ token, maxPerCall: BigInt(-1) }] }, - { - chainNamespace: chain, - assets: [{ token, maxTotal: BigInt(1) << BigInt(256) }], - }, - { agent: zeroAddress }, - { validUntil: 1 }, - { validUntil: 1.5 }, - { assets: [null] }, + [{ assets: [eth()] }, /needs chainNamespace/], + [ + { chainNamespace: chain, assets: [{ chainNamespace: other, ...eth() }] }, + /outside the selected preset/, + ], + [{ chainNamespace: chain, assets: [eth(), eth()] }, /duplicate/], + [ + { chainNamespace: chain, assets: [{ ...eth(), token }] }, + /only ETH from Push/, + ], + [{ chainNamespace: chain, assets: [{ token: zeroAddress }] }, /uint256/], + [ + { chainNamespace: chain, assets: [eth(BigInt(0), BigInt(1))] }, + /positive maxPerCall and maxTotal/, + ], + [ + { chainNamespace: chain, assets: [eth(BigInt(-1))] }, + /maxPerCall must be a uint256/, + ], + [ + { + chainNamespace: chain, + assets: [eth(BigInt(1), BigInt(1) << BigInt(256))], + }, + /maxTotal must be a uint256/, + ], + [{ chainNamespace: chain, assets: [] }, /Add an ETH asset/], + [{ assets: [{ chainNamespace: chain, ...eth() }] }, /Add an ETH asset/], + [{ agent: zeroAddress }, /nonzero Push address/], + [{ validUntil: 1 }, /future uint48/], + [{ validUntil: 1.5 }, /future uint48/], + [{ assets: [null] }, /must be an object/], ])( 'rejects malformed or mismatched options before returning templates (case %#)', - (options) => { + (options, error) => { expect(() => buildPresetApi(catalog()).get('DEXES', options as never) - ).toThrow(); + ).toThrow(error); } ); it('rejects unknown presets, unsupported list types and unsupported chain filters', () => { @@ -214,34 +179,14 @@ it('rejects unknown presets, unsupported list types and unsupported chain filter /no approved entry/ ); }); -it('produces separate pinned alternatives for multiple spenders', () => { - const data = catalog(); - data[0].chains[0].approvals = [ - { spender: target, app: 'One' }, - { spender: ADDR.other, app: 'Two' }, - ]; - const result = buildPresetApi(data).get('DEXES', { - chainNamespace: chain, - assets: [{ token }], - }); - expect( - result.rules[0].allowedCalls.filter( - (c) => c.selector === 'approve(address,uint256)' - ) - ).toHaveLength(2); -}); -it('enforces the 32-call ceiling including generated approvals', () => { +it('enforces the 32-call ceiling', () => { const data = catalog(); - data[0].chains[0].allowedCalls = Array.from({ length: 32 }, (_, i) => ({ + data[0].chains[0].allowedCalls = Array.from({ length: 33 }, (_, i) => ({ target, selector: `fn${i}()`, })); - const api = buildPresetApi(data); - expect( - api.get('DEXES', { chainNamespace: chain }).rules[0].allowedCalls - ).toHaveLength(32); expect(() => - api.get('DEXES', { chainNamespace: chain, assets: [{ token }] }) + buildPresetApi(data).get('DEXES', { chainNamespace: chain }) ).toThrow(/1..32/); }); it('requires full signatures and rejects duplicate function entries', () => { @@ -256,25 +201,21 @@ it('requires full signatures and rejects duplicate function entries', () => { /unpinned alternative/ ); }); -it('compiles preset spender and amount pins with the deployed v6 policy', async () => { +it('compiles preset value and amount caps with the deployed v6 policy', async () => { const { rules } = buildPresetApi(catalog()).get('DEXES', { chainNamespace: chain, agent, validUntil: until(), - assets: [{ token, maxPerCall: BigInt(5) }], + assets: [eth()], }); - const normalized = await normalizePublicRule( + const normalized = (await normalizePublicRule( publicRules(rules)[0], ADDR.other, mockRuntime(new FakeChain()) - ); - expect( - ( - normalized as import('../agentic.types').UniversalRule - ).allowedCalls[1].pins?.map((p) => p.op) - ).toEqual([0, 1]); + )) as import('../agentic.types').UniversalRule; + expect(normalized.allowedCalls[0].maxValue).toBe(BigInt(5)); + expect(normalized.allowedCalls[1].pins?.map((p) => p.op)).toEqual([0, 1]); }); - it('keeps OR alternatives EVM-only: repeated Solana instructions are refused', () => { const api = buildPresetApi([ { diff --git a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts index 19d266272..7cb5fceb9 100644 --- a/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/response-surface.spec.ts @@ -2,6 +2,7 @@ import { encodeErrorResult, encodeFunctionData, getAddress, + toFunctionSelector, type Hex, } from 'viem'; import * as core from '../../index'; @@ -189,6 +190,22 @@ describe('revert decoding', () => { }); }); + it.each([ + [ + 'InnerValueExceedsAllowance', + 'InnerValueExceedsAllowance(uint256,uint256,uint256)', + ], + ['BeneficiaryMismatch', 'BeneficiaryMismatch(address,address)'], + ])( + 'names %s, which URP encodes by selector and its ABI omits', + (name, signature) => { + const inner = toFunctionSelector(`function ${signature}`).slice(2); + expect( + decodeAgwErrorData(`0xf4270752${inner.padEnd(64, '0')}`) + ).toMatchObject({ name: `PolicyCheckReverted(${name})` }); + } + ); + it('finds revert data through error causes and viem-style messages', () => { const inner = new Error('outer', { cause: { @@ -225,7 +242,7 @@ describe('public surface', () => { expect(new AgenticRevertError('x', { cause: 'c' }).cause).toBe('c'); }); - it('exposes the wallet marker and preset API, keeping deployment wiring and codecs internal', () => { + it('exposes the wallet marker, preset API and ALL asset helper, keeping deployment wiring and codecs internal', () => { expect(CONSTANTS.AGENTIC.WALLET).toBe('agentic:wallet'); expect(Object.keys(CONSTANTS.AGENTIC).sort()).toEqual([ 'JOB', @@ -234,7 +251,9 @@ describe('public surface', () => { 'WALLET', ]); expect(Object.isFrozen(CONSTANTS.AGENTIC)).toBe(true); - expect(Object.keys(Utils.agentic)).toEqual(['presets']); + expect(Object.keys(Utils.agentic)).toEqual(['presets', 'assets']); expect(Object.keys(Utils.agentic.presets).sort()).toEqual(['get', 'list']); + expect(Object.keys(Utils.agentic.assets)).toEqual(['all']); + expect(Object.isFrozen(Utils.agentic.assets)).toBe(true); }); }); diff --git a/packages/core/src/lib/agentic/__tests__/rule-inputs.spec.ts b/packages/core/src/lib/agentic/__tests__/rule-inputs.spec.ts new file mode 100644 index 000000000..458629965 --- /dev/null +++ b/packages/core/src/lib/agentic/__tests__/rule-inputs.spec.ts @@ -0,0 +1,301 @@ +import { getAddress, parseUnits, zeroAddress } from 'viem'; +import { CHAIN } from '../../constants/enums'; +import { + MOVEABLE_TOKEN_CONSTANTS, + MOVEABLE_TOKENS, +} from '../../constants/tokens'; +import { Utils } from '../../utils'; +import { allRoutableAssets, prepareRuleInput } from '../management/rule-inputs'; +import { normalizePublicRule, publicRules } from '../public-rule'; +import type { AuthoredRule, RuleInput, UniversalRule } from '../agentic.types'; +import type { Snapshot } from '../reads/snapshot'; +import { ADDR, FakeChain } from './fake-chain'; +import { mockRuntime } from './mock-runtime'; + +const SEPOLIA = MOVEABLE_TOKEN_CONSTANTS.ETHEREUM_SEPOLIA; +const CIRCLE_SEPOLIA = getAddress('0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'); +const snap = {} as Snapshot; +const base = (over: Partial = {}): AuthoredRule => ({ + agent: ADDR.agent, + chainNamespace: 'eip155:11155111', + validUntil: 2_000_000_000, + maxGasPerCall: BigInt(1), + ...over, +}); +const prepare = ( + rule: RuleInput, + rt = mockRuntime(new FakeChain(), { signer: ADDR.owner }) +) => + prepareRuleInput( + publicRules([rule])[0], + rt, + ADDR.owner, + snap + ) as unknown as Promise; +const addressOf = (token: unknown) => + typeof token === 'string' ? token : (token as { address: string }).address; + +describe('MOVEABLE ALL', () => { + it('lists every token Push can send to Sepolia, .old Circle USDC separately', () => { + const assets = allRoutableAssets('eip155:11155111'); + expect(assets.map((a) => addressOf(a.token))).toEqual([ + zeroAddress, + SEPOLIA.USDT.address, + SEPOLIA.USDC.address, + SEPOLIA.WETH.address, + SEPOLIA.stETH.address, + CIRCLE_SEPOLIA, + ]); + expect(assets[0]).toMatchObject({ + maxPerCall: parseUnits('0.001', 18), + maxTotal: parseUnits('0.01', 18), + }); + expect(assets[5]).toEqual({ + token: CIRCLE_SEPOLIA, + maxPerCall: parseUnits('10', 6), + maxTotal: parseUnits('100', 6), + }); + }); + it.each( + Object.keys(MOVEABLE_TOKENS).filter( + (c) => c !== CHAIN.PUSH_TESTNET_DONUT && c !== CHAIN.ETHEREUM_MAINNET + ) + )('expands %s within eight routable tokens, each with a default', (chain) => { + const assets = allRoutableAssets(chain); + expect(assets.length).toBeGreaterThan(0); + expect(assets.length).toBeLessThanOrEqual(8); + expect( + assets.every((a) => a.maxPerCall > BigInt(0) && a.maxTotal! > BigInt(0)) + ).toBe(true); + }); + it('leaves out registry tokens without a Push route', () => { + const base = allRoutableAssets('eip155:84532').map((a) => + addressOf(a.token).toLowerCase() + ); + // Base Sepolia WETH is in the registry but has no PRC20. + expect(base).not.toContain('0x4200000000000000000000000000000000000006'); + expect(base).toHaveLength(4); + }); + it('is the public helper and the marker comes from the token constants', () => { + expect(Utils.agentic.assets.all('eip155:421614')).toEqual( + allRoutableAssets('eip155:421614') + ); + expect(SEPOLIA.ALL).toEqual({ kind: 'ALL', chain: CHAIN.ETHEREUM_SEPOLIA }); + expect(Object.isFrozen(SEPOLIA.ALL)).toBe(true); + }); + it('expands in a rule, and a listed token replaces its default', async () => { + const usdc = { + token: SEPOLIA.USDC, + maxPerCall: BigInt(3), + maxTotal: BigInt(9), + }; + const out = await prepare( + base({ + assets: [{ token: SEPOLIA.ALL }, usdc], + allowedCalls: [{ target: ADDR.target, selector: 'increment()' }], + }) + ); + expect(out.assets).toHaveLength(6); + expect(out.assets![2]).toBe(usdc); + expect(out.allowedCalls).toHaveLength(1); + }); + it.each([ + [ + { assets: [{ token: SEPOLIA.ALL, maxPerCall: BigInt(1) }] }, + { code: 'INVALID_RULE', details: { reason: 'ALL_WITH_LIMITS' } }, + ], + [ + { + chainNamespace: 'eip155:84532' as const, + assets: [{ token: SEPOLIA.ALL }], + }, + { code: 'ASSET_CHAIN_MISMATCH' }, + ], + ])( + 'refuses ALL with limits or for another chain (case %#)', + async (over, error) => { + await expect( + prepare(base({ allowedCalls: [], ...over } as never)) + ).rejects.toMatchObject(error); + } + ); + it('refuses an unexpanded marker if normalization is reached directly', async () => { + await expect( + normalizePublicRule( + publicRules([ + base({ + assets: [{ token: SEPOLIA.ALL }], + allowedCalls: [{ target: ADDR.target, selector: 'increment()' }], + }), + ])[0], + ADDR.other, + mockRuntime(new FakeChain()) + ) + ).rejects.toThrow(/expand MOVEABLE ALL/); + }); +}); + +describe('tokens-only rules', () => { + const usdc = { + token: SEPOLIA.USDC, + maxPerCall: BigInt(5), + maxTotal: BigInt(9), + }; + it('allows each ERC20 only back to the owner, capped per transfer', async () => { + for (const allowedCalls of [undefined, []]) { + const out = await prepare(base({ assets: [usdc], allowedCalls })); + expect(out.allowedCalls).toEqual([ + { + target: getAddress(SEPOLIA.USDC.address), + selector: 'transfer(address,uint256)', + pins: [ + { arg: 0, expected: ADDR.owner }, + { arg: 1, max: BigInt(5) }, + ], + app: 'Token transfer', + label: 'Transfer to owner', + }, + ]); + expect(out).not.toHaveProperty('transferTo'); + } + }); + it('adds named recipients as opt-in alternatives and compiles for the v6 policy', async () => { + const out = await prepare( + base({ + assets: [usdc, { token: CIRCLE_SEPOLIA, maxPerCall: BigInt(2) }], + transferTo: [ADDR.other, ADDR.owner.toLowerCase() as never], + }) + ); + expect( + out.allowedCalls!.map((c) => [c.target, c.pins![0].expected, c.label]) + ).toEqual([ + [getAddress(SEPOLIA.USDC.address), ADDR.owner, 'Transfer to owner'], + [ + getAddress(SEPOLIA.USDC.address), + ADDR.other, + 'Transfer to named recipient', + ], + [CIRCLE_SEPOLIA, ADDR.owner, 'Transfer to owner'], + [CIRCLE_SEPOLIA, ADDR.other, 'Transfer to named recipient'], + ]); + const wire = (await normalizePublicRule( + out as never, + ADDR.other, + mockRuntime(new FakeChain()) + )) as UniversalRule; + expect(wire.allowedCalls.map((c) => c.pins?.map((p) => p.op))).toEqual([ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ]); + }); + it('with ALL and no apps, every routable ERC20 can go back to the owner', async () => { + const out = await prepare(base({ assets: [{ token: SEPOLIA.ALL }] })); + // Native ETH cannot be moved by a transfer rule, so ALL leaves it out here. + expect(out.assets!.map((a) => addressOf(a.token))).toEqual([ + SEPOLIA.USDT.address, + SEPOLIA.USDC.address, + SEPOLIA.WETH.address, + SEPOLIA.stETH.address, + CIRCLE_SEPOLIA, + ]); + expect(out.allowedCalls!.map((c) => getAddress(c.target))).toEqual( + out.assets!.map((a) => getAddress(addressOf(a.token))) + ); + expect( + out.allowedCalls!.every((c) => c.pins![0].expected === ADDR.owner) + ).toBe(true); + }); + it('uses the UEA origin as the owner address, and needs a recipient for a Solana owner', async () => { + const origin = getAddress('0x00000000000000000000000000000000000000cc'); + const evm = mockRuntime(new FakeChain(), { + signer: ADDR.owner, + signerIsPushNative: () => false, + signerOrigin: () => ({ chain: CHAIN.ETHEREUM_SEPOLIA, address: origin }), + }); + expect( + (await prepare(base({ assets: [usdc] }), evm)).allowedCalls![0].pins![0] + ).toEqual({ arg: 0, expected: origin }); + const sol = mockRuntime(new FakeChain(), { + signer: ADDR.owner, + signerIsPushNative: () => false, + signerOrigin: () => ({ + chain: CHAIN.SOLANA_DEVNET, + address: '11111111111111111111111111111111', + }), + }); + await expect(prepare(base({ assets: [usdc] }), sol)).rejects.toMatchObject({ + details: { reason: 'TRANSFER_RECIPIENT_REQUIRED' }, + }); + const named = await prepare( + base({ assets: [usdc], transferTo: [ADDR.other] }), + sol + ); + expect(named.allowedCalls!.map((c) => c.label)).toEqual([ + 'Transfer to named recipient', + ]); + }); + it.each([ + [ + base({ assets: [{ token: zeroAddress, maxPerCall: BigInt(1) }] }), + 'TOKENS_ONLY_NATIVE', + ], + [ + base({ + assets: [usdc], + transferTo: [ADDR.other], + allowedCalls: [{ target: ADDR.target, selector: 'increment()' }], + }), + 'TRANSFER_TO_WITH_CALLS', + ], + [ + base({ assets: [usdc], transferTo: [zeroAddress] }), + 'TRANSFER_RECIPIENT_INVALID', + ], + [ + base({ + chainNamespace: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + assets: [ + { + token: 'So11111111111111111111111111111111111111112', + maxPerCall: BigInt(1), + }, + ], + }), + 'TOKENS_ONLY_UNSUPPORTED', + ], + ])( + 'refuses what a transfer rule cannot express (case %#)', + async (rule, reason) => { + await expect(prepare(rule)).rejects.toMatchObject({ + code: 'INVALID_RULE', + details: { reason }, + }); + } + ); + it('only the connected owner can author one', async () => { + await expect( + prepare( + base({ assets: [usdc] }), + mockRuntime(new FakeChain(), { signer: ADDR.agent }) + ) + ).rejects.toMatchObject({ code: 'NOT_WALLET_OWNER' }); + }); + it('leaves rules with allowed functions and decoded rules unchanged', async () => { + const rule = publicRules([ + base({ + assets: [usdc], + allowedCalls: [{ target: ADDR.target, selector: 'increment()' }], + }), + ])[0]; + expect( + await prepareRuleInput( + rule, + mockRuntime(new FakeChain(), { signer: ADDR.owner }), + ADDR.owner, + snap + ) + ).toBe(rule); + }); +}); diff --git a/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts b/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts index cf8c79122..bb7e3de2f 100644 --- a/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/svm-internals.spec.ts @@ -3,6 +3,7 @@ import { bytesToHex, hexToBytes, numberToHex, type Hex } from 'viem'; import { decodeSvmTerms, encodeSvmTerms, + refuseFromEndPins, validateSvmTerms, SvmDataPinMode, type SvmTermsWire, @@ -112,6 +113,19 @@ describe('v5 internal SVM wire codec', () => { { token: ADDR.other, maxPerCall: BigInt(0), maxTotal: BigInt(0) }, ]; expect(decodeSvmTerms(encodeSvmTerms(t, NOW))).toEqual(t); + // Existing fromEnd rules stay readable, but new grants refuse them. + expect(() => refuseFromEndPins(t)).toThrow( + expect.objectContaining({ + code: 'INVALID_RULE', + details: { reason: 'FROM_END_PIN_UNSUPPORTED', field: 'dataPins' }, + }) + ); + expect(() => + refuseFromEndPins({ + ...t, + dataPins: t.dataPins.filter((p) => !p.fromEnd), + }) + ).not.toThrow(); }); it.each([ [ diff --git a/packages/core/src/lib/agentic/__tests__/token-routes.spec.ts b/packages/core/src/lib/agentic/__tests__/token-routes.spec.ts index 8535a1690..3a7d37957 100644 --- a/packages/core/src/lib/agentic/__tests__/token-routes.spec.ts +++ b/packages/core/src/lib/agentic/__tests__/token-routes.spec.ts @@ -3,9 +3,7 @@ import { CHAIN, PUSH_NETWORK } from '../../constants/enums'; import { SYNTHETIC_PUSH_ERC20 } from '../../constants/chain'; import type { Orchestrator } from '../../orchestrator/orchestrator'; import { createAgenticRuntime } from '../runtime'; -import { agenticPresets } from '../presets'; import { exactAgenticRoute, resolveAgenticPrc20 } from '../token-routes'; -import { ADDR } from './fake-chain'; const CIRCLE = { [CHAIN.ETHEREUM_SEPOLIA]: [ @@ -81,43 +79,3 @@ it('never matches by symbol, by another chain or on another network', () => { exactAgenticRoute(CHAIN.ETHEREUM_SEPOLIA, 'USDC', PUSH_NETWORK.TESTNET) ).toBeUndefined(); }); -it.each([ - ['DEXES', CHAIN.ETHEREUM_SEPOLIA], - ['LENDING', CHAIN.ARBITRUM_SEPOLIA], -] as const)( - '%s %s defaults are route-ready with unchanged assets and caps', - (category, chainNamespace) => { - const res = agenticPresets.get(category, { - chainNamespace, - agent: ADDR.agent, - }); - expect(res.readiness.ready).toBe(true); - const state = res.readiness.chains[0]; - expect(state.bridgeAssets.every((a) => a.status === 'registered')).toBe( - true - ); - expect(state.bridgeAssets).toContainEqual({ - token: getAddress(CIRCLE[chainNamespace][0]), - prc20: getAddress(CIRCLE[chainNamespace][1]), - status: 'registered', - }); - expect(res.rules[0].assets).toEqual(state.selectedAssets); - } -); -it('Base DEX USDC is routed while its WETH leg stays unavailable', () => { - const state = agenticPresets.get('DEXES', { - chainNamespace: CHAIN.BASE_SEPOLIA, - agent: ADDR.agent, - }).readiness.chains[0]; - expect(state.bridgeAssets).toEqual([ - { - token: '0x4200000000000000000000000000000000000006', - status: 'unavailable', - }, - { - token: getAddress(CIRCLE[CHAIN.BASE_SEPOLIA][0]), - prc20: getAddress(CIRCLE[CHAIN.BASE_SEPOLIA][1]), - status: 'registered', - }, - ]); -}); diff --git a/packages/core/src/lib/agentic/agentic.types.ts b/packages/core/src/lib/agentic/agentic.types.ts index b8c5d465b..094a133d5 100644 --- a/packages/core/src/lib/agentic/agentic.types.ts +++ b/packages/core/src/lib/agentic/agentic.types.ts @@ -4,7 +4,7 @@ import type { ArgPinWire } from './codec/pins'; */ import type { Idl } from '@coral-xyz/anchor'; import type { SvmTermsWire } from './codec/svm-terms'; -import type { MoveableToken } from '../constants/tokens'; +import type { MoveableToken, MoveableTokenSet } from '../constants/tokens'; import type { UniversalTxResponse } from '../orchestrator/orchestrator.types'; import type { ProgressEvent } from '../progress-hook/progress-hook.types'; @@ -214,6 +214,11 @@ export interface RuleAsset { maxPerCall: bigint; maxTotal?: bigint; } +/** Authoring form of a rule asset: `CONSTANTS.MOVEABLE.TOKEN..ALL` + * expands to every routable token with its default limits, so it takes no caps. */ +export type RuleAssetInput = + | RuleAsset + | { token: MoveableTokenSet; maxPerCall?: never; maxTotal?: never }; export interface Rule { agent: AgenticAddress; chainNamespace?: ForeignChainNamespace; @@ -222,7 +227,18 @@ export interface Rule { maxGasPerCall?: bigint; allowedCalls: RuleCall[]; } -export type RuleInput = Rule | DecodedSolanaRule; +/** What create, rules.add and rules.update accept. Every Rule is valid input. */ +export interface AuthoredRule extends Omit { + assets?: RuleAssetInput[]; + /** 1..8 for Push-chain rules, 1..32 for other chains. Omit (or leave empty) + * on an EVM rule with ERC20 assets to allow only transfers of those tokens + * back to the owner, plus any `transferTo` recipients. */ + allowedCalls?: RuleCall[]; + /** Tokens-only EVM rules: extra recipients the agent may transfer each asset + * to, besides the owner. Refused together with allowedCalls. */ + transferTo?: AgenticAddress[]; +} +export type RuleInput = AuthoredRule | DecodedSolanaRule; export type Spent = | { kind: 'native'; @@ -242,7 +258,7 @@ export type Spent = } | { kind: 'universal'; assets: { token: string; spent: bigint }[] }; export interface RulesRecord extends Omit { - rule: RuleInput; + rule: Rule | DecodedSolanaRule; spent: Spent; } export interface InternalCreateOptions { diff --git a/packages/core/src/lib/agentic/codec/rules.ts b/packages/core/src/lib/agentic/codec/rules.ts index 402929d5f..d3cf0b22b 100644 --- a/packages/core/src/lib/agentic/codec/rules.ts +++ b/packages/core/src/lib/agentic/codec/rules.ts @@ -21,7 +21,11 @@ import { } from './native'; import { buildSession, encodeEnvelope, type SessionWire } from './session'; import { encodeUniversalRule, type ResolvedUniversalRule } from './universal'; -import { encodeSvmTerms, type SvmTermsWire } from './svm-terms'; +import { + encodeSvmTerms, + refuseFromEndPins, + type SvmTermsWire, +} from './svm-terms'; import { SEND_OUTBOUND_SELECTOR } from '../contracts/v6'; import type { UniversalTermsWire } from './universal-terms'; @@ -147,6 +151,7 @@ export function prepareRules( AGENTIC_ERROR_CODE.INVALID_RULE, 'SVM rules require resolved wallet context and validated instruction layouts' ); + refuseFromEndPins(terms); return { rule, agent, diff --git a/packages/core/src/lib/agentic/codec/svm-terms.ts b/packages/core/src/lib/agentic/codec/svm-terms.ts index 3b0e10abd..23e909298 100644 --- a/packages/core/src/lib/agentic/codec/svm-terms.ts +++ b/packages/core/src/lib/agentic/codec/svm-terms.ts @@ -160,6 +160,19 @@ function offsetValid(fromEnd: boolean, offset: number, len: number): boolean { : offset + len <= SVM_LIMITS.ixData; } +/** Write-path only. The deployed SvmRulebook doesn't bind instruction-data + * length to a rule, so appended bytes can shift a `fromEnd` pin onto + * attacker-chosen data (Zaryab, 2026-10-23). Refused until the policy fix; + * decoding and reading existing rules still accept it. */ +export function refuseFromEndPins(t: SvmTermsWire): void { + if (t.dataPins.some((p) => p.fromEnd)) + throw new AgenticError( + AGENTIC_ERROR_CODE.INVALID_RULE, + 'Solana data pins counted from the end of the instruction data are not supported yet', + { details: { reason: 'FROM_END_PIN_UNSUPPORTED', field: 'dataPins' } } + ); +} + /** Grant-time structural rules. Chain/token/mint correctness requires resolved on-chain inputs. */ export function validateSvmTerms(t: SvmTermsWire, nowSeconds: number): void { integer(t.validUntil, 2 ** 48 - 1, 'validUntil'); diff --git a/packages/core/src/lib/agentic/constants.ts b/packages/core/src/lib/agentic/constants.ts index b5e798de1..5c5d29483 100644 --- a/packages/core/src/lib/agentic/constants.ts +++ b/packages/core/src/lib/agentic/constants.ts @@ -19,7 +19,6 @@ export const AGENTIC = Object.freeze({ DEXES: 'DEXES' as const, LENDING: 'LENDING' as const, STAKING: 'STAKING' as const, - PREDICTION: 'PREDICTION' as const, }), }), }); diff --git a/packages/core/src/lib/agentic/error-messages.ts b/packages/core/src/lib/agentic/error-messages.ts index 8c4deb9ff..db3a529b2 100644 --- a/packages/core/src/lib/agentic/error-messages.ts +++ b/packages/core/src/lib/agentic/error-messages.ts @@ -49,6 +49,23 @@ const MESSAGES: Record = { 'The agentic wallet does not have enough funds for this action.', }; +/** Tokens-only rules and MOVEABLE ALL (management/rule-inputs). */ +const REASON_MESSAGES: Readonly> = { + TOKENS_ONLY_NATIVE: + 'Native ETH and BNB cannot be transferred by a rule. Remove them, or add an app.', + TOKENS_ONLY_UNSUPPORTED: + 'Tokens alone can only be allowed on EVM networks. Add an app for this network.', + TRANSFER_RECIPIENT_REQUIRED: + 'Name at least one recipient: the owner has no address on this network.', + TRANSFER_RECIPIENT_INVALID: 'Enter a valid recipient address.', + TRANSFER_TO_WITH_CALLS: + 'Recipients apply only to rules without apps. Add the transfer as an allowed function instead.', + ALL_WITH_LIMITS: + 'All tokens uses each token’s default limits. Remove the limits, or select the tokens individually.', + ALL_TOO_MANY: 'A rule can include at most eight tokens. Select fewer tokens.', + ALL_UNAVAILABLE: 'No token can be sent from Push to this network yet.', +}; + export function agenticUserMessage( code: AgenticErrorCode, diagnostic: string, @@ -67,7 +84,9 @@ export function agenticUserMessage( } if (code !== 'INVALID_RULE') return MESSAGES[code]; if (details?.['reason'] === 'ALLOWED_FUNCTION_REQUIRED') - return 'Add at least one allowed function. Selecting tokens and limits alone does not authorize transfers.'; + return 'Add at least one allowed function, or select tokens on an EVM network to allow transfers back to the owner.'; + const reasonMessage = REASON_MESSAGES[String(details?.['reason'])]; + if (reasonMessage) return reasonMessage; if (/validUntil|expiry|expired|future.*timestamp/i.test(diagnostic)) return 'Choose an expiry date in the future.'; if (/label.*(64|length|most)/i.test(diagnostic)) @@ -146,7 +165,7 @@ export function agenticRevertUserMessage(name?: string): string { if (/PCValueExceedsCap|Gas.*Cap/.test(name ?? '')) return 'The required gas exceeds this rule’s gas budget. Review the budget before trying again.'; if ( - /ArgPinAboveMax|AmountExceedsCap|TotalSpendCapExceeded|Value.*Cap|MaxCalls|CallLimitReached|TotalNativeAmountExceeded|TotalValueExceeded/.test( + /ArgPinAboveMax|AmountExceedsCap|TotalSpendCapExceeded|Value.*Cap|InnerValueExceedsAllowance|MaxCalls|CallLimitReached|TotalNativeAmountExceeded|TotalValueExceeded/.test( name ?? '' ) ) diff --git a/packages/core/src/lib/agentic/index.ts b/packages/core/src/lib/agentic/index.ts index 396c39c22..eb2677346 100644 --- a/packages/core/src/lib/agentic/index.ts +++ b/packages/core/src/lib/agentic/index.ts @@ -8,6 +8,8 @@ export type { AgenticWallet, RuleCall as AllowedCall, RuleAsset as AssetCap, + RuleAssetInput, + AuthoredRule, Checkpoint, CheckpointKind, CreateOptions, @@ -37,8 +39,5 @@ export type { ReadyPresetOptions, PresetRuleTemplate, PresetResult, - PresetChainReadiness, - PresetConfigurationIssue, - PresetCallIssue, AgenticPresets, } from './presets/types'; diff --git a/packages/core/src/lib/agentic/management/prepare.ts b/packages/core/src/lib/agentic/management/prepare.ts index df51da54b..32b60b0e4 100644 --- a/packages/core/src/lib/agentic/management/prepare.ts +++ b/packages/core/src/lib/agentic/management/prepare.ts @@ -1,4 +1,5 @@ import { isPublicRule, normalizePublicRule } from '../public-rule'; +import { prepareRuleInput } from './rule-inputs'; import { preparePublicSvmRule } from './svm-public'; import type { SvmTermsWire } from '../codec/svm-terms'; import { getAddress, zeroAddress, type Address } from 'viem'; @@ -46,7 +47,11 @@ export async function prepareWalletRules( inputs.map(async (rule, ruleIndex) => { try { return isPublicRule(rule) - ? await normalizePublicRule(rule, wallet, runtime) + ? await normalizePublicRule( + await prepareRuleInput(rule, runtime, owner, snap), + wallet, + runtime + ) : rule; } catch (error) { throw ruleErrorContext(error, ruleIndex, rule?.chainNamespace); diff --git a/packages/core/src/lib/agentic/management/rule-inputs.ts b/packages/core/src/lib/agentic/management/rule-inputs.ts new file mode 100644 index 000000000..4fd8a1a11 --- /dev/null +++ b/packages/core/src/lib/agentic/management/rule-inputs.ts @@ -0,0 +1,310 @@ +import { + getAddress, + isAddress, + parseUnits, + zeroAddress, + type Address, +} from 'viem'; +import { CHAIN, PUSH_NETWORK, VM } from '../../constants/enums'; +import { CHAIN_INFO } from '../../constants/chain'; +import { + MOVEABLE_TOKENS, + isMoveableTokenSet, + type MoveableToken, +} from '../../constants/tokens'; +import { exactAgenticRoutes, resolveAgenticPrc20 } from '../token-routes'; +import type { + AuthoredRule, + InternalRule, + RuleAsset, + RuleAssetInput, + RuleCall, +} from '../agentic.types'; +import type { AgenticRuntime } from '../runtime'; +import type { Snapshot } from '../reads/snapshot'; +import { readOriginToken } from '../contracts/prc20-metadata'; +import { AgenticError, AGENTIC_ERROR_CODE } from '../errors'; + +/** Contract limit: assets per universal rule. */ +const MAX_ASSETS = 8; +const refuse = (message: string, reason: string, field?: string) => + new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message, { + details: { reason, ...(field ? { field } : {}) }, + }); + +// Default limits for ALL, by the SDK registry's own symbol and decimals (never +// caller metadata). Amounts are token quantities, not USD valuations. +const DEFAULT_LIMITS: Readonly> = { + USDT: ['10', '100'], + USDC: ['10', '100'], + DAI: ['10', '100'], + ETH: ['0.001', '0.01'], + WETH: ['0.001', '0.01'], + stETH: ['0.001', '0.01'], + BNB: ['0.01', '0.1'], + SOL: ['0.01', '0.1'], +}; +function withDefaultLimits( + token: MoveableToken | string, + symbol: string, + decimals: number, + chain: string +): RuleAsset { + const limits = DEFAULT_LIMITS[symbol]; + if (!limits) + throw new AgenticError( + AGENTIC_ERROR_CODE.CAPABILITY_UNAVAILABLE, + `No default limit for ${symbol} on ${chain}`, + { details: { capability: 'allTokenDefaults', dependency: symbol } } + ); + return { + token, + maxPerCall: parseUnits(limits[0], decimals), + maxTotal: parseUnits(limits[1], decimals), + }; +} + +/** Every token Push can send to `chainNamespace`, each with its default limits: + * the SDK registry plus exact node routes (Circle USDC `.old` is listed + * separately from the routed mock USDC), deduplicated by Push PRC20. */ +export function allRoutableAssets( + chainNamespace: string, + options: { network?: PUSH_NETWORK } = {} +): RuleAsset[] { + const network = options.network ?? PUSH_NETWORK.TESTNET_DONUT; + const seen = new Set(); + const out: RuleAsset[] = []; + const add = ( + token: MoveableToken | string, + address: string, + symbol: string, + decimals: number + ) => { + let prc20: Address; + try { + prc20 = resolveAgenticPrc20( + { chain: chainNamespace, address }, + network + ).address; + } catch { + return; // listed in the registry but not routable from Push + } + if (seen.has(prc20)) return; + seen.add(prc20); + out.push(withDefaultLimits(token, symbol, decimals, chainNamespace)); + }; + for (const token of MOVEABLE_TOKENS[chainNamespace as CHAIN] ?? []) + add({ ...token }, token.address, token.symbol, token.decimals); + for (const route of exactAgenticRoutes(chainNamespace, network)) + add(getAddress(route.source), route.source, route.symbol, route.decimals); + if (!out.length) + throw refuse( + `No token can be sent from Push to ${chainNamespace}`, + 'ALL_UNAVAILABLE' + ); + if (out.length > MAX_ASSETS) + throw refuse( + `ALL expands to ${out.length} tokens on ${chainNamespace}; a rule holds at most eight assets`, + 'ALL_TOO_MANY' + ); + return out; +} + +const sourceKey = (token: MoveableToken | string): string => { + const address = typeof token === 'string' ? token : token.address; + if (typeof token !== 'string' && token.mechanism === 'native') + return zeroAddress; + return isAddress(address, { strict: false }) + ? address.toLowerCase() + : address; +}; +function expandAll( + rule: AuthoredRule, + network: PUSH_NETWORK, + transferable: boolean +): RuleAsset[] | undefined { + const assets = rule.assets ?? []; + const markers = assets.filter((a) => isMoveableTokenSet(a?.token)); + if (!markers.length) return undefined; + const chain = rule.chainNamespace; + for (const marker of markers) { + if ( + marker.maxPerCall !== undefined || + (marker as RuleAssetInput).maxTotal !== undefined + ) + throw refuse( + 'ALL uses each token’s default limits. Remove maxPerCall and maxTotal, or list the tokens individually.', + 'ALL_WITH_LIMITS', + 'assets' + ); + if (!chain || (marker.token as { chain: string }).chain !== chain) + throw new AgenticError( + AGENTIC_ERROR_CODE.ASSET_CHAIN_MISMATCH, + `ALL for ${ + (marker.token as { chain: string }).chain + } does not match the rule's chain ${chain ?? 'Push Chain'}`, + { details: { field: 'assets' } } + ); + } + // Without apps the rule can only transfer ERC20s, so ALL leaves native out. + const expanded = allRoutableAssets(chain as string, { network }).filter( + (a) => !transferable || sourceKey(a.token) !== zeroAddress + ); + // An explicitly listed token replaces its default entry. + for (const asset of assets) { + if (isMoveableTokenSet(asset.token)) continue; + const explicit = asset as RuleAsset; + const key = sourceKey(explicit.token); + const i = expanded.findIndex((a) => sourceKey(a.token) === key); + if (i >= 0) expanded[i] = explicit; + else expanded.push(explicit); + } + if (expanded.length > MAX_ASSETS) + throw refuse( + `ALL plus the listed tokens make ${expanded.length} assets; a rule holds at most eight`, + 'ALL_TOO_MANY', + 'assets' + ); + return expanded; +} + +/** The owner's own EVM address: the Push EOA, or the EVM origin of its UEA. */ +function ownerEvmAddress( + runtime: AgenticRuntime, + owner: Address +): Address | undefined { + if (getAddress(runtime.signerPushAccount()) !== getAddress(owner)) + throw new AgenticError( + AGENTIC_ERROR_CODE.NOT_WALLET_OWNER, + 'tokens-only rules pin transfers to the connected owner' + ); + if (runtime.signerIsPushNative()) return getAddress(owner); + const origin = runtime.signerOrigin(); + return CHAIN_INFO[origin.chain]?.vm === VM.EVM && + isAddress(origin.address, { strict: false }) + ? getAddress(origin.address) + : undefined; +} + +async function transferCalls( + rule: AuthoredRule, + assets: RuleAsset[], + runtime: AgenticRuntime, + owner: Address, + snap: Snapshot +): Promise { + const chain = rule.chainNamespace as string; + if (assets.some((a) => sourceKey(a.token) === zeroAddress)) + throw refuse( + 'Native ETH and BNB cannot be transferred by a rule. Remove them, or add an app.', + 'TOKENS_ONLY_NATIVE', + 'assets' + ); + const recipients: { address: Address; owner: boolean }[] = []; + const ownerAddress = ownerEvmAddress(runtime, owner); + if (ownerAddress) recipients.push({ address: ownerAddress, owner: true }); + for (const [i, to] of (rule.transferTo ?? []).entries()) { + if (!isAddress(to, { strict: false }) || getAddress(to) === zeroAddress) + throw refuse( + 'Enter a valid recipient address.', + 'TRANSFER_RECIPIENT_INVALID', + `transferTo[${i}]` + ); + if (!recipients.some((r) => r.address === getAddress(to))) + recipients.push({ address: getAddress(to), owner: false }); + } + if (!recipients.length) + throw refuse( + 'Name at least one recipient in transferTo: an owner from Solana has no address on this network.', + 'TRANSFER_RECIPIENT_REQUIRED', + 'transferTo' + ); + const calls: RuleCall[] = []; + for (const asset of assets) { + // The ERC20 lives on the destination chain; a Push token names its source. + const target = + typeof asset.token === 'object' && 'prc20Address' in asset.token + ? await readOriginToken( + snap, + getAddress( + (asset.token as MoveableToken & { prc20Address: string }) + .prc20Address + ), + chain + ) + : getAddress( + typeof asset.token === 'string' ? asset.token : asset.token.address + ); + for (const recipient of recipients) + calls.push({ + target, + selector: 'transfer(address,uint256)', + pins: [ + { arg: 0, expected: recipient.address }, + { arg: 1, max: asset.maxPerCall }, + ], + app: 'Token transfer', + label: recipient.owner + ? 'Transfer to owner' + : 'Transfer to named recipient', + }); + } + return calls; +} + +/** Authoring conveniences resolved before normalization, once the owner and + * network are known: MOVEABLE ALL markers, and tokens-only EVM rules, which + * become ERC20 transfers to the owner plus any `transferTo` recipients. */ +export async function prepareRuleInput( + input: InternalRule, + runtime: AgenticRuntime, + owner: Address, + snap: Snapshot +): Promise { + const rule = input as unknown as AuthoredRule; + if ('format' in rule) return input; + const noCalls = !rule.allowedCalls?.length; + const chain = rule.chainNamespace; + const expanded = expandAll( + rule, + runtime.network, + noCalls && !!chain?.startsWith('eip155:') + ); + const assets = (expanded ?? rule.assets ?? []) as RuleAsset[]; + if (rule.transferTo !== undefined) { + if (!Array.isArray(rule.transferTo)) + throw refuse('transferTo must be an array', 'TRANSFER_RECIPIENT_INVALID'); + if (!noCalls) + throw refuse( + 'transferTo applies only to rules without allowed functions. Add the transfer as an allowed function instead.', + 'TRANSFER_TO_WITH_CALLS', + 'transferTo' + ); + if (!chain?.startsWith('eip155:')) + throw refuse( + 'transferTo is only available on EVM networks.', + 'TOKENS_ONLY_UNSUPPORTED', + 'transferTo' + ); + } + let allowedCalls = rule.allowedCalls; + if (noCalls && assets.length && chain?.startsWith('solana:')) + throw refuse( + 'A Solana rule needs an app: tokens alone cannot be transferred there.', + 'TOKENS_ONLY_UNSUPPORTED', + 'allowedCalls' + ); + if (noCalls && assets.length && chain?.startsWith('eip155:')) + allowedCalls = await transferCalls(rule, assets, runtime, owner, snap); + if ( + !expanded && + allowedCalls === rule.allowedCalls && + !('transferTo' in rule) + ) + return input; + // Spread keeps the public-input marker symbol. + const out = { ...rule, ...(expanded ? { assets: expanded } : {}) }; + delete out.transferTo; + if (allowedCalls !== undefined) out.allowedCalls = allowedCalls; + return out as unknown as InternalRule; +} diff --git a/packages/core/src/lib/agentic/management/svm-public.ts b/packages/core/src/lib/agentic/management/svm-public.ts index 96da8645d..c15c323ac 100644 --- a/packages/core/src/lib/agentic/management/svm-public.ts +++ b/packages/core/src/lib/agentic/management/svm-public.ts @@ -12,7 +12,7 @@ import { createSvmMetadataProvider } from './svm-metadata'; import { deriveAgwSvmAta, svmKey } from '../codec/svm-accounts'; import { compileSvmIdlRule } from '../codec/svm-idl'; import { svmInvalid } from '../codec/svm-terms'; -import { validateSvmTerms } from '../codec/svm-terms'; +import { refuseFromEndPins, validateSvmTerms } from '../codec/svm-terms'; import { tokenRouteUnavailable } from '../errors'; export function svmMetadata(runtime: AgenticRuntime) { @@ -140,6 +140,7 @@ export async function preparePublicSvmRule( throw svmInvalid('decoded rule omits a required wallet value account'); const terms = { ...stored, assets: caps }; validateSvmTerms(terms, runtime.nowSeconds()); + refuseFromEndPins(terms); return terms; } return compileSvmIdlRule( diff --git a/packages/core/src/lib/agentic/presets/aave-reserves.ts b/packages/core/src/lib/agentic/presets/aave-reserves.ts deleted file mode 100644 index bdd076386..000000000 --- a/packages/core/src/lib/agentic/presets/aave-reserves.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** Aave V3 reserve addresses from the official bgd-labs/aave-address-book - * snapshots reviewed October 15, 2026. Listings are not liquidity/availability guarantees. */ -import type { Address } from 'viem'; -export const AAVE_RESERVES: Readonly> = { - 'eip155:1': [ - '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', - '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', - '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - '0x6B175474E89094C44Da98b954EedeAC495271d0F', - '0x514910771AF9Ca656af840dff83E8264EcF986CA', - '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', - '0xBe9895146f7AF43049ca1c1AE358B0541Ea49704', - '0xdAC17F958D2ee523a2206206994597C13D831ec7', - '0xae78736Cd615f374D3085123A210448E74Fc6393', - '0x5f98805A4E8be255a32880FDeC7F6728C6568bA0', - '0xD533a949740bb3306d119CC777fa900bA034cd52', - '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', - '0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F', - '0xba100000625a3754423978a60c9317c58a424e3D', - '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', - '0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32', - '0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72', - '0x111111111117dC0aa78b770fA6A738034120C302', - '0x853d955aCEf822Db058eb8505911ED77F175b99e', - '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f', - '0xD33526068D116cE69F19A9ee46F0bd304F21A51f', - '0x83F20F44975D03b1b09e64809B757c47f942BEeA', - '0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6', - '0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202', - '0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0', - '0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E', - '0x6c3ea9036406852006290770BEdFcAbA0e23A0e8', - '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', - '0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38', - '0x4c9EDD5852cd905f086C759E8383e09bff1E68B3', - '0xA35b1B31Ce002FBF2058D22F30f95D405200A15b', - '0x9D39A5DE30e57443BfF2A8307A4256c8797A3497', - '0x18084fbA666a33d37592fA2633fD49a74DD93a88', - '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', - '0xdC035D45d973E3EC169d2276DDab16f1e407384F', - '0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7', - '0x8236a87084f8B84306f72007F36F2618A5634494', - '0x657e8C867D8B37dCC18fA4Caead9C45EB088C642', - '0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD', - '0x50D2C7992b802Eef16c04FeADAB310f31866a545', - '0x3b3fB9C57858EF816833dC91565EFcd85D96f634', - '0xC139190F447e929f090Edeb554D95AbB8b18aC1C', - '0x917459337CaAC939D41d7493B3999f571D20D667', - '0x14Bdc3A3AE09f5518b923b69489CBcAfB238e617', - '0x90D2af7d622ca3141efA4d8f1F24d86E5974Cc8F', - '0xC96dE26018A54D51c097160568752c4E3BD6C364', - '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c', - '0x9F56094C450763769BA0EA9Fe2876070c0fD5F77', - '0xBC6736d346a5eBC0dEbc997397912CD9b8FAe10a', - '0xD11c452fc99cF405034ee446803b6F6c1F6d5ED8', - '0xbf5495Efe5DB9ce00f80364C8B423567e58d2110', - '0x68749665FF8D2d112Fa859AA293F07A622782F38', - '0xe6A934089BBEe34F832060CE98848359883749B3', - '0x62C6E813b9589C3631Ba0Cdb013acdB8544038B7', - '0x1F84a51296691320478c98b8d77f2Bbd17D34350', - '0xE8483517077afa11A9B07f849cee2552f040d7b2', - '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', - '0x356B8d89c1e1239Cbbb9dE4815c39A1474d5BA7D', - '0xe343167631d89B6Ffc58B88d6b7fB0228795491D', - '0xAeBf0Bb9f57E89260d57f31AF34eB58657d96Ce0', - '0x3de0ff76E8b528C092d47b9DaC775931cef80F49', - '0x9Bf45ab47747F4B4dD09B3C2c73953484b4eB375', - '0xB0F70C0bD6FD87dbEb7C10dC692a2a6106817072', - '0x619D75E3b790eBC21c289f2805Bb7177A7D732E2', - '0x9db38D74a0D29380899aD354121DfB521aDb0548', - '0x59bC9FaE5D62B19d4f8d07D758047aCb9EE19d34', - ], - 'eip155:8453': [ - '0x4200000000000000000000000000000000000006', - '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22', - '0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA', - '0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452', - '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - '0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A', - '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', - '0x2416092f143378750bb29b79eD961ab195CcEea5', - '0x6Bb7a212910682DCFdbd5BCBb3e28FB4E8da10Ee', - '0xEDfa23602D0EC14714057867A78d01e94176BEA0', - '0xecAc9C5F704e954931349Da37F60E39f515c11c1', - '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42', - '0x63706e401c06ac8513145b7687A14804d17f814b', - '0x236aa50979D5f3De3Bd1Eeb40E81137F22ab794b', - '0x660975730059246A68521a3e2FBD4740173100f5', - ], - 'eip155:42161': [ - '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', - '0xf97f4df75117a78c1A5a0DBb814Af92458539FB4', - '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', - '0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f', - '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', - '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', - '0xba5DdD1f9d7F570dc94a51479a000E3BCE967196', - '0xD22a58f79e9481D1a88e00c343885A588b34b68B', - '0x5979D7b546E38E414F7E9822514be443A4800529', - '0x3F56e0c36d275367b8C502090EDF38289b3dEa0d', - '0xEC70Dcb4A1EFa46b8F2D97C310C9c4790ba5ffA8', - '0x93b346b6BC2548dA6A1E7d98E9a421B42541425b', - '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', - '0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F', - '0x912CE59144191C1204E64559FE8253a0e49E6548', - '0x35751007a407ca6FEFfE80b3cB397736D2cf4dbe', - '0x7dfF72693f6A4149b17e7C6314655f6A9F7c8B33', - '0x2416092f143378750bb29b79eD961ab195CcEea5', - '0x4186BFC76E2E237523CBC30FD220FE055156b41F', - '0x6c84a8f1c29108F47a79964b5Fe888D4f4D0dE40', - ], - 'eip155:56': [ - '0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82', - '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', - '0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c', - '0x2170Ed0880ac9A755fd29B2688956BD959F933F8', - '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - '0x55d398326f99059fF775485246999027B3197955', - '0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409', - '0x26c5e01524d2E6280A48F2c50fF6De7e52E9611C', - ], - 'eip155:11155111': [ - '0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357', - '0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5', - '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8', - '0x29f2D40B0605204364af54EC677bD022dA425d03', - '0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c', - '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', - '0x88541670E55cC00bEEFD87eB59EDd1b7C511AC9a', - '0x6d906e526a4e2Ca02097BA9d0caA3c382F52278E', - '0xc4bF5CbDaBE595361438F8c6a187bDc330539c60', - ], - 'eip155:84532': [ - '0xba50Cd2A20f6DA35D788639E581bca8d0B5d4D5f', - '0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a', - '0x54114591963CF60EF3aA63bEfD6eC263D98145a4', - '0x4200000000000000000000000000000000000006', - '0xD171b9694f7A2597Ed006D41f7509aaD4B485c4B', - '0x810D46F9a9027E28F9B01F75E2bdde839dA61115', - ], - 'eip155:421614': [ - '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', - '0x1dF462e2712496373A347f8ad10802a5E95f053D', - ], -}; diff --git a/packages/core/src/lib/agentic/presets/catalog.ts b/packages/core/src/lib/agentic/presets/catalog.ts index 263ddd924..c68a5d06c 100644 --- a/packages/core/src/lib/agentic/presets/catalog.ts +++ b/packages/core/src/lib/agentic/presets/catalog.ts @@ -1,28 +1,15 @@ -import { getAddress, type Address } from 'viem'; -import type { RuleCall, ForeignChainNamespace } from '../agentic.types'; +import { getAddress, parseUnits, type Address } from 'viem'; +import type { + ArgumentPath, + RuleCall, + ForeignChainNamespace, +} from '../agentic.types'; import type { PresetCategory } from './types'; -import { AAVE_RESERVES } from './aave-reserves'; -import { - DEX_DEFAULT_TOKENS, - LENDING_DEFAULT_TOKENS, - STAKING_DEFAULT_TOKENS, -} from './default-tokens'; +/** Caps filled from the preset's native ETH asset (its maxPerCall). */ export type PresetBudget = - | { - kind: 'token-argument'; - tokenArg: number; - amountArg: number; - tokens?: readonly Address[]; - } - | { kind: 'fixed-token'; token: Address; amountArg: number } | { kind: 'native-value' } - | { kind: 'setup' } - | { - kind: 'unsupported'; - reason: 'SHARE_AMOUNT' | 'DYNAMIC_AMOUNT' | 'POSITION_AMOUNT'; - alternative?: RuleCall['selector']; - }; + | { kind: 'native-amount'; amountArg: ArgumentPath }; export interface PresetCallDefinition extends RuleCall { budget?: PresetBudget; } @@ -30,18 +17,7 @@ export interface PresetCallDefinition extends RuleCall { /** Internal catalog: reviewed addresses and explicit calls only; never fetched at runtime. */ export interface PresetChainDefinition { chainNamespace: ForeignChainNamespace; - /** Used with the bundled permission policy only when assets is omitted. */ - defaultTokens?: readonly Address[]; allowedCalls: readonly PresetCallDefinition[]; - /** Static single-hop routers; incomplete pairs return zero-input-cap review calls. */ - swapRouters?: readonly { address: Address; app: string }[]; - /** Exact spenders that need an approval for each non-native supplied asset. */ - approvals?: readonly { - spender: Address; - app: string; - token?: Address; - tokens?: readonly Address[]; - }[]; maxGasPerCall?: bigint; } export interface PresetDefinition { @@ -49,391 +25,179 @@ export interface PresetDefinition { name: string; chains: readonly PresetChainDefinition[]; } -/** Mainnet and testnet templates from the October 15 Notion export and protocol sources. - * Read-only deployment/code evidence and exclusions: plan/agw/research/preset-catalog-2026-10-15/. - * Mainnet entries are templates only; no mainnet deployment or execution guarantee. +/** Testnet entries that ran live on Donut. Every preset sends native ETH from Push + * and wraps it on arrival; no other token is moved from Push. Circle USDC is only + * the destination-side swap token (the only WETH pools with liquidity, checked + * read-only on October 24). Evidence: plan/agw/research/. */ const address = (value: string): Address => getAddress(value.toLowerCase()); const wallet = 'agentic:wallet' as const; -const aave = (pool: Address, chain: string): PresetCallDefinition[] => - [ - ['supply(address,uint256,address,uint16)', 2, 'Supply'], - ['withdraw(address,uint256,address)', 2, 'Withdraw'], - ['borrow(address,uint256,uint256,uint16,address)', 4, 'Borrow'], - ['repay(address,uint256,uint256,address)', 3, 'Repay'], - ].map(([selector, arg, label]) => ({ - target: pool, - selector: selector as RuleCall['selector'], - pins: [{ arg: arg as number, expected: wallet }], - app: 'Aave V3', - label: label as string, - budget: { - kind: 'token-argument', - tokenArg: 0, - amountArg: 1, - tokens: AAVE_RESERVES[chain], - }, - })); -const market = ( - target: Address, - app: string, - token: Address -): PresetCallDefinition[] => - [ - 'mint(uint256)', - 'redeem(uint256)', - 'redeemUnderlying(uint256)', - 'borrow(uint256)', - 'repayBorrow(uint256)', - ].map((selector) => ({ - target, - selector: selector as RuleCall['selector'], - app, - label: selector.split('(')[0], - budget: - selector === 'redeem(uint256)' - ? { - kind: 'unsupported', - reason: 'SHARE_AMOUNT', - alternative: 'redeemUnderlying(uint256)', - } - : { kind: 'fixed-token', token, amountArg: 0 }, - })); -const sepoliaPool = address('0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951'); -const basePool = address('0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27'); -const arbitrumPool = address('0xBfC91D59fdAA134A4ED45f7B584cAf96D7792Eff'); -const moonUSDBC = address('0x876852425331a113d8E432eFFB3aC5BEf38f033a'); -const moonWETH = address('0x2F39a349A79492a70E152760ce7123A1933eCf28'); -const venusUSDT = address('0xb7526572FFE56AB9D7489838Bf2E18e3323b441A'); -const venusUSDC = address('0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7'); -const eigen = address('0x2E3D6c0744b10eb0A4e6F679F71554a39Ec47a5D'); -const stETH = address('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84'); -const wstETH = address('0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'); -const lidoQueue = address('0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1'); -const mainnetMarkets = ( - chain: string -): Pick => { - const entries = - chain === 'eip155:8453' - ? [ - [ - '0xEdc817A28E8B93B03976FBd4a3dDBc9f7D176c22', - 'Moonwell mUSDC', - '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - ], - [ - '0x628ff693426583D9a7FB391E54366292F509D457', - 'Moonwell mWETH', - '0x4200000000000000000000000000000000000006', - ], - ] - : chain === 'eip155:56' - ? [ - [ - '0xfD5840Cd36d94D7229439859C0112a4185BC0255', - 'Venus vUSDT', - '0x55d398326f99059fF775485246999027B3197955', - ], - [ - '0xecA88125a5ADbe82614ffC12D0DB554E2e2867C8', - 'Venus vUSDC', - '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - ], - ] - : []; - const controller = - chain === 'eip155:8453' - ? '0xfBb21d0380beE3312B33c4353c8936a0F13EF26C' - : '0xfD36E2c2a6789Db23113685031d7F16329158384'; - return { - allowedCalls: [ - ...entries.flatMap(([target, app, token]) => - market(address(target), app, address(token)) - ), - ...(entries.length - ? [ - { - target: address(controller), - selector: 'enterMarkets(address[])' as const, - app: chain === 'eip155:8453' ? 'Moonwell' : 'Venus', - label: 'Enter markets', - budget: { kind: 'setup' as const }, - }, - ] - : []), - ], - approvals: entries.map(([spender, app, token]) => ({ - spender: address(spender), - app, - token: address(token), - })), +/** Destination-held USDC per swap; editable in the returned rule. */ +const USDC_PER_SWAP = parseUnits('10', 6); +const SWAP_STRUCT = '(address,address,uint24,address,uint256,uint256,uint160)'; + +const wrap = (weth: Address): PresetCallDefinition => ({ + target: weth, + selector: 'deposit()', + app: 'WETH', + label: 'Wrap ETH', + budget: { kind: 'native-value' }, +}); +const unwrap = (weth: Address): PresetCallDefinition => ({ + target: weth, + selector: 'withdraw(uint256)', + app: 'WETH', + label: 'Unwrap WETH', + budget: { kind: 'native-amount', amountArg: 0 }, +}); +const approveWeth = ( + weth: Address, + spender: Address, + app: string +): PresetCallDefinition => ({ + target: weth, + selector: 'approve(address,uint256)', + pins: [{ arg: 0, expected: spender }], + app, + label: 'Approve WETH spending', + budget: { kind: 'native-amount', amountArg: 1 }, +}); + +const uniswap = ( + router: Address, + weth: Address, + usdc: Address +): PresetCallDefinition[] => { + const swap = ( + exactInput: boolean, + tokenIn: Address, + tokenOut: Address + ): PresetCallDefinition => { + const amountArg = [0, exactInput ? 4 : 5] as const; + return { + target: router, + selector: `${ + exactInput ? 'exactInputSingle' : 'exactOutputSingle' + }(${SWAP_STRUCT})`, + pins: [ + { arg: [0, 0], expected: tokenIn }, + { arg: [0, 1], expected: tokenOut }, + { arg: [0, 3], expected: wallet }, + ...(tokenIn === usdc ? [{ arg: amountArg, max: USDC_PER_SWAP }] : []), + ], + app: 'Uniswap V3', + label: `${exactInput ? 'Swap exact input' : 'Swap exact output'} (${ + tokenIn === weth ? 'WETH to USDC' : 'USDC to WETH' + })`, + ...(tokenIn === weth + ? { budget: { kind: 'native-amount' as const, amountArg } } + : {}), + }; }; + return [ + wrap(weth), + approveWeth(weth, router, 'Uniswap V3'), + swap(true, weth, usdc), + swap(false, weth, usdc), + { + target: usdc, + selector: 'approve(address,uint256)', + pins: [ + { arg: 0, expected: router }, + { arg: 1, max: USDC_PER_SWAP }, + ], + app: 'Uniswap V3', + label: 'Approve USDC spending', + }, + swap(true, usdc, weth), + swap(false, usdc, weth), + unwrap(weth), + ]; }; + +const aave = (pool: Address, weth: Address): PresetCallDefinition[] => [ + wrap(weth), + approveWeth(weth, pool, 'Aave V3'), + ...( + [ + ['supply(address,uint256,address,uint16)', 2, 'Supply'], + ['withdraw(address,uint256,address)', 2, 'Withdraw'], + ['borrow(address,uint256,uint256,uint16,address)', 4, 'Borrow'], + ['repay(address,uint256,uint256,address)', 3, 'Repay'], + ] as const + ).map( + ([selector, beneficiary, label]): PresetCallDefinition => ({ + target: pool, + selector, + pins: [ + { arg: 0, expected: weth }, + { arg: beneficiary, expected: wallet }, + ], + app: 'Aave V3', + label, + budget: { kind: 'native-amount', amountArg: 1 }, + }) + ), + unwrap(weth), +]; + export const PRESET_CATALOG: readonly PresetDefinition[] = [ { constant: 'DEXES', name: 'Popular DEXes', - chains: [ - ['eip155:1', '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', 'Uniswap V3'], - [ - 'eip155:8453', - '0x2626664c2603336E57B271c5C0b26F421741e481', - 'Uniswap V3', - ], - [ - 'eip155:42161', - '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', - 'Uniswap V3', - ], - [ - 'eip155:56', - '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4', - 'PancakeSwap V3', - ], - [ - 'eip155:11155111', - '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', - 'Uniswap V3', - ], - [ - 'eip155:84532', - '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4', - 'Uniswap V3', - ], - [ - 'eip155:421614', - '0x101F443B4d1b059569D643917553c771E1b9663E', - 'Uniswap V3', - ], + chains: ( [ - 'eip155:97', - '0x9a489505a00cE272eAa5e07Dba6491314CaE3796', - 'PancakeSwap V3', - ], - ].map(([chain, router, app]) => ({ - chainNamespace: chain as ForeignChainNamespace, - defaultTokens: DEX_DEFAULT_TOKENS[chain], - allowedCalls: [], - swapRouters: [{ address: address(router), app }], - approvals: [{ spender: address(router), app }], - })), - }, - { - constant: 'LENDING', - name: 'Lending', - chains: [ - ...( [ - ['eip155:1', '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2'], - ['eip155:8453', '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5'], - ['eip155:42161', '0x794a61358D6845594F94dc1DB02A252b5b4814aD'], - ['eip155:56', '0x6807dc923806fE8Fd134338EABCA509979a7e0cB'], - ] as const - ).map(([chainNamespace, pool]) => ({ - chainNamespace, - defaultTokens: LENDING_DEFAULT_TOKENS[chainNamespace], - allowedCalls: [ - ...aave(address(pool), chainNamespace), - ...mainnetMarkets(chainNamespace).allowedCalls, - ], - approvals: [ - { - spender: address(pool), - app: 'Aave V3', - tokens: AAVE_RESERVES[chainNamespace], - }, - ...(mainnetMarkets(chainNamespace).approvals ?? []), + 'eip155:11155111', + '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', + '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', ], - })), - - { - chainNamespace: 'eip155:11155111', - defaultTokens: LENDING_DEFAULT_TOKENS['eip155:11155111'], - allowedCalls: aave(sepoliaPool, 'eip155:11155111'), - approvals: [ - { - spender: sepoliaPool, - app: 'Aave V3', - tokens: AAVE_RESERVES['eip155:11155111'], - }, - ], - }, - { - chainNamespace: 'eip155:421614', - defaultTokens: LENDING_DEFAULT_TOKENS['eip155:421614'], - allowedCalls: aave(arbitrumPool, 'eip155:421614'), - approvals: [ - { - spender: arbitrumPool, - app: 'Aave V3', - tokens: AAVE_RESERVES['eip155:421614'], - }, - ], - }, - { - chainNamespace: 'eip155:84532', - defaultTokens: LENDING_DEFAULT_TOKENS['eip155:84532'], - allowedCalls: [ - ...aave(basePool, 'eip155:84532'), - ...market( - moonUSDBC, - 'Moonwell mUSDbC', - address('0x4616975f7E6045FD9737e95bC527e9e14c727BD2') - ), - ...market( - moonWETH, - 'Moonwell mWETH', - address('0x663A6F02d547dFd639BF689FED298712f2FB69Fb') - ), - { - target: address('0xC72ba45f2Ae134F21DAEb2b7B5fd5De0064Ee9bB'), - selector: 'enterMarkets(address[])', - app: 'Moonwell', - label: 'Enter markets', - budget: { kind: 'setup' as const }, - }, - ], - approvals: [ - { - spender: basePool, - app: 'Aave V3', - tokens: AAVE_RESERVES['eip155:84532'], - }, - { - spender: moonUSDBC, - app: 'Moonwell mUSDbC', - token: address('0x4616975f7E6045FD9737e95bC527e9e14c727BD2'), - }, - { - spender: moonWETH, - app: 'Moonwell mWETH', - token: address('0x663A6F02d547dFd639BF689FED298712f2FB69Fb'), - }, - ], - }, - { - chainNamespace: 'eip155:97', - defaultTokens: LENDING_DEFAULT_TOKENS['eip155:97'], - allowedCalls: [ - ...market( - venusUSDT, - 'Venus vUSDT', - address('0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c') - ), - ...market( - venusUSDC, - 'Venus vUSDC', - address('0x16227D60f7a0e586C66B005219dfc887D13C9531') - ), - { - target: address('0x94d1820b2D1c7c7452A163983Dc888CEC546b77D'), - selector: 'enterMarkets(address[])', - app: 'Venus', - label: 'Enter markets', - budget: { kind: 'setup' as const }, - }, + [ + 'eip155:84532', + '0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4', + '0x4200000000000000000000000000000000000006', + '0x036CbD53842c5426634e7929541eC2318f3dCF7e', ], - approvals: [ - { - spender: venusUSDT, - app: 'Venus vUSDT', - token: address('0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c'), - }, - { - spender: venusUSDC, - app: 'Venus vUSDC', - token: address('0x16227D60f7a0e586C66B005219dfc887D13C9531'), - }, + [ + 'eip155:421614', + '0x101F443B4d1b059569D643917553c771E1b9663E', + '0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', + '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', ], - }, - ], + ] as const + ).map(([chainNamespace, router, weth, usdc]) => ({ + chainNamespace, + allowedCalls: uniswap(address(router), address(weth), address(usdc)), + })), }, { - constant: 'STAKING', - name: 'Staking', - chains: [ - { - chainNamespace: 'eip155:1', - defaultTokens: STAKING_DEFAULT_TOKENS['eip155:1'], - allowedCalls: [ - { - target: stETH, - selector: 'submit(address)', - maxValue: BigInt(0), - app: 'Lido', - label: 'Stake ETH', - budget: { kind: 'native-value' }, - }, - { - target: wstETH, - selector: 'wrap(uint256)', - app: 'Lido wstETH', - label: 'Wrap stETH', - budget: { kind: 'fixed-token', token: stETH, amountArg: 0 }, - }, - { - target: wstETH, - selector: 'unwrap(uint256)', - app: 'Lido wstETH', - label: 'Unwrap wstETH', - budget: { kind: 'fixed-token', token: wstETH, amountArg: 0 }, - }, - { - target: lidoQueue, - selector: 'requestWithdrawals(uint256[],address)', - pins: [{ arg: 1, expected: wallet }], - app: 'Lido withdrawal queue', - label: 'Request withdrawal', - budget: { kind: 'unsupported', reason: 'DYNAMIC_AMOUNT' }, - }, - { - target: lidoQueue, - selector: 'claimWithdrawals(uint256[],uint256[])', - app: 'Lido withdrawal queue', - label: 'Claim finalized withdrawals', - budget: { kind: 'unsupported', reason: 'POSITION_AMOUNT' }, - }, - { - target: lidoQueue, - selector: 'claimWithdrawal(uint256)', - app: 'Lido withdrawal queue', - label: 'Claim finalized withdrawal', - budget: { kind: 'unsupported', reason: 'POSITION_AMOUNT' }, - }, - - { - target: address('0x858646372CC42E1A627fcE94aa7A7033e7CF075A'), - selector: 'depositIntoStrategy(address,address,uint256)', - app: 'EigenLayer', - label: 'Deposit into strategy (withdrawals not included)', - budget: { kind: 'token-argument', tokenArg: 1, amountArg: 2 }, - }, + constant: 'LENDING', + name: 'Lending', + chains: ( + [ + [ + 'eip155:11155111', + '0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951', + // Aave's own Sepolia WETH reserve, not the canonical WETH. + '0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c', ], - approvals: [ - { spender: wstETH, token: stETH, app: 'Lido wstETH' }, - - { - spender: address('0x858646372CC42E1A627fcE94aa7A7033e7CF075A'), - app: 'EigenLayer', - }, + [ + 'eip155:84532', + '0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27', + '0x4200000000000000000000000000000000000006', ], - }, - - { - chainNamespace: 'eip155:11155111', - defaultTokens: STAKING_DEFAULT_TOKENS['eip155:11155111'], - allowedCalls: [ - { - target: eigen, - selector: 'depositIntoStrategy(address,address,uint256)', - app: 'EigenLayer', - label: 'Deposit into strategy (withdrawals not included)', - budget: { kind: 'token-argument', tokenArg: 1, amountArg: 2 }, - }, + [ + 'eip155:421614', + '0xBfC91D59fdAA134A4ED45f7B584cAf96D7792Eff', + // Aave's own Arbitrum Sepolia WETH reserve. + '0x1dF462e2712496373A347f8ad10802a5E95f053D', ], - approvals: [{ spender: eigen, app: 'EigenLayer' }], - }, - ], + ] as const + ).map(([chainNamespace, pool, weth]) => ({ + chainNamespace, + allowedCalls: aave(address(pool), address(weth)), + })), }, - { constant: 'PREDICTION', name: 'Prediction markets', chains: [] }, + { constant: 'STAKING', name: 'Staking', chains: [] }, ]; diff --git a/packages/core/src/lib/agentic/presets/default-policy.ts b/packages/core/src/lib/agentic/presets/default-policy.ts index dc1cf5dd5..03ab4eac2 100644 --- a/packages/core/src/lib/agentic/presets/default-policy.ts +++ b/packages/core/src/lib/agentic/presets/default-policy.ts @@ -1,98 +1,26 @@ -import { getAddress, parseUnits } from 'viem'; -import type { Address } from 'viem'; +import { parseUnits, zeroAddress } from 'viem'; import type { RuleAsset } from '../agentic.types'; -import { capabilityUnavailable } from '../errors'; /** Preset-only permission policy. Ordinary rule authoring retains zero defaults. - * Amounts are token quantities, not USD valuations or destination lifetime caps. - * Metadata verified read-only on October 18; evidence is retained under plan/agw/. + * Amounts are ETH quantities sent from Push, not USD valuations or destination + * lifetime caps. */ export const PRESET_VALIDITY_SECONDS = 7 * 24 * 60 * 60; // Explicit profiles keep future chains from inheriting an unreviewed budget. // 20 PC is a ceiling, not the amount charged: execution obtains a fresh quote. -// This budget has testnet protocol-flow coverage; mainnet remains template-only. export const PRESET_GAS_BUDGETS: Readonly> = Object.fromEntries( - [ - 'eip155:1', - 'eip155:8453', - 'eip155:42161', - 'eip155:56', - 'eip155:11155111', - 'eip155:84532', - 'eip155:421614', - 'eip155:97', - ].map((chain) => [chain, parseUnits('20', 18)]) + ['eip155:11155111', 'eip155:84532', 'eip155:421614'].map((chain) => [ + chain, + parseUnits('20', 18), + ]) ); -type TokenBudget = { decimals: number; perCall: string; total: string }; -const stable6: TokenBudget = { decimals: 6, perCall: '10', total: '100' }; -const stable18: TokenBudget = { ...stable6, decimals: 18 }; -const eth: TokenBudget = { decimals: 18, perCall: '0.001', total: '0.01' }; -const bnb: TokenBudget = { decimals: 18, perCall: '0.01', total: '0.1' }; -const link: TokenBudget = { decimals: 18, perCall: '1', total: '10' }; - -// Match by chain AND address. Never infer decimals or limits from a symbol, -// caller metadata, or the position of a token in an arbitrary selection. -const TOKEN_BUDGETS: Readonly< - Record -> = { - 'eip155:1': [ - ['0x0000000000000000000000000000000000000000', eth], - ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', eth], - ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', stable6], - ['0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', eth], - ['0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', eth], - ], - 'eip155:8453': [ - ['0x4200000000000000000000000000000000000006', eth], - ['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', stable6], - ], - 'eip155:42161': [ - ['0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', eth], - ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831', stable6], - ], - 'eip155:56': [ - ['0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', bnb], - ['0x55d398326f99059fF775485246999027B3197955', stable18], - ], - 'eip155:11155111': [ - ['0xfff9976782d46cc05630d1f6ebab18b2324d6b14', eth], - ['0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', stable6], - ['0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5', link], - ['0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9', eth], - ], - 'eip155:84532': [ - ['0x4200000000000000000000000000000000000006', eth], - ['0x036CbD53842c5426634e7929541eC2318f3dCF7e', stable6], - ['0x810D46F9a9027E28F9B01F75E2bdde839dA61115', link], - ['0x4616975f7E6045FD9737e95bC527e9e14c727BD2', stable6], - ['0x663A6F02d547dFd639BF689FED298712f2FB69Fb', eth], - ], - 'eip155:421614': [ - ['0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', eth], - ['0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', stable6], - ], - 'eip155:97': [ - ['0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', bnb], - ['0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c', stable6], - ['0x16227D60f7a0e586C66B005219dfc887D13C9531', stable6], - ], -}; - -export function defaultPresetAsset(chain: string, token: Address): RuleAsset { - const budget = TOKEN_BUDGETS[chain]?.find( - ([address]) => getAddress(address) === getAddress(token) - )?.[1]; - if (!budget) - throw capabilityUnavailable( - 'presetPolicy', - `No reviewed default spending policy for ${chain}:${token}` - ); +export function defaultPresetAsset(): RuleAsset { return { - token, - maxPerCall: parseUnits(budget.perCall, budget.decimals), - maxTotal: parseUnits(budget.total, budget.decimals), + token: zeroAddress, + maxPerCall: parseUnits('0.001', 18), + maxTotal: parseUnits('0.01', 18), }; } diff --git a/packages/core/src/lib/agentic/presets/default-tokens.ts b/packages/core/src/lib/agentic/presets/default-tokens.ts deleted file mode 100644 index 09f38ecee..000000000 --- a/packages/core/src/lib/agentic/presets/default-tokens.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { getAddress, zeroAddress, type Address } from 'viem'; - -/** Curated subsets of the reviewed catalog. Addresses are destination tokens, - * not a promise that a Push PRC20 route or protocol liquidity is available. - * Keep these sets within the policy's 32-call/32-pin limits after expansion. - * Testnet DEX pairs and lending/staking assets match the live preset fixtures. - * Mainnet addresses share the catalog's October 15 read-only source baseline. - * Spending caps are intentionally separate from token selection. - */ -const addresses = (...values: string[]): readonly Address[] => - values.map((value) => getAddress(value.toLowerCase())); - -export const DEX_DEFAULT_TOKENS: Readonly> = - { - 'eip155:1': addresses( - '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' - ), - 'eip155:8453': addresses( - '0x4200000000000000000000000000000000000006', - '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' - ), - 'eip155:42161': addresses( - '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', - '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' - ), - 'eip155:56': addresses( - '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', - '0x55d398326f99059fF775485246999027B3197955' - ), - 'eip155:11155111': addresses( - '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', - '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' - ), - 'eip155:84532': addresses( - '0x4200000000000000000000000000000000000006', - '0x036CbD53842c5426634e7929541eC2318f3dCF7e' - ), - 'eip155:421614': addresses( - '0x980B62Da83eFf3D4576C647993b0c1D7faf17c73', - '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d' - ), - 'eip155:97': addresses( - '0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd', - '0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c' - ), - }; - -export const LENDING_DEFAULT_TOKENS: Readonly< - Record -> = { - 'eip155:1': DEX_DEFAULT_TOKENS['eip155:1'], - // One shared reserve/market token keeps Aave + Moonwell/Venus within 32 pins. - 'eip155:8453': addresses('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'), - 'eip155:42161': DEX_DEFAULT_TOKENS['eip155:42161'], - 'eip155:56': addresses('0x55d398326f99059fF775485246999027B3197955'), - // Protocol testnet reserves differ from Circle USDC and DEX wrapped ETH. - 'eip155:11155111': addresses('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'), - 'eip155:84532': addresses( - '0x810D46F9a9027E28F9B01F75E2bdde839dA61115', - '0x4616975f7E6045FD9737e95bC527e9e14c727BD2', - '0x663A6F02d547dFd639BF689FED298712f2FB69Fb' - ), - 'eip155:421614': addresses('0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d'), - 'eip155:97': addresses( - '0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c', - '0x16227D60f7a0e586C66B005219dfc887D13C9531' - ), -}; - -export const STAKING_DEFAULT_TOKENS: Readonly< - Record -> = { - 'eip155:1': addresses( - zeroAddress, - '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', - '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0' - ), - 'eip155:11155111': addresses('0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9'), -}; diff --git a/packages/core/src/lib/agentic/presets/index.ts b/packages/core/src/lib/agentic/presets/index.ts index b98ba0fd0..7925edf31 100644 --- a/packages/core/src/lib/agentic/presets/index.ts +++ b/packages/core/src/lib/agentic/presets/index.ts @@ -10,14 +10,10 @@ import type { AgenticPresets, PresetOptions, PresetRuleTemplate, - PresetChainReadiness, - PresetConfigurationIssue, } from './types'; import { parseSelector } from '../codec/selectors'; import { PRESET_CATALOG, type PresetDefinition } from './catalog'; -import { applyOperationLimits, assetAddress } from './operation-limits'; -import { PUSH_NETWORK } from '../../constants/enums'; -import { presetRoutes } from './routes'; +import { applyOperationLimits, nativeAsset } from './operation-limits'; import { defaultPresetAsset, PRESET_GAS_BUDGETS, @@ -28,7 +24,6 @@ const MAX_UINT256 = (BigInt(1) << BigInt(256)) - BigInt(1); const invalid = (message: string) => new AgenticError(AGENTIC_ERROR_CODE.INVALID_RULE, message); function cap(value: unknown, name: string): bigint { - if (value === undefined) return BigInt(0); if (typeof value !== 'bigint' || value < BigInt(0) || value > MAX_UINT256) throw invalid(`${name} must be a uint256 bigint`); return value; @@ -55,12 +50,6 @@ function copyCall(call: RuleCall): RuleCall { : {}), }; } -function tokenAddress(token: RuleAsset['token']): string { - const address = typeof token === 'string' ? token : token?.address; - if (typeof address !== 'string' || !address.trim()) - throw invalid('preset asset requires a token address'); - return address; -} /** Not a public registration API. Tests inject fixtures; production uses the bundled catalog. */ export function buildPresetApi( catalog: readonly PresetDefinition[] @@ -70,25 +59,18 @@ export function buildPresetApi( ...p, chains: p.chains.map((c) => ({ ...c, - ...(c.defaultTokens ? { defaultTokens: [...c.defaultTokens] } : {}), allowedCalls: c.allowedCalls.map((call) => ({ ...copyCall(call), ...(call.budget ? { - budget: { - ...call.budget, - ...(call.budget.kind === 'token-argument' && call.budget.tokens - ? { tokens: [...call.budget.tokens] } - : {}), - }, + budget: + call.budget.kind === 'native-amount' && + typeof call.budget.amountArg !== 'number' + ? { ...call.budget, amountArg: [...call.budget.amountArg] } + : { ...call.budget }, } : {}), })), - approvals: c.approvals?.map((a) => ({ - ...a, - ...(a.tokens ? { tokens: [...a.tokens] } : {}), - })), - swapRouters: c.swapRouters?.map((r) => ({ ...r })), })), })); if ( @@ -118,9 +100,6 @@ export function buildPresetApi( if (!options || typeof options !== 'object' || Array.isArray(options)) throw invalid('preset options must be an object'); const now = Math.floor(Date.now() / 1000); - const network = options.network ?? PUSH_NETWORK.TESTNET_DONUT; - if (!Object.values(PUSH_NETWORK).includes(network)) - throw invalid('unknown preset Push network'); if ('funding' in options) throw invalid( 'The preset funding option is no longer supported. Remove it; preset assets and limits are always preserved.' @@ -159,18 +138,9 @@ export function buildPresetApi( 'presetCatalog', `preset ${preset} has no approved app entries` ); - const assets = new Map(); - // An explicit array (including []) replaces the defaults for all selected - // chains. Never add permissions on chains the caller left unconfigured. - if (options.assets === undefined) - for (const chain of chains) - if (chain.defaultTokens) - assets.set( - chain.chainNamespace, - chain.defaultTokens.map((token) => - defaultPresetAsset(chain.chainNamespace, token) - ) - ); + // Presets move native ETH from Push only; the app tokens are wrapped or + // swapped on the destination chain. + const assets = new Map(); for (const asset of options.assets ?? []) { if (!asset || typeof asset !== 'object') throw invalid('preset asset must be an object'); @@ -181,186 +151,51 @@ export function buildPresetApi( ); if (!chains.some((c) => c.chainNamespace === chain)) throw invalid(`asset chain ${chain} is outside the selected preset`); - const address = tokenAddress(asset.token); - if ( - chain.startsWith('eip155:') && - !isAddress(address, { strict: false }) - ) - throw invalid('EVM preset asset requires an EVM address'); - const group = assets.get(chain) ?? []; - const key = chain.startsWith('eip155:') - ? address.toLowerCase() - : address; if ( - group.some( - (a) => - (chain.startsWith('eip155:') - ? tokenAddress(a.token).toLowerCase() - : tokenAddress(a.token)) === key - ) + !asset.token || + !nativeAsset({ token: asset.token, maxPerCall: BigInt(0) }) ) + throw invalid( + "Presets send only ETH from Push. Use the zero address or the chain's ETH moveable token." + ); + if (assets.has(chain)) throw invalid('duplicate preset asset on the same chain'); - group.push({ + const maxPerCall = cap(asset.maxPerCall, 'maxPerCall'); + const maxTotal = cap(asset.maxTotal, 'maxTotal'); + if (maxPerCall === BigInt(0) || maxTotal === BigInt(0)) + throw invalid( + 'Set a positive maxPerCall and maxTotal for ETH. A missing or zero limit lets the agent spend nothing.' + ); + assets.set(chain, { token: typeof asset.token === 'string' ? asset.token : { ...asset.token }, - maxPerCall: cap(asset.maxPerCall, 'maxPerCall'), - maxTotal: cap(asset.maxTotal, 'maxTotal'), + maxPerCall, + maxTotal, }); - assets.set(chain, group); } - const readiness: PresetChainReadiness[] = []; + if (options.assets !== undefined) + for (const chain of chains) + if (!assets.has(chain.chainNamespace)) + throw invalid( + `Add an ETH asset for ${chain.chainNamespace}, or pass chainNamespace to configure one chain.` + ); const rules: PresetRuleTemplate[] = chains.map((chain) => { + const asset = assets.get(chain.chainNamespace) ?? defaultPresetAsset(); const maxGasPerCall = options.maxGasPerCall ?? chain.maxGasPerCall ?? - (chain.defaultTokens - ? PRESET_GAS_BUDGETS[chain.chainNamespace] - : undefined); - const selectedAssets = assets.get(chain.chainNamespace) ?? []; - const limits = applyOperationLimits( + PRESET_GAS_BUDGETS[chain.chainNamespace]; + const calls = applyOperationLimits( chain.allowedCalls.map((c) => ({ ...copyCall(c), ...(c.budget ? { budget: c.budget } : {}), })), - selectedAssets + asset.maxPerCall ); - const calls = limits.calls; - if (chain.swapRouters?.length) { - const tokens = assets.get(chain.chainNamespace) ?? []; - if ( - tokens.some( - (a) => - tokenAddress(a.token) === zeroAddress || - (typeof a.token !== 'string' && a.token.mechanism === 'native') - ) - ) - throw invalid( - 'DEX token pins require explicit ERC20 addresses; use wrapped-native tokens instead of the native marker' - ); - // Keep discovery useful before a swap pair is selected. These review - // calls cannot spend input tokens: the amount maximum is explicitly zero. - // Re-run get with both tokens to generate the executable pair pins. - if (tokens.length < 2) - for (const router of chain.swapRouters) - for (const exactInput of [true, false]) - calls.push({ - target: router.address, - selector: `${ - exactInput ? 'exactInputSingle' : 'exactOutputSingle' - }((address,address,uint24,address,uint256,uint256,uint160))`, - pins: [ - ...(tokens.length - ? [ - { - arg: [0, 0], - expected: getAddress(tokenAddress(tokens[0].token)), - }, - ] - : []), - { arg: [0, 3], expected: 'agentic:wallet' }, - { arg: [0, exactInput ? 4 : 5], max: BigInt(0) }, - ], - app: router.app, - label: `${ - exactInput ? 'Swap exact input' : 'Swap exact output' - } (select two tokens to enable)`, - }); - for (const router of chain.swapRouters) - for (const input of tokens) - for (const output of tokens) { - if ( - tokenAddress(input.token).toLowerCase() === - tokenAddress(output.token).toLowerCase() - ) - continue; - for (const exactInput of [true, false]) - calls.push({ - target: router.address, - selector: `${ - exactInput ? 'exactInputSingle' : 'exactOutputSingle' - }((address,address,uint24,address,uint256,uint256,uint160))`, - pins: [ - { - arg: [0, 0], - expected: getAddress(tokenAddress(input.token)), - }, - { - arg: [0, 1], - expected: getAddress(tokenAddress(output.token)), - }, - { arg: [0, 3], expected: 'agentic:wallet' }, - { arg: [0, exactInput ? 4 : 5], max: input.maxPerCall }, - ], - app: router.app, - label: exactInput - ? 'Swap exact input' - : 'Swap exact output', - }); - } - } - if (chain.swapRouters?.length) { - const swaps = calls.filter((c) => c.selector.startsWith('exact')); - for (const c of swaps) { - const positive = c.pins?.some( - (p) => 'max' in p && p.max !== undefined && p.max > BigInt(0) - ); - if (positive) limits.enabled = true; - else - limits.disabledCalls.push({ - target: c.target, - selector: c.selector, - app: c.app, - reason: - selectedAssets.length < 2 ? 'TOKEN_REQUIRED' : 'ZERO_CAP', - }); - } - } - const approvals = new Set(); - for (const asset of assets.get(chain.chainNamespace) ?? []) { - const token = tokenAddress(asset.token); - if ( - token === zeroAddress || - (typeof asset.token !== 'string' && - asset.token.mechanism === 'native') - ) - continue; - for (const approval of chain.approvals ?? []) { - if (!chain.chainNamespace.startsWith('eip155:')) - throw capabilityUnavailable( - 'presetApproval', - 'Solana approvals require a separately reviewed instruction template' - ); - if ( - approval.token && - getAddress(token) !== getAddress(approval.token) - ) - continue; - if ( - approval.tokens && - !approval.tokens.some((t) => getAddress(t) === getAddress(token)) - ) - continue; - const spender = getAddress(approval.spender); - const target = getAddress(token); - const approvalKey = `${target}:${spender}`; - if (approvals.has(approvalKey)) continue; - approvals.add(approvalKey); - calls.push({ - target, - selector: 'approve(address,uint256)', - pins: [ - { arg: 0, expected: spender }, - { arg: 1, max: asset.maxPerCall }, - ], - app: approval.app, - label: 'Approve token spending', - }); - } - } if (!calls.length || calls.length > 32) throw capabilityUnavailable( 'presetCalls', - 'preset must contain 1..32 allowed calls including approvals' + 'preset must contain 1..32 allowed calls' ); const conditionCount = calls.reduce( (n, c) => n + (c.pins?.length ?? 0), @@ -396,62 +231,6 @@ export function buildPresetApi( ); pairs.set(pair, !!call.pins?.length); } - const issues: PresetConfigurationIssue[] = []; - if (!options.agent) - issues.push({ - code: 'AGENT_REQUIRED', - message: 'Choose the agent Push address.', - }); - if ((maxGasPerCall ?? BigInt(0)) === BigInt(0)) - issues.push({ - code: 'GAS_BUDGET_REQUIRED', - message: 'Set a positive PC gas budget per outbound.', - }); - if (!selectedAssets.length) - issues.push({ - code: 'ASSETS_REQUIRED', - message: - 'No tokens selected: transfers from Push are disabled, not unrestricted. Choose explicit tokens and limits, then regenerate this preset.', - }); - if (chain.swapRouters?.length && selectedAssets.length < 2) - issues.push({ - code: 'TOKEN_PAIR_REQUIRED', - message: 'Choose both swap tokens, then regenerate this preset.', - }); - if (!limits.enabled) - issues.push({ - code: 'NO_ENABLED_OPERATION', - message: 'No asset operation has a positive per-call limit.', - }); - const configurationReady = issues.length === 0; - const routes = presetRoutes( - chain.chainNamespace, - selectedAssets, - network - ); - issues.push(...routes.issues); - const bridgeAssets = selectedAssets; - readiness.push({ - chainNamespace: chain.chainNamespace, - ready: issues.length === 0, - configurationReady, - selectedAssets: selectedAssets.map((a) => ({ - ...a, - token: typeof a.token === 'string' ? a.token : { ...a.token }, - })), - bridgeAssets: routes.bridgeAssets, - issues, - disabledCalls: limits.disabledCalls, - excludedCalls: limits.excludedCalls, - knownTokens: [ - ...new Set([...limits.knownTokens, ...(chain.defaultTokens ?? [])]), - ], - zeroBridgeLimits: bridgeAssets - .filter( - (a) => a.maxPerCall === BigInt(0) || a.maxTotal === BigInt(0) - ) - .map(assetAddress), - }); return { chainNamespace: chain.chainNamespace, allowedCalls: calls, @@ -459,21 +238,13 @@ export function buildPresetApi( ? { agent: getAddress(options.agent) } : {}), validUntil, - ...(options.assets !== undefined || chain.defaultTokens - ? { assets: bridgeAssets } - : {}), + assets: [asset], ...(maxGasPerCall !== undefined ? { maxGasPerCall: cap(maxGasPerCall, 'maxGasPerCall') } : {}), }; }); - return { - rules, - readiness: { - ready: readiness.length > 0 && readiness.every((c) => c.ready), - chains: readiness, - }, - }; + return { rules }; }, }; // Expiry is generated; the overload narrows templates when the agent is supplied. diff --git a/packages/core/src/lib/agentic/presets/operation-limits.ts b/packages/core/src/lib/agentic/presets/operation-limits.ts index 4466eb932..9b8a2ce27 100644 --- a/packages/core/src/lib/agentic/presets/operation-limits.ts +++ b/packages/core/src/lib/agentic/presets/operation-limits.ts @@ -1,7 +1,6 @@ -import { getAddress, zeroAddress } from 'viem'; +import { zeroAddress } from 'viem'; import type { RuleAsset, RuleCall } from '../agentic.types'; import type { PresetCallDefinition } from './catalog'; -import type { PresetCallIssue } from './types'; export const assetAddress = (asset: RuleAsset): string => typeof asset.token === 'string' ? asset.token : asset.token.address; @@ -9,108 +8,23 @@ export const nativeAsset = (asset: RuleAsset): boolean => assetAddress(asset) === zeroAddress || (typeof asset.token !== 'string' && asset.token.mechanism === 'native'); -/** Compile reviewed protocol argument positions. No exchange-rate or share guesses. */ +/** Fill the reviewed native-ETH caps: maxValue on the wrap, amount maxima on + * every call that spends the wrapped ETH. */ export function applyOperationLimits( definitions: readonly PresetCallDefinition[], - assets: RuleAsset[] -) { - const matches = (definition: PresetCallDefinition): RuleAsset[] => { - const b = definition.budget; - if (!b) return []; - if (b.kind === 'native-value') return assets.filter(nativeAsset); - if (b.kind === 'fixed-token') - return assets.filter( - (a) => - !nativeAsset(a) && getAddress(assetAddress(a)) === getAddress(b.token) - ); - if (b.kind === 'token-argument') - return assets.filter( - (a) => - !nativeAsset(a) && - (!b.tokens || - b.tokens.some((t) => getAddress(t) === getAddress(assetAddress(a)))) - ); - return []; - }; - const previewAll = !definitions.some((d) => matches(d).length > 0); - const calls: RuleCall[] = []; - const disabledCalls: PresetCallIssue[] = [], - excludedCalls: PresetCallIssue[] = []; - const knownTokens = new Set(); - let enabled = false; - for (const definition of definitions) { - const { budget, ...call } = definition; - if (!budget || budget.kind === 'setup') { - calls.push(call); - if (!budget) enabled = true; // Non-budgeted internal fixture entries only. - continue; - } - const info = { - target: call.target, - selector: call.selector, - ...(call.app ? { app: call.app } : {}), - }; - if (budget.kind === 'unsupported') { - excludedCalls.push({ - ...info, - reason: budget.reason, - ...(budget.alternative ? { alternative: budget.alternative } : {}), - }); - continue; - } - if (budget.kind === 'fixed-token') - knownTokens.add(getAddress(budget.token)); - if (budget.kind === 'native-value') knownTokens.add(zeroAddress); - if (budget.kind === 'token-argument') - budget.tokens?.forEach((t) => knownTokens.add(getAddress(t))); - const selected = matches(definition); - if (!selected.length && !previewAll) { - excludedCalls.push({ - ...info, - reason: 'UNSELECTED_TOKEN', - ...(budget.kind === 'fixed-token' - ? { token: getAddress(budget.token) } - : {}), - }); - continue; - } - for (const asset of selected.length ? selected : [undefined]) { - const maximum = asset?.maxPerCall ?? BigInt(0); - const pins = [...(call.pins ?? [])]; - if (budget.kind === 'token-argument' && asset) - pins.push({ - arg: budget.tokenArg, - expected: getAddress(assetAddress(asset)), - }); - if (budget.kind !== 'native-value') - pins.push({ arg: budget.amountArg, max: maximum }); - calls.push({ + maxPerCall: bigint +): RuleCall[] { + return definitions.map(({ budget, ...call }) => { + if (budget?.kind === 'native-value') + return { ...call, maxValue: maxPerCall }; + if (budget?.kind === 'native-amount') + return { ...call, - ...(pins.length ? { pins } : {}), - ...(budget.kind === 'native-value' ? { maxValue: maximum } : {}), - }); - if (maximum > BigInt(0)) enabled = true; - else - disabledCalls.push({ - ...info, - reason: asset - ? 'ZERO_CAP' - : assets.length - ? 'UNSELECTED_TOKEN' - : 'TOKEN_REQUIRED', - ...(asset - ? { token: assetAddress(asset) } - : budget.kind === 'fixed-token' - ? { token: getAddress(budget.token) } - : {}), - }); - } - } - return { - calls, - enabled, - disabledCalls, - excludedCalls, - knownTokens: [...knownTokens], - }; + pins: [ + ...(call.pins ?? []), + { arg: budget.amountArg, max: maxPerCall }, + ], + }; + return call; + }); } diff --git a/packages/core/src/lib/agentic/presets/routes.ts b/packages/core/src/lib/agentic/presets/routes.ts deleted file mode 100644 index c022fa089..000000000 --- a/packages/core/src/lib/agentic/presets/routes.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { PUSH_NETWORK, type CHAIN } from '../../constants/enums'; -import { SUPPORTED_GATEWAY_CHAINS } from '../../orchestrator/internals/helpers'; -import { resolveAgenticPrc20 } from '../token-routes'; -import { generationsFor } from '../deployments'; -import type { RuleAsset } from '../agentic.types'; -import type { PresetChainReadiness, PresetConfigurationIssue } from './types'; -import { assetAddress } from './operation-limits'; - -/** Offline registry checks only. Grant preparation still verifies source metadata - * and wiring against chain state before any signature or transaction. */ -export function presetRoutes( - chain: string, - assets: RuleAsset[], - network: PUSH_NETWORK -): { - bridgeAssets: PresetChainReadiness['bridgeAssets']; - issues: PresetConfigurationIssue[]; -} { - const issues: PresetConfigurationIssue[] = []; - network = - network === PUSH_NETWORK.TESTNET ? PUSH_NETWORK.TESTNET_DONUT : network; - if (!generationsFor(network).some((g) => g.advertised)) - issues.push({ - code: 'AGW_NETWORK_UNAVAILABLE', - message: `No verified AGW deployment is registered for ${network}.`, - }); - const supported = SUPPORTED_GATEWAY_CHAINS.includes(chain as CHAIN); - if (!supported) - issues.push({ - code: 'DESTINATION_UNAVAILABLE', - message: `The SDK has no supported gateway route to ${chain}; this is a catalog template.`, - }); - const bridgeAssets = assets.map((asset) => { - const token = assetAddress(asset); - try { - if (!supported) throw new Error(`No supported gateway route to ${chain}`); - const prc20 = resolveAgenticPrc20({ chain, address: token }, network); - return { - token, - prc20: prc20.address, - status: 'registered' as const, - }; - } catch { - issues.push({ - code: 'BRIDGE_ASSET_UNAVAILABLE', - token, - message: `No SDK PRC20 mapping for ${chain}:${token} on ${network}. This preset cannot be created with this token until its Push route is verified. Choose a routed token only if the selected app supports that exact token address.`, - }); - return { token, status: 'unavailable' as const }; - } - }); - return { bridgeAssets, issues }; -} diff --git a/packages/core/src/lib/agentic/presets/types.ts b/packages/core/src/lib/agentic/presets/types.ts index 0e2983e86..30bd5c0de 100644 --- a/packages/core/src/lib/agentic/presets/types.ts +++ b/packages/core/src/lib/agentic/presets/types.ts @@ -1,13 +1,7 @@ import type { Address } from 'viem'; -import type { PUSH_NETWORK } from '../../constants/enums'; -import type { - Rule, - RuleAsset, - RuleCall, - ForeignChainNamespace, -} from '../agentic.types'; +import type { Rule, RuleAsset, ForeignChainNamespace } from '../agentic.types'; -export type PresetCategory = 'DEXES' | 'LENDING' | 'STAKING' | 'PREDICTION'; +export type PresetCategory = 'DEXES' | 'LENDING' | 'STAKING'; export interface Preset { type: 'CATEGORY'; constant: PresetCategory; @@ -19,18 +13,18 @@ export type PresetRuleTemplate = Omit & { agent?: Address; validUntil?: number; }; +/** Presets send native ETH from Push only: the zero address, a native + * MoveableToken (for example CONSTANTS.MOVEABLE.TOKEN.BASE_SEPOLIA.ETH). */ export interface PresetAsset { chainNamespace?: ForeignChainNamespace; token: RuleAsset['token']; - /** Per allowed operation and per Push outbound, in raw token units. Omitted = 0. + /** Per Push outbound and per allowed operation, in wei. Required and positive. * Does not aggregate multiple destination calls in one transaction. */ - maxPerCall?: bigint; - /** Cumulative Push-outbound amount only; not lifetime activity on destination-held assets. Omitted = 0. */ - maxTotal?: bigint; + maxPerCall: bigint; + /** Cumulative Push-outbound amount, in wei. Required and positive. */ + maxTotal: bigint; } export interface PresetOptions { - /** Offline route/deployment checks; defaults to TESTNET_DONUT. */ - network?: PUSH_NETWORK; /** Push-PC budget ceiling per outbound; omitted uses the catalog policy (20 PC). * Explicit zero is preserved. This is not the fee charged. */ maxGasPerCall?: bigint; @@ -38,71 +32,17 @@ export interface PresetOptions { agent?: Address; /** Omitted: seven days from generation time. Explicit expiry is preserved. */ validUntil?: number; - /** Omitted: include the catalog's curated tokens and explicit spending policy. - * An explicit array replaces all defaults (including [] to select no tokens). - * Supplied assets retain zero for omitted caps; no policy is guessed for them. - * DEX swaps with fewer than two tokens remain zero-input-cap review templates. */ + /** Omitted: each chain gets the catalog's ETH limits. Supplied: exactly one + * native ETH asset for every selected chain. */ assets?: PresetAsset[]; } export interface ReadyPresetOptions extends PresetOptions { agent: Address; } -/** Offline configuration/registry checks; chain state and balances are not checked. */ -export interface PresetConfigurationIssue { - code: - | 'AGENT_REQUIRED' - | 'EXPIRY_REQUIRED' - | 'GAS_BUDGET_REQUIRED' - | 'ASSETS_REQUIRED' - | 'TOKEN_PAIR_REQUIRED' - | 'NO_ENABLED_OPERATION' - | 'AGW_NETWORK_UNAVAILABLE' - | 'DESTINATION_UNAVAILABLE' - | 'BRIDGE_ASSET_UNAVAILABLE'; - message: string; - token?: string; -} -export interface PresetCallIssue { - target: string; - selector: RuleCall['selector']; - app?: string; - reason: - | 'TOKEN_REQUIRED' - | 'ZERO_CAP' - | 'UNSELECTED_TOKEN' - | 'SHARE_AMOUNT' - | 'DYNAMIC_AMOUNT' - | 'POSITION_AMOUNT'; - token?: string; - alternative?: RuleCall['selector']; -} -export interface PresetChainReadiness { - chainNamespace: ForeignChainNamespace; - ready: boolean; - configurationReady: boolean; - /** Selected assets and caps, matching the returned rule assets. */ - selectedAssets: RuleAsset[]; - /** Registry presence only; grant preparation verifies exact on-chain identity. */ - bridgeAssets: { - token: string; - status: 'registered' | 'unavailable'; - prc20?: Address; - }[]; - issues: PresetConfigurationIssue[]; - /** Calls present in the rule but disabled by a zero amount/value maximum. */ - disabledCalls: PresetCallIssue[]; - /** Calls deliberately omitted, including operations with incompatible amount units. */ - excludedCalls: PresetCallIssue[]; - /** Known catalog token addresses, including the curated default subset. */ - knownTokens: string[]; - /** Selected tokens with a zero Push-outbound budget; destination-held assets are separate. */ - zeroBridgeLimits: string[]; -} export interface PresetResult< T extends PresetRuleTemplate = PresetRuleTemplate > { rules: T[]; - readiness: { ready: boolean; chains: PresetChainReadiness[] }; } export interface AgenticPresets { list(options?: { type?: 'CATEGORY' }): { presets: Preset[] }; diff --git a/packages/core/src/lib/agentic/public-rule.ts b/packages/core/src/lib/agentic/public-rule.ts index 12e49cd4a..9e1a4a800 100644 --- a/packages/core/src/lib/agentic/public-rule.ts +++ b/packages/core/src/lib/agentic/public-rule.ts @@ -19,6 +19,7 @@ import { } from 'viem'; import type { Rule, + RuleAsset, RuleInput, InternalRule, NativeAction, @@ -38,6 +39,7 @@ import { UINT256_MAX } from './codec/defaults'; import { deriveAgwSvmCea, svmKey } from './codec/svm-accounts'; import type { AgenticRuntime } from './runtime'; import { CHAIN } from '../constants/enums'; +import { isMoveableTokenSet } from '../constants/tokens'; import { resolveRuleDestination } from './management/destination'; export const WALLET = 'agentic:wallet' as const; @@ -64,6 +66,13 @@ function rejectFields(obj: object, fields: string[], where: string) { `${where}.${field} is not supported by the current rule shape` ); } +/** Preparation expands MOVEABLE ALL markers before normalization. */ +function authoredAssets(rule: Rule): RuleAsset[] { + const assets = rule.assets ?? []; + if (assets.some((a) => isMoveableTokenSet(a?.token))) + throw invalid('expand MOVEABLE ALL assets before normalizing a rule'); + return assets; +} function uint(value: unknown, name: string): bigint { if (typeof value !== 'bigint' || value < BigInt(0) || value > UINT256_MAX) throw invalid(`${name} must be a uint256 bigint`); @@ -139,6 +148,8 @@ export async function normalizePublicRule( 'ref', 'allowedInstructions', 'outputTokens', + // Expanded into transfer calls during preparation (management/rule-inputs). + 'transferTo', ], 'rule' ); @@ -154,11 +165,21 @@ export async function normalizePublicRule( ) throw new AgenticError( AGENTIC_ERROR_CODE.INVALID_RULE, - 'Add at least one allowed function. Selecting tokens and limits alone does not authorize transfers.', + 'Add at least one allowed function, or select tokens on an EVM network to allow transfers back to the owner.', { details: { reason: 'ALLOWED_FUNCTION_REQUIRED', field: 'allowedCalls' }, } ); + // Push-chain rules have the smaller limit; report it before the generic one. + if ( + r.chainNamespace === undefined && + Array.isArray(r.allowedCalls) && + r.allowedCalls.length > 8 + ) + throw capabilityUnavailable( + 'nativeActions', + 'the deployed v6 wallet permits at most eight native actions' + ); if ( !Array.isArray(r.allowedCalls) || r.allowedCalls.length < 1 || @@ -208,11 +229,6 @@ export async function normalizePublicRule( const native = r.chainNamespace === undefined; if (r.chainNamespace === runtime.pushChainNamespace) throw invalid('omit chainNamespace for Push rules'); - if (native && r.allowedCalls.length > 8) - throw capabilityUnavailable( - 'nativeActions', - 'the deployed v6 wallet permits at most eight native actions' - ); if (native && (r.assets !== undefined || r.maxGasPerCall !== undefined)) throw invalid('native rules do not carry assets or maxGasPerCall'); const own = native @@ -276,7 +292,7 @@ export async function normalizePublicRule( agent: r.agent, validUntil: r.validUntil, chainNamespace: r.chainNamespace as `eip155:${string}`, - assets: (r.assets ?? []).map((a) => ({ + assets: authoredAssets(r).map((a) => ({ ...a, token: a.token as Address, maxTotal: a.maxTotal ?? BigInt(0), @@ -333,7 +349,7 @@ function svmRule(rule: Rule, cea: Hex): SolanaRule { ) ), ], - assets: (rule.assets ?? []).map((asset) => ({ + assets: authoredAssets(rule).map((asset) => ({ ...asset, maxTotal: asset.maxTotal ?? BigInt(0), })), diff --git a/packages/core/src/lib/agentic/revert.ts b/packages/core/src/lib/agentic/revert.ts index 0c4430ff2..bedabd2fc 100644 --- a/packages/core/src/lib/agentic/revert.ts +++ b/packages/core/src/lib/agentic/revert.ts @@ -1,5 +1,6 @@ import { decodeErrorResult, + parseAbi, toFunctionSelector, type Abi, type Hex, @@ -16,9 +17,17 @@ import { /** URP reverts reached through the engine are truncated to this wrapper. */ export const POLICY_CHECK_REVERTED_SELECTOR = '0xf4270752'; +/** URP builds these with abi.encodeWithSelector (any-match over allowed calls), + * so the compiled ABI omits them. Signatures from src/libraries/Errors.sol at ffd567d. */ +const URP_ENCODED_ERRORS = parseAbi([ + 'error BeneficiaryMismatch(address expected, address actual)', + 'error InnerValueExceedsAllowance(uint256 index, uint256 value, uint256 maxValue)', +]); + const ERROR_ABIS: readonly Abi[] = [ AGW_ABI as unknown as Abi, UNIVERSAL_RULES_POLICY_ABI as unknown as Abi, + URP_ENCODED_ERRORS, AGW_FACTORY_ABI as unknown as Abi, SMART_SESSION_ABI as unknown as Abi, ]; diff --git a/packages/core/src/lib/constants/index.ts b/packages/core/src/lib/constants/index.ts index a9ee42116..69060a3da 100644 --- a/packages/core/src/lib/constants/index.ts +++ b/packages/core/src/lib/constants/index.ts @@ -54,6 +54,7 @@ export const READ_CONSTANTS = { } as const; export type { MoveableToken, + MoveableTokenSet, PayableToken, PushChainMoveableToken, ChainSuffixAccessor, diff --git a/packages/core/src/lib/constants/tokens.ts b/packages/core/src/lib/constants/tokens.ts index c2c710ad8..ae4635191 100644 --- a/packages/core/src/lib/constants/tokens.ts +++ b/packages/core/src/lib/constants/tokens.ts @@ -40,10 +40,26 @@ export type PayableTokenMap = Partial<{ DAI: PayableToken; }>; +/** Agentic rule assets only: every token Push can send to `chain`. The SDK + * expands it when a rule is prepared (at most eight, each with its default + * limits). It is not a token and cannot be sent or paid with. */ +export interface MoveableTokenSet { + readonly kind: 'ALL'; + readonly chain: CHAIN; +} + +export const isMoveableTokenSet = (token: unknown): token is MoveableTokenSet => + !!token && + typeof token === 'object' && + (token as { kind?: unknown }).kind === 'ALL'; + // Strongly-typed accessors that throw at runtime if a token is unavailable, // while providing non-undefined types at compile time. export class MoveableTokenAccessor { - constructor(private readonly tokens: Record) {} + constructor( + private readonly tokens: Record, + private readonly chain?: CHAIN + ) {} private require(name: keyof MoveableTokenMap): MoveableToken { const t = this.tokens[name as string]; @@ -76,6 +92,11 @@ export class MoveableTokenAccessor { get DAI(): MoveableToken { return this.require('DAI'); } + /** Every routable token on this chain, for agentic rule assets only. */ + get ALL(): MoveableTokenSet { + if (!this.chain) throw new Error('ALL is not available on this chain'); + return Object.freeze({ kind: 'ALL' as const, chain: this.chain }); + } } export class PayableTokenAccessor { @@ -549,37 +570,43 @@ function buildMoveableTokenConstants(): MoveableTokenConstantsMap { toSymbolMap(MOVEABLE_TOKENS[CHAIN.ETHEREUM_SEPOLIA]) as Record< string, MoveableToken - > + >, + CHAIN.ETHEREUM_SEPOLIA ), ETHEREUM_MAINNET: new MoveableTokenAccessor( toSymbolMap(MOVEABLE_TOKENS[CHAIN.ETHEREUM_MAINNET]) as Record< string, MoveableToken - > + >, + CHAIN.ETHEREUM_MAINNET ), ARBITRUM_SEPOLIA: new MoveableTokenAccessor( toSymbolMap(MOVEABLE_TOKENS[CHAIN.ARBITRUM_SEPOLIA]) as Record< string, MoveableToken - > + >, + CHAIN.ARBITRUM_SEPOLIA ), BASE_SEPOLIA: new MoveableTokenAccessor( toSymbolMap(MOVEABLE_TOKENS[CHAIN.BASE_SEPOLIA]) as Record< string, MoveableToken - > + >, + CHAIN.BASE_SEPOLIA ), BNB_TESTNET: new MoveableTokenAccessor( toSymbolMap(MOVEABLE_TOKENS[CHAIN.BNB_TESTNET]) as Record< string, MoveableToken - > + >, + CHAIN.BNB_TESTNET ), SOLANA_DEVNET: new MoveableTokenAccessor( toSymbolMap(MOVEABLE_TOKENS[CHAIN.SOLANA_DEVNET]) as Record< string, MoveableToken - > + >, + CHAIN.SOLANA_DEVNET ), PUSH_TESTNET_DONUT: buildPushChainMoveableTokenAccessor(), }; diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index ebea7e1b5..fb3937654 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -263,6 +263,8 @@ export type { AgenticWallet, AllowedCall, AssetCap, + RuleAssetInput, + AuthoredRule, Checkpoint, CheckpointKind, CreateOptions, @@ -291,8 +293,5 @@ export type { ReadyPresetOptions, PresetRuleTemplate, PresetResult, - PresetChainReadiness, - PresetConfigurationIssue, - PresetCallIssue, AgenticPresets, } from './agentic'; diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts index 2707d1d7a..5f24d7580 100644 --- a/packages/core/src/lib/utils.ts +++ b/packages/core/src/lib/utils.ts @@ -1,4 +1,5 @@ import { agenticPresets } from './agentic/presets'; +import { allRoutableAssets } from './agentic/management/rule-inputs'; import { jobSpecUtils } from './job-spec'; import { validateAgentCardRules, encodeAgentCardRules, decodeAgentCardRules } from './market/rules'; import { @@ -875,7 +876,11 @@ export class Utils { }; /** Pure preset discovery/templates; internal encoders and identifiers stay private. */ - static readonly agentic = Object.freeze({ presets: agenticPresets }); + static readonly agentic = Object.freeze({ + presets: agenticPresets, + /** Every token Push can send to a chain, with default limits (MOVEABLE ALL). */ + assets: Object.freeze({ all: allRoutableAssets }), + }); static readonly job = jobSpecUtils; static readonly market = Object.freeze({ validateAgentCardRules, encodeAgentCardRules, decodeAgentCardRules }); From e5bcfb4306c094a64227c43b7850e3f90e8eabc7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 24 Oct 2026 11:45:04 +0000 Subject: [PATCH 27/27] release(core): bump to 6.0.27-alpha.7 [skip ci] --- packages/core/CHANGELOG.md | 6 ++++++ packages/core/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 9515d4410..865e56bc0 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,9 @@ +@pushchain/core@6.0.27-alpha.7 (2026-10-24) + +- feat: ship working AGW presets, tokens-only rules and MOVEABLE ALL + +--- + @pushchain/core@6.0.27-alpha.6 (2026-10-21) - fix: route exact AGW tokens and size agent gas from the pool diff --git a/packages/core/package.json b/packages/core/package.json index 87aaa3467..1db02c540 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pushchain/core", - "version": "6.0.27-alpha.6", + "version": "6.0.27-alpha.7", "homepage": "https://push.org", "keywords": [ "pushchain",