diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..8a37a30a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Description + + +## Changes + + +## Checklist +- [ ] Builds cleanly (`make build` or `cmake --build`) +- [ ] Tests pass (`make test` or `ctest -V`) +- [ ] README updated if new features, CLI commands, or behaviour changed +- [ ] New public methods have doc comments +- [ ] Branch is off `main` (not another feature branch) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88f0207a..7a00b0d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,15 +5,116 @@ on: push: branches: [main] +env: + CARGO_TERM_COLOR: always + jobs: - test: - name: Tests + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: "unit" + - name: Build (framework + codegen) + run: cargo build -p spel-framework -p spel-framework-core -p spel-framework-macros -p spel-client-gen + - name: Unit tests + run: cargo test -p spel-framework-core -p spel-framework-macros -p spel-client-gen + + e2e-tests: + name: E2E Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Build (core + macros) - run: cargo build -p nssa-framework -p nssa-framework-core -p nssa-framework-macros - - name: Test (core + macros) - run: cargo test -p nssa-framework -p nssa-framework-core -p nssa-framework-macros + with: + prefix-key: "e2e" + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/main/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build (all packages including spel) + run: cargo build -p spel-framework -p spel-framework-core -p spel-framework-macros -p spel-client-gen -p spel + - name: E2E tests + run: cargo test -p spel-framework + + privacy-smoke-test: + name: Privacy Smoke Test + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + RISC0_VERSION: "3.0.5" + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: "privacy" + + - name: Generate Cargo.lock + run: cargo generate-lockfile + - name: Extract LEZ commit from spel-framework/Cargo.toml + id: lez-ref + run: | + LEZ_REV=$(grep 'nssa_core' spel-framework/Cargo.toml | grep -o 'rev = "[^"]*"' | cut -d'"' -f2) + echo "LEZ_REV=$LEZ_REV" >> $GITHUB_OUTPUT + echo "Using LEZ commit: $LEZ_REV" + + - name: Install risc0 toolchain + run: | + curl -L https://risczero.com/install | bash + echo "$HOME/.risc0/bin" >> $GITHUB_PATH + export PATH="$HOME/.risc0/bin:$PATH" + rzup install cargo-risczero ${{ env.RISC0_VERSION }} + rzup install rust + + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/main/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install spel + run: cargo install --path spel-cli --locked + + - name: Clone LEZ at correct commit + run: | + git clone --depth 1 https://github.com/logos-blockchain/logos-execution-zone.git /tmp/lssa || true + cd /tmp/lssa + git fetch --depth 1 origin ${{ steps.lez-ref.outputs.LEZ_REV }} + git checkout ${{ steps.lez-ref.outputs.LEZ_REV }} + + - name: Cache sequencer + id: cache-seq + uses: actions/cache@v4 + with: + path: /tmp/lssa/target/release/sequencer_service + key: sequencer-${{ steps.lez-ref.outputs.LEZ_REV }} + + - name: Build sequencer + if: steps.cache-seq.outputs.cache-hit != 'true' + run: | + cd /tmp/lssa + cargo build --release --features standalone -p sequencer_service + + - name: Build wallet + run: | + cd /tmp/lssa + cargo build --release -p wallet + + - name: Setup wallet + run: | + mkdir -p /tmp/wallet + echo '{"sequencer_addr":"http://127.0.0.1:3040","seq_poll_timeout":"30s","seq_tx_poll_max_blocks":15,"seq_poll_max_retries":10,"seq_block_poll_max_amount":100,"initial_accounts":[{"Public":{"account_id":"CbgR6tj5kWx5oziiFptM7jMvrQeYY3Mzaao6ciuhSr2r","pub_sign_key":"7f273098f25b71e6c005a9519f2678da8d1c7f01f6a27778e2d9948abdf901fb"}},{"Public":{"account_id":"2RHZhw9h534Zr3eq2RGhQete2Hh667foECzXPmSkGni2","pub_sign_key":"f434f8741720014586ae43356d2aec6257da086222f604ddb75d69733b86fc4c"}}]}' > /tmp/wallet/wallet_config.json + + - name: Run privacy smoke test + env: + NSSA_WALLET_HOME_DIR: /tmp/wallet + LSSA_DIR: /tmp/lssa + run: | + export PATH="/tmp/lssa/target/release:$PATH" + scripts/smoke-test-privacy.sh /tmp/privacy-smoke diff --git a/.github/workflows/lez-compat.yml b/.github/workflows/lez-compat.yml new file mode 100644 index 00000000..71fbef82 --- /dev/null +++ b/.github/workflows/lez-compat.yml @@ -0,0 +1,104 @@ +name: LEZ Compatibility Check + +on: + schedule: + # Every Monday at 06:00 UTC + - cron: '0 6 * * 1' + workflow_dispatch: + inputs: + lez_ref: + description: 'LEZ commit/branch to test against (default: main)' + required: false + default: 'main' + +env: + CARGO_TERM_COLOR: always + +jobs: + lez-compat: + name: Check SPEL against latest LEZ + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: "lez-compat" + + - name: Get latest LEZ commit + id: lez + run: | + REF="${{ github.event.inputs.lez_ref || 'main' }}" + COMMIT=$(git ls-remote https://github.com/logos-blockchain/logos-execution-zone.git "$REF" | head -1 | cut -f1) + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "🔍 Testing against LEZ $REF ($COMMIT)" + + - name: Update LEZ deps to latest + run: | + COMMIT="${{ steps.lez.outputs.commit }}" + echo "Updating all LEZ deps to $COMMIT" + + # Find all Cargo.toml files with LEZ git deps + find . -name Cargo.toml -exec grep -l 'logos-execution-zone\|logos-blockchain/lssa' {} \; | while read f; do + # Update rev= pins + sed -i "s|rev = \"[a-f0-9]\{40\}\"|rev = \"$COMMIT\"|g" "$f" + # Ensure URL points to new repo name + sed -i "s|logos-blockchain/lssa\.git|logos-blockchain/logos-execution-zone.git|g" "$f" + echo " Updated: $f" + done + + - name: cargo check (spel-cli) + run: cargo check -p spel-cli + + - name: cargo check (spel-framework) + run: cargo check -p spel-framework + + - name: cargo check (spel-framework-core) + run: cargo check -p spel-framework-core + + - name: Report + if: always() + run: | + echo "## LEZ Compatibility Report" >> "$GITHUB_STEP_SUMMARY" + echo "- **LEZ ref:** ${{ steps.lez.outputs.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **LEZ commit:** \`${{ steps.lez.outputs.commit }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **SPEL ref:** \`$(git rev-parse HEAD)\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Create issue on failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + const commit = '${{ steps.lez.outputs.commit }}'; + const ref = '${{ steps.lez.outputs.ref }}'; + const title = `LEZ compatibility broken (${ref}: ${commit.slice(0, 8)})`; + + // Check for existing open issue + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'lez-compat', + per_page: 1, + }); + + if (existing.data.length > 0) { + // Comment on existing issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.data[0].number, + body: `Still broken as of LEZ \`${commit}\` (${ref}).\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + labels: ['lez-compat'], + body: `The weekly LEZ compatibility check failed.\n\n- **LEZ ref:** ${ref}\n- **LEZ commit:** \`${commit}\`\n- **Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\nPlease update SPEL to match the latest LEZ API changes.`, + }); + } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..91670c80 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,45 @@ +name: Publish Release + +on: + pull_request: + types: [closed] + branches: [main] + +jobs: + publish-release: + name: Publish Release + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/v') + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from branch + id: version + run: | + VERSION=${GITHUB_HEAD_REF#release/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + body: | + ## v${{ steps.version.outputs.version }} + + See [CHANGELOG.md](https://github.com/logos-co/spel/blob/main/CHANGELOG.md) for full details. + + --- + + **Installation** (Cargo.toml): + ```toml + spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v${{ steps.version.outputs.version }}" } + ``` + draft: false + prerelease: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..00496b68 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g. 0.2.0)' + required: true + type: string + +env: + CARGO_TERM_COLOR: always + +jobs: + prepare-release: + name: Prepare Release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: dtolnay/rust-toolchain@stable + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/main/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── Create release branch ────────────────────────────────────────── + - name: Create release branch + run: | + VERSION="${{ inputs.version }}" + git checkout -b "release/v${VERSION}" + + # ── Bump versions ────────────────────────────────────────────────── + - name: Bump versions in Cargo.toml files + run: | + VERSION="${{ inputs.version }}" + for toml in \ + spel-framework-core/Cargo.toml \ + spel-framework-macros/Cargo.toml \ + spel-framework/Cargo.toml \ + spel-client-gen/Cargo.toml \ + spel-cli/Cargo.toml; do + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" "$toml" + echo "Bumped $toml to $VERSION" + done + + # ── Build check ──────────────────────────────────────────────────── + - name: Cargo check + run: cargo check + + - name: Run unit tests + run: cargo test --lib + + # ── Generate CHANGELOG ───────────────────────────────────────────── + - name: Generate CHANGELOG entry + id: changelog + run: | + VERSION="${{ inputs.version }}" + PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + RANGE="HEAD" + echo "No previous tag found — using full history" + else + RANGE="${PREV_TAG}..HEAD" + echo "Generating changelog from $PREV_TAG to HEAD" + fi + + # Categorize commits + FEATS=$(git log $RANGE --pretty=format:"- %s (%h)" --no-merges | grep "^- feat" | sed 's/^- feat[^:]*: //' || true) + FIXES=$(git log $RANGE --pretty=format:"- %s (%h)" --no-merges | grep "^- fix" | sed 's/^- fix[^:]*: //' || true) + BREAKING=$(git log $RANGE --pretty=format:"- %s (%h)" --no-merges | grep -E "^- feat!:|^- fix!:|BREAKING" || true) + OTHER=$(git log $RANGE --pretty=format:"- %s (%h)" --no-merges | grep -vE "^- (feat|fix|feat!|fix!|chore: bump|ci:|chore: retrigger)" || true) + + DATE=$(date +%Y-%m-%d) + + # Build categorized entry + ENTRY="## v${VERSION} (${DATE})" + + if [ -n "$BREAKING" ]; then + ENTRY="${ENTRY}\n\n### ⚠ïļ Breaking Changes\n${BREAKING}" + fi + + if [ -n "$FEATS" ]; then + ENTRY="${ENTRY}\n\n### âœĻ Features\n${FEATS}" + fi + + if [ -n "$FIXES" ]; then + ENTRY="${ENTRY}\n\n### 🐛 Fixes\n${FIXES}" + fi + + if [ -n "$OTHER" ]; then + ENTRY="${ENTRY}\n\n### ðŸ“Ķ Other\n${OTHER}" + fi + + # Prepend to CHANGELOG.md + if [ -f CHANGELOG.md ]; then + echo -e "${ENTRY}\n\n$(cat CHANGELOG.md)" > CHANGELOG.md + else + echo -e "# Changelog\n\n${ENTRY}" > CHANGELOG.md + fi + + # Build release body + BODY="" + [ -n "$BREAKING" ] && BODY="${BODY}### ⚠ïļ Breaking Changes\n${BREAKING}\n\n" + [ -n "$FEATS" ] && BODY="${BODY}### âœĻ Features\n${FEATS}\n\n" + [ -n "$FIXES" ] && BODY="${BODY}### 🐛 Fixes\n${FIXES}\n\n" + [ -n "$OTHER" ] && BODY="${BODY}### ðŸ“Ķ Other\n${OTHER}\n\n" + + # Save for GitHub release + echo "body<> $GITHUB_OUTPUT + echo -e "$BODY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # ── Commit and push branch ───────────────────────────────────────── + - name: Commit version bump + changelog + run: | + VERSION="${{ inputs.version }}" + git add -A + git commit -m "chore: release v${VERSION}" + # Delete stale remote branch if exists from previous failed run + git push origin --delete "release/v${VERSION}" 2>/dev/null || true + git push origin "release/v${VERSION}" + + # ── Open PR ─────────────────────────────────────────────────────── + - name: Open release issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_URL="https://github.com/logos-co/spel/compare/main...release/v${{ inputs.version }}?expand=1&title=Release+v${{ inputs.version }}&body=Release+v${{ inputs.version }}" + gh issue create \ + --repo logos-co/spel \ + --title "Release v${{ inputs.version }} ready to merge" \ + --body "Release branch has been prepared. Click to open the PR:\n\n$PR_URL" \ + --label "release" 2>/dev/null || true + echo "" + echo "✅ Release branch pushed: release/v${{ inputs.version }}" + echo "👉 Open PR manually: $PR_URL" diff --git a/.gitignore b/.gitignore index 96ef6c0b..0cc14d18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target Cargo.lock +tests/e2e/fixture_program/target/ +test-spel-e2e/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6d3e7909 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## v0.2.0 (2026-04-01) + +### ðŸ“Ķ Other +- fix(release): create issue with PR link instead of PR directly (#100) (8a67c6b) +- fix(release): delete stale remote branch before push (#99) (dc933d9) +- fix(release): fix broken YAML in gh pr create body (#98) (05ec85b) +- fix(release): use gh pr create instead of peter-evans action (#97) (93c9aff) +- fix(release): add logos-blockchain-circuits to release workflow (#96) (d3ccd60) +- ci(release): PR-based flow with categorized changelog (#95) (8f059c2) +- feat(spel-cli): detect Private/ prefix, build PrivacyPreservingTransaction (#92) (57201f6) +- feat: update to latest LEZ (ffcbc159) and fix spel-client-gen API (3621a26) +- rename: lez-* crates to spel-*, binary as spel (fixes #57) (034a39b) +- fix(e2e): update instruction count after adding PDA fixtures (600ea8a) +- test(fixture): add arg and multi-seed PDA examples to fixture program (9d2cd3c) +- fix(client-gen): use lez_framework_core::pda::compute_pda for correct PDA derivation (eb05263) +- feat(client-gen): generate PDA compute and state query helpers (2785438) +- fix(init): extract project name from path to support absolute paths (68e5f6a) +- feat(lez-cli): add `generate-idl` subcommand for runtime IDL generation (f4370bf) +- fix(cli)!: remove `-account` suffix (021041d) +- fix(init): fix scaffolded projects failing cargo risczero build (#73) (54fc4f4) +- feat: expose generic compute_pda() utility in lez-framework-core (bebe8c2) +- chore: add PR template with README checklist (b488a91) +- chore: add MIT and Apache-2.0 license files (aa7d5a1) +- chore: add PR template with README checklist (6dd72f6) +- feat: add `inspect` subcommand for account data decoding (#60) (c117260) +- chore: add PR template with README checklist (7cd8189) +- docs: add pda subcommand, Vec types, and --program-id flag to README (976d103) +- chore: update URLs for logos-co org transfer (3276fa8) +- docs: fix SPEL acronym — Smart Program Engine for Logos (233a066) +- docs: rename to SPEL, update README with acronym and ecosystem table (eefd20d) diff --git a/Cargo.toml b/Cargo.toml index 450a754e..bc3334d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [workspace] members = [ - "nssa-framework", - "nssa-framework-core", - "nssa-framework-macros", - "nssa-framework-cli", + "spel-framework", + "spel-framework-core", + "spel-framework-macros", + "spel-cli", + "spel-client-gen", ] +exclude = ["tests/e2e/fixture_program"] resolver = "2" diff --git a/LICENSE-APACHE-v2 b/LICENSE-APACHE-v2 new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE-APACHE-v2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..e98c1ca5 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Logos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2e2a289e..159e50dc 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# nssa-framework +# spel-framework -[![CI](https://github.com/jimmy-claw/nssa-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/jimmy-claw/nssa-framework/actions/workflows/ci.yml) +[![CI](https://github.com/logos-co/spel/actions/workflows/ci.yml/badge.svg)](https://github.com/logos-co/spel/actions/workflows/ci.yml) -Developer framework for building NSSA/LEZ programs — inspired by [Anchor](https://www.anchor-lang.com/) for Solana. +Developer framework for building SPEL programs — inspired by [Anchor](https://www.anchor-lang.com/) for Solana. Write your program logic with proc macros. Get IDL generation, a full CLI with TX submission, and project scaffolding for free. @@ -11,8 +11,8 @@ Write your program logic with proc macros. Get IDL generation, a full CLI with T ### Scaffold a new project ```bash -cargo install --path nssa-framework-cli -nssa-cli init my-program +cargo install --path spel-cli # installs as "spel" +spel init my-program cd my-program ``` @@ -38,7 +38,7 @@ my-program/ ```bash make build # Build the guest binary (risc0) -make idl # Generate IDL from #[nssa_program] annotations +make idl # Generate IDL from #[lez_program] annotations make deploy # Deploy to sequencer make cli ARGS="--help" # See auto-generated commands make cli ARGS="-p initialize --owner-account " @@ -51,11 +51,11 @@ make cli ARGS="-p initialize --owner-account " use nssa_core::account::AccountWithMetadata; use nssa_core::program::AccountPostState; -use nssa_framework::prelude::*; +use spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); -#[nssa_program] +#[lez_program] mod my_program { #[allow(unused_imports)] use super::*; @@ -66,9 +66,9 @@ mod my_program { state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, - ) -> NssaResult { + ) -> SpelResult { // Your logic here - Ok(NssaOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new_claimed(state.account.clone()), AccountPostState::new(owner.account.clone()), ])) @@ -82,9 +82,9 @@ mod my_program { #[account(signer)] sender: AccountWithMetadata, amount: u128, - ) -> NssaResult { + ) -> SpelResult { // Your logic here - Ok(NssaOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new(state.account.clone()), AccountPostState::new(recipient.account.clone()), AccountPostState::new(sender.account.clone()), @@ -103,13 +103,14 @@ mod my_program { | `#[account(pda = literal("seed"))]` | PDA derived from a constant string | | `#[account(pda = account("other"))]` | PDA derived from another account's ID | | `#[account(pda = arg("create_key"))]` | PDA derived from an instruction argument | +| `members: Vec` | Variable-length trailing account list | ### Runtime Validation Accounts marked with `#[account(signer)]` or `#[account(init)]` get **automatic runtime checks** before your handler runs: -- **Signer**: Verifies `is_authorized` is true, returns `NssaError::Unauthorized` if not -- **Init**: Verifies account is in default state, returns `NssaError::AccountAlreadyInitialized` if not +- **Signer**: Verifies `is_authorized` is true, returns `SpelError::Unauthorized` if not +- **Init**: Verifies account is in default state, returns `SpelError::AccountAlreadyInitialized` if not No manual checking needed in your instruction handlers. @@ -118,7 +119,7 @@ No manual checking needed in your instruction handlers. If your `Instruction` enum lives in a shared core crate (used by both on-chain program and CLI), you can tell the macro to use it instead of generating one: ```rust -#[nssa_program(instruction = "my_core::Instruction")] +#[lez_program(instruction = "my_core::Instruction")] mod my_program { // ... } @@ -131,7 +132,7 @@ Every program gets a full CLI for free. The wrapper is just: ```rust #[tokio::main] async fn main() { - nssa_framework_cli::run().await; + spel_cli::run().await; } ``` @@ -149,37 +150,57 @@ This provides: The IDL generator is also a one-liner: ```rust -nssa_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); +spel_framework::generate_idl!("../methods/guest/src/bin/my_program.rs"); ``` -It reads the `#[nssa_program]` annotations at compile time and generates a complete JSON IDL describing instructions, arguments, accounts, and PDA seeds. +It reads the `#[lez_program]` annotations at compile time and generates a complete JSON IDL describing instructions, arguments, accounts, and PDA seeds. + +#### LSSA-lang compatible fields + +The generated IDL is a superset of the lssa-lang IDL spec. In addition to our core fields, each instruction includes: + +- **discriminator** -- SHA256 of global:name, first 8 bytes, matching lssa-lang convention +- **execution** -- public/private_owned flags (default: public execution) +- **variant** -- PascalCase variant name + +Each account field includes: + +- **visibility** -- list of visibility tags (default: public) + +These fields are optional and backward-compatible -- existing IDL consumers that do not know about them will simply ignore them. ## CLI Usage ```bash # Scaffold a new project (no --idl needed) -nssa-cli init my-program +spel init my-program # Inspect program binaries (no --idl needed) -nssa-cli inspect program.bin +spel inspect program.bin # Show available commands -nssa-cli --idl program-idl.json --help +spel --idl program-idl.json --help # Dry run an instruction -nssa-cli --idl program-idl.json --dry-run -p program.bin \ +spel --idl program-idl.json --dry-run -p program.bin \ create-vault --token-name "MYTKN" --initial-supply 1000000 # Submit a transaction -nssa-cli --idl program-idl.json -p program.bin \ +spel --idl program-idl.json -p program.bin \ create-vault --token-name "MYTKN" --initial-supply 1000000 +# Use --program-id instead of binary (skips loading the file) +spel --idl program-idl.json --program-id <64-char-hex> create-vault --token-name "MYTKN" --initial-supply 1000000 + +# Compute a PDA from the IDL +spel --idl program-idl.json --program-id <64-char-hex> pda vault --create-key my-multisig + # Auto-fill program IDs from binaries -nssa-cli --idl program-idl.json -p treasury.bin --bin-token token.bin \ +spel --idl program-idl.json -p treasury.bin --bin-token token.bin \ create-vault --token-name "MYTKN" --initial-supply 1000000 # Get help for a specific instruction -nssa-cli --idl program-idl.json create-vault --help +spel --idl program-idl.json create-vault --help ``` ### Type Formats @@ -189,7 +210,10 @@ nssa-cli --idl program-idl.json create-vault --help | `u8`, `u32`, `u64`, `u128` | Decimal number | | `[u8; N]` | Hex string (2×N chars) or UTF-8 string (â‰ĪN chars, right-padded) | | `[u32; 8]` / `program_id` | Comma-separated u32s: `"0,0,0,0,0,0,0,0"` | +| `Vec` | Comma-separated decimal bytes: `"0,1,2"` | +| `Vec` | Comma-separated decimal u32s: `"0,200,0,0,0"` | | `Vec<[u8; 32]>` | Comma-separated hex or base58: `"addr1,addr2"` | +| `rest` accounts | Comma-separated base58/hex: `--foo-account "addr1,addr2"` | | `Option` | Value or `"none"` | | Account IDs | Base58 or 64-char hex | @@ -197,10 +221,11 @@ nssa-cli --idl program-idl.json create-vault --help | Crate | Description | |-------|-------------| -| `nssa-framework` | Umbrella crate — re-exports macros + core with a prelude | -| `nssa-framework-core` | IDL types, error types, `NssaOutput` | -| `nssa-framework-macros` | Proc macros: `#[nssa_program]`, `#[instruction]`, `generate_idl!` | -| `nssa-framework-cli` | Generic IDL-driven CLI with TX submission + project scaffolding | +| `spel-framework` | Umbrella crate — re-exports macros + core with a prelude | +| `spel-framework-core` | IDL types, error types, `SpelOutput` | +| `spel-framework-macros` | Proc macros: `#[lez_program]`, `#[instruction]`, `generate_idl!` | +| `spel` | Generic IDL-driven CLI with TX submission + project scaffolding | +| `spel-client-gen` | Code generator — produces typed Rust FFI clients from IDL JSON | ## License diff --git a/docs/multi-seed-pda.md b/docs/multi-seed-pda.md index 6169665a..7c05c543 100644 --- a/docs/multi-seed-pda.md +++ b/docs/multi-seed-pda.md @@ -1,6 +1,6 @@ # Multi-seed and arg-based PDA derivation -GitHub Issue: https://github.com/jimmy-claw/nssa-framework/issues/1 +GitHub Issue: https://github.com/jimmy-claw/spel-framework/issues/1 ## Problem @@ -44,9 +44,9 @@ pda = AccountId::from((program_id, &PdaSeed::new(combined_seed))) ### Changes needed: -1. **Macro** (`nssa-framework-macros`): Parse array syntax `pda = [...]`, support `arg("name")` -2. **IDL** (`nssa-framework-core`): `IdlSeed` already has `Const`, `Account`, `Arg` variants — just need to handle multiple seeds -3. **CLI** (`nssa-framework-cli`): `compute_pda_from_seeds` already accepts `&[IdlSeed]` — implement multi-seed hashing +1. **Macro** (`spel-framework-macros`): Parse array syntax `pda = [...]`, support `arg("name")` +2. **IDL** (`spel-framework-core`): `IdlSeed` already has `Const`, `Account`, `Arg` variants — just need to handle multiple seeds +3. **CLI** (`spel-cli`): `compute_pda_from_seeds` already accepts `&[IdlSeed]` — implement multi-seed hashing 4. **Guest code generation**: Macro-generated `main()` needs to compute multi-seed PDAs at runtime ### Seed types: diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 00000000..9f11fabf --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,115 @@ +# Privacy-Preserving Programs with SPEL + +SPEL programs are **privacy-agnostic** — the same program code works identically with both public and private accounts. Privacy is handled at the transaction layer, not the program layer. + +## How Privacy Works in SPEL + +LEZ uses a commitment/nullifier scheme: + +- **Private accounts** are owned by the `auth-transfer` program and encrypted on-chain +- **Commitments** hide account state in a Merkle tree +- **Nullifiers** prove an account was spent without revealing which one +- **ZK proofs** (RISC0) verify execution correctness without revealing private data + +The sequencer never sees plaintext private account state — only commitments, nullifiers, and ZK proofs. + +## Using Private Accounts with SPEL + +### 1. Create a private account + +```bash +wallet account new private +# → Private/5jH7h9CfRDcbfZxCs7h93PcuL1ESW5EJxWbntBup2tJ8 + +wallet auth-transfer init --account-id Private/ +wallet account sync-private +``` + +### 2. Call any SPEL instruction with a private account + +Simply pass the `Private/` prefixed account ID — `spel` detects it automatically and builds a `PrivacyPreservingTransaction`: + +```bash +spel --idl my-program-idl.json -p my-program.bin \ + my_instruction \ + --owner Private/5jH7h9Cf... +``` + +That's it. The program logic doesn't change. + +### 3. Verify the data was written + +```bash +wallet account sync-private +wallet account get --account-id Private/ +# → {"balance": 0, "data_b64": "SGVsbG8h", ...} +``` + +The `data_b64` field contains the base64-encoded private data, decrypted by your wallet. + +## What the Sequencer Sees + +For a `PrivacyPreservingTransaction`: + +| Field | Value | +|-------|-------| +| Account states | Encrypted ciphertext | +| New commitments | Merkle tree insertions | +| Spent nullifiers | Prevents replay | +| ZK proof | RISC0 receipt | + +The sequencer verifies the proof but never sees plaintext account data. + +## Privacy Transaction Types + +| Account prefix | Transaction type | ZK proof | +|---------------|-----------------|----------| +| `0x...` (public) | `PublicTransaction` | Signature | +| `Private/...` | `PrivacyPreservingTransaction` | RISC0 receipt | +| Mixed | `PrivacyPreservingTransaction` | RISC0 receipt | + +## Writing Privacy-Compatible SPEL Programs + +No special annotations needed. A simple program works with both: + +```rust +#[lez_program] +mod my_program { + #[instruction] + pub fn store_data( + #[account(mut)] + target: AccountWithMetadata, // works with public or Private/ accounts + data: Vec, + ) -> SpelResult { + let acc = target.account.clone(); + + // Claim the account if unowned; otherwise return unchanged + // (auth-transfer owned accounts cannot have data written to them) + let post = if acc.program_owner == nssa_core::program::DEFAULT_PROGRAM_ID { + let mut acc = acc; + acc.data = Data::try_from(data) + .map_err(|_| SpelError::custom(999, "data too large"))?; + AccountPostState::new_claimed(acc) + } else { + AccountPostState::new(acc) + }; + + Ok(SpelOutput::states_only(vec![post])) + } +} +``` + +> **Note:** Programs can only write data to accounts they own. For auth-transfer owned accounts +> (freshly initialized private accounts), the program can read but not modify data until the +> account is claimed by the program. + +## Private Account Lifecycle + +``` +wallet account new private # create keypair, derive NPK/NSK +wallet auth-transfer init # register commitment on-chain +wallet account sync-private # sync Merkle tree state +spel ... --account Private/ # use in any SPEL instruction +wallet account sync-private # sync updated state +wallet account get --account-id ... # read decrypted data +``` diff --git a/nssa-framework-cli/Cargo.toml b/nssa-framework-cli/Cargo.toml deleted file mode 100644 index 0b4dba90..00000000 --- a/nssa-framework-cli/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "nssa-framework-cli" -version = "0.1.0" -edition = "2021" -description = "Generic IDL-driven CLI for NSSA/LEZ programs" - -[[bin]] -name = "nssa-cli" -path = "src/bin/main.rs" - -[dependencies] -nssa-framework-core = { path = "../nssa-framework-core" } -nssa_core = { git = "https://github.com/logos-blockchain/lssa.git", branch = "schouhy/full-bedrock-integration" } -nssa = { git = "https://github.com/logos-blockchain/lssa.git", branch = "schouhy/full-bedrock-integration" } -wallet = { git = "https://github.com/logos-blockchain/lssa.git", branch = "schouhy/full-bedrock-integration" } -risc0-zkvm = { version = "3.0.3", features = ["std"] } -base58 = "0.2" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -borsh = "1.5" -tokio = { version = "1.28.2", features = ["net", "rt-multi-thread", "sync", "macros"] } diff --git a/nssa-framework-cli/src/bin/main.rs b/nssa-framework-cli/src/bin/main.rs deleted file mode 100644 index 836aa618..00000000 --- a/nssa-framework-cli/src/bin/main.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[tokio::main] -async fn main() { - nssa_framework_cli::run().await; -} diff --git a/nssa-framework-cli/src/hex.rs b/nssa-framework-cli/src/hex.rs deleted file mode 100644 index 62bc237c..00000000 --- a/nssa-framework-cli/src/hex.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Hex encoding/decoding utilities. - -use base58::FromBase58; - -pub fn hex_encode(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -pub fn hex_decode(hex: &str) -> Result, String> { - if hex.len() % 2 != 0 { - return Err(format!("Hex string has odd length: {}", hex.len())); - } - let mut bytes = Vec::with_capacity(hex.len() / 2); - for i in (0..hex.len()).step_by(2) { - let byte = u8::from_str_radix(&hex[i..i + 2], 16) - .map_err(|e| format!("Invalid hex at position {}: {}", i, e))?; - bytes.push(byte); - } - Ok(bytes) -} - -/// Decode a 32-byte value from base58 or hex string. -pub fn decode_bytes_32(input: &str) -> Result<[u8; 32], String> { - if let Ok(bytes) = input.from_base58() { - if bytes.len() == 32 { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - return Ok(arr); - } - return Err(format!( - "Base58 decoded to {} bytes, expected 32", - bytes.len() - )); - } - - let hex = input - .strip_prefix("0x") - .or_else(|| input.strip_prefix("0X")) - .unwrap_or(input); - let bytes = hex_decode(hex)?; - if bytes.len() == 32 { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&bytes); - Ok(arr) - } else { - Err(format!( - "Expected 32 bytes, got {} (provide base58 or 64 hex chars)", - bytes.len() - )) - } -} diff --git a/nssa-framework-cli/src/lib.rs b/nssa-framework-cli/src/lib.rs deleted file mode 100644 index 0da0e994..00000000 --- a/nssa-framework-cli/src/lib.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Generic IDL-driven CLI library for NSSA/LEZ programs. -//! -//! Provides: -//! - IDL parsing and type-aware argument handling -//! - risc0-compatible serialization -//! - Transaction building and submission -//! - PDA computation from IDL seeds -//! - Binary inspection (ProgramId extraction) -//! -//! Use `run()` for a complete CLI entry point, or import individual modules. - -pub mod hex; -pub mod parse; -pub mod serialize; -pub mod pda; -pub mod tx; -pub mod inspect; -pub mod cli; -pub mod init; - -use cli::{print_help, parse_instruction_args, snake_to_kebab}; -use init::init_project; -use inspect::inspect_binaries; -use tx::execute_instruction; -use nssa_framework_core::idl::NssaIdl; -use std::collections::HashMap; -use std::{env, fs, process}; - -/// Run the generic IDL-driven CLI. Call this from your program's main(): -/// -/// ```no_run -/// #[tokio::main] -/// async fn main() { -/// nssa_framework_cli::run().await; -/// } -/// ``` -pub async fn run() { - let args: Vec = env::args().collect(); - - let mut idl_path = String::new(); - let mut program_path = "program.bin".to_string(); - let mut dry_run = false; - let mut extra_bins: HashMap = HashMap::new(); - let mut remaining_args: Vec = vec![args[0].clone()]; - let mut i = 1; - - while i < args.len() { - match args[i].as_str() { - "--idl" | "-i" => { - i += 1; - if i < args.len() { idl_path = args[i].clone(); } - } - "--program" | "-p" => { - i += 1; - if i < args.len() { program_path = args[i].clone(); } - } - "--dry-run" => { dry_run = true; } - s if s.starts_with("--bin-") => { - let name = s.strip_prefix("--bin-").unwrap().to_string(); - i += 1; - if i < args.len() { - extra_bins.insert(format!("{}-program-id", name), args[i].clone()); - } - } - _ => remaining_args.push(args[i].clone()), - } - i += 1; - } - - // Handle commands that don't need an IDL - if let Some(cmd) = remaining_args.get(1).map(|s| s.as_str()) { - match cmd { - "init" => { - let name = remaining_args.get(2).unwrap_or_else(|| { - eprintln!("Usage: {} init ", args[0]); - process::exit(1); - }); - init_project(name); - return; - } - "inspect" => { - inspect_binaries(&remaining_args[2..]); - return; - } - _ => {} - } - } - - if idl_path.is_empty() { - eprintln!("Usage: {} --idl [ARGS]", args[0]); - eprintln!(); - eprintln!("Commands that don't need --idl:"); - eprintln!(" init Scaffold a new NSSA project"); - eprintln!(" inspect [FILE...] Print ProgramId for ELF binary(ies)"); - eprintln!(); - eprintln!("For all other commands, provide an IDL JSON file."); - process::exit(1); - } - - let idl_content = match fs::read_to_string(&idl_path) { - Ok(c) => c, - Err(e) => { - eprintln!("Error reading IDL '{}': {}", idl_path, e); - process::exit(1); - } - }; - let idl: NssaIdl = serde_json::from_str(&idl_content).unwrap_or_else(|e| { - eprintln!("Error parsing IDL: {}", e); - process::exit(1); - }); - - let subcmd = remaining_args.get(1).map(|s| s.as_str()); - let binary_name = std::path::Path::new(&args[0]) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| args[0].clone()); - - match subcmd { - Some("--help") | Some("-h") | None => { - print_help(&idl, &binary_name); - } - Some("idl") => { - println!("{}", serde_json::to_string_pretty(&idl).unwrap()); - } - Some("inspect") => { - inspect_binaries(&remaining_args[2..]); - } - Some(cmd) => { - let instruction = idl.instructions.iter().find(|ix| { - snake_to_kebab(&ix.name) == cmd || ix.name == cmd - }); - - match instruction { - Some(ix) => { - let cli_args = parse_instruction_args(&remaining_args[2..], ix); - execute_instruction( - &idl, ix, &cli_args, &program_path, dry_run, &extra_bins, - ).await; - } - None => { - eprintln!("Unknown command: {}", cmd); - print_help(&idl, &binary_name); - process::exit(1); - } - } - } - } -} diff --git a/nssa-framework-cli/src/pda.rs b/nssa-framework-cli/src/pda.rs deleted file mode 100644 index dd50449e..00000000 --- a/nssa-framework-cli/src/pda.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! PDA (Program Derived Address) computation from IDL seed definitions. - -use std::collections::HashMap; -use nssa::AccountId; -use nssa_core::program::{PdaSeed, ProgramId}; -use nssa_framework_core::idl::IdlSeed; -use crate::parse::ParsedValue; - -/// Compute PDA AccountId from IDL seed definitions. -pub fn compute_pda_from_seeds( - seeds: &[IdlSeed], - program_id: &ProgramId, - account_map: &HashMap, - _parsed_args: &HashMap, -) -> Result { - if seeds.len() != 1 { - return Err(format!( - "Multi-seed PDAs not yet supported (got {} seeds)", - seeds.len() - )); - } - - let seed_bytes: [u8; 32] = match &seeds[0] { - IdlSeed::Const { value } => { - let mut bytes = [0u8; 32]; - let src = value.as_bytes(); - if src.len() > 32 { - return Err(format!("Const seed '{}' exceeds 32 bytes", value)); - } - bytes[..src.len()].copy_from_slice(src); - bytes - } - IdlSeed::Account { path } => { - let account_id = account_map - .get(path) - .ok_or_else(|| { - format!( - "PDA seed references account '{}' which hasn't been resolved yet", - path - ) - })?; - *account_id.value() - } - IdlSeed::Arg { path } => { - return Err(format!("Arg-based PDA seeds not yet supported (arg: {})", path)); - } - }; - - let pda_seed = PdaSeed::new(seed_bytes); - Ok(AccountId::from((program_id, &pda_seed))) -} diff --git a/nssa-framework-cli/src/tx.rs b/nssa-framework-cli/src/tx.rs deleted file mode 100644 index 6e2f16b2..00000000 --- a/nssa-framework-cli/src/tx.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Transaction building and submission. - -use std::collections::HashMap; -use std::fs; -use std::process; -use nssa::program::Program; -use nssa::public_transaction::{Message, WitnessSet}; -use nssa::{AccountId, PublicTransaction}; -use nssa_framework_core::idl::{IdlSeed, NssaIdl, IdlInstruction}; -use crate::hex::{hex_encode, decode_bytes_32}; -use crate::parse::{parse_value, ParsedValue}; -use crate::serialize::serialize_to_risc0; -use crate::pda::compute_pda_from_seeds; -use crate::cli::{snake_to_kebab, to_pascal_case}; -use wallet::WalletCore; - -/// Execute an instruction: parse args, build TX, optionally submit. -pub async fn execute_instruction( - idl: &NssaIdl, - ix: &IdlInstruction, - args: &HashMap, - program_path: &str, - dry_run: bool, - extra_bins: &HashMap, -) { - println!("📋 Instruction: {}", ix.name); - println!(); - - let mut args = args.clone(); - - // Auto-fill program-id args from binary paths - for (key, bin_path) in extra_bins { - if !args.contains_key(key) { - if let Ok(bytes) = fs::read(bin_path) { - if let Ok(program) = Program::new(bytes) { - let id = program.id(); - let id_str: Vec = id.iter().map(|w| w.to_string()).collect(); - let val = id_str.join(","); - println!(" â„đïļ Auto-filled --{} from {}", key, bin_path); - args.insert(key.clone(), val); - } - } - } - } - - // Validate required args - let mut missing = vec![]; - for arg in &ix.args { - let key = snake_to_kebab(&arg.name); - if !args.contains_key(&key) { - missing.push(format!("--{}", key)); - } - } - for acc in &ix.accounts { - if acc.pda.is_none() { - let key = format!("{}-account", snake_to_kebab(&acc.name)); - if !args.contains_key(&key) { - missing.push(format!("--{}", key)); - } - } - } - if !missing.is_empty() { - eprintln!("❌ Missing required arguments: {}", missing.join(", ")); - process::exit(1); - } - - // Parse instruction args - let mut parsed_args: Vec<(&str, &nssa_framework_core::idl::IdlType, ParsedValue)> = Vec::new(); - let mut has_errors = false; - for arg in &ix.args { - let key = snake_to_kebab(&arg.name); - let raw = args.get(&key).unwrap(); - match parse_value(raw, &arg.type_) { - Ok(val) => parsed_args.push((&arg.name, &arg.type_, val)), - Err(e) => { eprintln!("❌ --{}: {}", key, e); has_errors = true; } - } - } - - // Parse non-PDA account IDs - let mut parsed_accounts: Vec<(&str, Vec)> = Vec::new(); - for acc in &ix.accounts { - if acc.pda.is_some() { continue; } - let key = format!("{}-account", snake_to_kebab(&acc.name)); - let raw = args.get(&key).unwrap(); - match decode_bytes_32(raw) { - Ok(bytes) => parsed_accounts.push((&acc.name, bytes.to_vec())), - Err(e) => { eprintln!("❌ --{}: {}", key, e); has_errors = true; } - } - } - if has_errors { process::exit(1); } - - // Build risc0 serialized data - let ix_index = idl.instructions.iter().position(|i| i.name == ix.name).unwrap_or(0); - let risc0_args: Vec<_> = parsed_args.iter().map(|(_, ty, val)| (*ty, val)).collect(); - let instruction_data = serialize_to_risc0(ix_index as u32, &risc0_args); - - // Display - println!("Accounts:"); - for acc in &ix.accounts { - if acc.pda.is_some() { - println!(" ðŸ“Ķ {} → auto-computed (PDA)", acc.name); - } else { - let account_bytes = parsed_accounts.iter().find(|(n, _)| *n == acc.name).unwrap(); - println!(" ðŸ“Ķ {} → 0x{}", acc.name, hex_encode(&account_bytes.1)); - } - } - println!(); - println!("Arguments (parsed):"); - for (name, _, val) in &parsed_args { - println!(" {} = {}", name, val); - } - println!(); - println!("🔧 Transaction:"); - println!(" program: {}", program_path); - println!(" instruction index: {}", ix_index); - println!(" instruction: {} {{", to_pascal_case(&ix.name)); - for (name, _, val) in &parsed_args { - println!(" {}: {},", name, val); - } - println!(" }}"); - println!(); - println!(" Serialized instruction data ({} u32 words):", instruction_data.len()); - let hex_words: Vec = instruction_data.iter().map(|w| format!("{:08x}", w)).collect(); - println!(" [{}]", hex_words.join(", ")); - println!(); - - if dry_run { - println!("⚠ïļ Dry run — omit --dry-run to submit the transaction."); - return; - } - - // ─── Transaction submission ────────────────────────────────── - println!("ðŸ“Ī Submitting transaction..."); - - let program_bytecode = fs::read(program_path).unwrap_or_else(|e| { - eprintln!("❌ Failed to read program binary '{}': {}", program_path, e); - process::exit(1); - }); - let program = Program::new(program_bytecode).unwrap_or_else(|e| { - eprintln!("❌ Failed to load program: {:?}", e); - process::exit(1); - }); - let program_id = program.id(); - println!(" Program ID: {:?}", program_id); - - // Build account map for PDA resolution - let mut account_map: HashMap = HashMap::new(); - for (name, bytes) in &parsed_accounts { - let mut arr = [0u8; 32]; - arr.copy_from_slice(bytes); - account_map.insert(name.to_string(), AccountId::new(arr)); - } - - // Resolve external account references needed by PDA seeds - for acc in &ix.accounts { - if let Some(pda) = &acc.pda { - for seed in &pda.seeds { - if let IdlSeed::Account { path } = seed { - if !account_map.contains_key(path) { - let key = format!("{}-account", snake_to_kebab(path)); - if let Some(raw) = args.get(&key) { - match decode_bytes_32(raw) { - Ok(bytes) => { - println!(" â„đïļ Using --{} for PDA seed '{}'", key, path); - account_map.insert(path.clone(), AccountId::new(bytes)); - } - Err(e) => { eprintln!("❌ --{}: {}", key, e); process::exit(1); } - } - } else { - eprintln!("❌ PDA '{}' requires account '{}' — provide --{}", acc.name, path, key); - process::exit(1); - } - } - } - } - } - } - - let mut parsed_arg_map: HashMap = HashMap::new(); - for (name, _, val) in &parsed_args { - parsed_arg_map.insert(name.to_string(), val.clone()); - } - - // Resolve PDA accounts - for acc in &ix.accounts { - if let Some(pda) = &acc.pda { - match compute_pda_from_seeds(&pda.seeds, &program_id, &account_map, &parsed_arg_map) { - Ok(id) => { - println!(" PDA {} → {}", acc.name, id); - account_map.insert(acc.name.clone(), id); - } - Err(e) => { - eprintln!("❌ Failed to compute PDA for '{}': {}", acc.name, e); - process::exit(1); - } - } - } - } - - let mut account_ids: Vec = Vec::new(); - for acc in &ix.accounts { - let id = account_map.get(&acc.name).unwrap_or_else(|| { - eprintln!("❌ Account '{}' not resolved", acc.name); - process::exit(1); - }); - account_ids.push(*id); - } - - let wallet_core = WalletCore::from_env().unwrap_or_else(|e| { - eprintln!("❌ Failed to initialize wallet: {:?}", e); - eprintln!(" Set NSSA_WALLET_HOME_DIR environment variable"); - process::exit(1); - }); - - let signer_accounts: Vec = ix.accounts.iter() - .filter(|a| a.signer) - .map(|a| *account_map.get(&a.name).unwrap()) - .collect(); - - let nonces = if signer_accounts.is_empty() { - vec![] - } else { - wallet_core.get_accounts_nonces(signer_accounts.clone()).await.unwrap_or_else(|e| { - eprintln!("❌ Failed to fetch nonces: {:?}", e); - process::exit(1); - }) - }; - - let signing_keys: Vec<_> = signer_accounts.iter().map(|id| { - wallet_core.storage().user_data.get_pub_account_signing_key(id).unwrap_or_else(|| { - eprintln!("❌ Signing key not found for account {}", id); - process::exit(1); - }) - }).collect(); - - let message = Message::new_preserialized(program_id, account_ids, nonces, instruction_data); - let witness_set = WitnessSet::for_message(&message, &signing_keys); - let tx = PublicTransaction::new(message, witness_set); - - let response = wallet_core.sequencer_client.send_tx_public(tx).await.unwrap_or_else(|e| { - eprintln!("❌ Failed to submit transaction: {:?}", e); - process::exit(1); - }); - - println!("ðŸ“Ī Transaction submitted!"); - println!(" tx_hash: {}", response.tx_hash); - println!(" Waiting for confirmation..."); - - let poller = wallet::poller::TxPoller::new( - wallet_core.config().clone(), - wallet_core.sequencer_client.clone(), - ); - - match poller.poll_tx(response.tx_hash).await { - Ok(_) => println!("✅ Transaction confirmed — included in a block."), - Err(e) => { - eprintln!("❌ Transaction NOT confirmed: {e:#}"); - process::exit(1); - } - } -} diff --git a/nssa-framework-core/Cargo.toml b/nssa-framework-core/Cargo.toml deleted file mode 100644 index d16a4a84..00000000 --- a/nssa-framework-core/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "nssa-framework-core" -version = "0.1.0" -edition = "2021" -description = "Core types for the NSSA/LEZ program framework" - -[dependencies] -nssa_core = { git = "https://github.com/logos-blockchain/lssa.git", branch = "schouhy/full-bedrock-integration", features = ["host"] } -borsh = { version = "1.0", features = ["derive"] } -thiserror = "1.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" diff --git a/nssa-framework-core/src/lib.rs b/nssa-framework-core/src/lib.rs deleted file mode 100644 index 52cb08e5..00000000 --- a/nssa-framework-core/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! # NSSA Framework Core -//! -//! Core types and traits for the NSSA program framework. - -pub mod error; -pub mod types; -pub mod idl; -pub mod validation; - -pub mod prelude { - pub use crate::error::{NssaError, NssaResult}; - pub use crate::types::{NssaOutput, AccountConstraint}; - pub use nssa_core::account::{Account, AccountWithMetadata}; - pub use nssa_core::program::{AccountPostState, ChainedCall, PdaSeed, ProgramId}; -} diff --git a/nssa-framework/Cargo.toml b/nssa-framework/Cargo.toml deleted file mode 100644 index 149527bd..00000000 --- a/nssa-framework/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "nssa-framework" -version = "0.1.0" -edition = "2021" -description = "Developer framework for building NSSA/LEZ programs (like Anchor for Solana)" - -[dependencies] -nssa-framework-core = { path = "../nssa-framework-core" } -nssa-framework-macros = { path = "../nssa-framework-macros" } -nssa_core = { git = "https://github.com/logos-blockchain/lssa.git", branch = "schouhy/full-bedrock-integration", features = ["host"] } -borsh = { version = "1.0", features = ["derive"] } diff --git a/nssa-framework/src/lib.rs b/nssa-framework/src/lib.rs deleted file mode 100644 index d60e34b4..00000000 --- a/nssa-framework/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! # NSSA Framework -//! -//! Developer framework for building programs on NSSA/LEZ, -//! similar to Anchor for Solana. - -// Re-export the proc macros -pub use nssa_framework_macros::{nssa_program, instruction, generate_idl}; - -// Re-export core types -pub use nssa_framework_core::*; - -pub mod prelude { - pub use crate::nssa_program; - pub use crate::instruction; - pub use nssa_framework_core::prelude::*; - pub use nssa_framework_core::types::NssaOutput; - pub use nssa_framework_core::error::{NssaError, NssaResult}; - pub use borsh::{BorshSerialize, BorshDeserialize}; -} diff --git a/scripts/smoke-test-privacy.sh b/scripts/smoke-test-privacy.sh new file mode 100755 index 00000000..b09d06fc --- /dev/null +++ b/scripts/smoke-test-privacy.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# SPEL Privacy Smoke Test +# Verifies both public and Private/ prefixed transactions work end-to-end +# including auth-transfer init for the private account. +# +# Usage: ./smoke-test-privacy.sh [WORK_DIR] + +set -euo pipefail + +export RISC0_DEV_MODE=1 +export RISC0_SKIP_BUILD=1 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK_DIR="${1:-/tmp/spel-privacy-smoke}" +SEQUENCER_PORT="${SEQUENCER_PORT:-3040}" +SEQUENCER_URL="http://127.0.0.1:${SEQUENCER_PORT}" +PROJECT_NAME="privacy_test" +LOG_DIR="${WORK_DIR}/logs" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[PRIVACY]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; } + +cleanup() { + if [ -n "${SEQ_PID:-}" ] && kill -0 "$SEQ_PID" 2>/dev/null; then + kill "$SEQ_PID" 2>/dev/null || true + wait "$SEQ_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# ─── Prerequisites ───────────────────────────────────────────────────────── + +command -v spel >/dev/null 2>&1 || fail "spel not found" +command -v cargo >/dev/null 2>&1 || fail "cargo not found" + +LSSA_DIR="${LSSA_DIR:-$HOME/lssa}" +SEQUENCER_BIN="" +for candidate in sequencer_service "$HOME/bin/sequencer_service" "$LSSA_DIR/target/release/sequencer_service"; do + if command -v "$candidate" >/dev/null 2>&1 || [ -x "$candidate" ]; then + SEQUENCER_BIN="$candidate"; break + fi +done +[ -n "$SEQUENCER_BIN" ] || fail "sequencer_service not found" + +WALLET_BIN="" +for candidate in wallet "$HOME/bin/wallet" "$LSSA_DIR/target/release/wallet"; do + if command -v "$candidate" >/dev/null 2>&1 || [ -x "$candidate" ]; then + WALLET_BIN="$candidate"; break + fi +done +[ -n "$WALLET_BIN" ] || fail "wallet not found" + +export NSSA_WALLET_HOME_DIR="${NSSA_WALLET_HOME_DIR:-${LSSA_DIR}/wallet/configs/debug}" +WALLET_PASSWORD="${WALLET_PASSWORD:-test}" + +# ─── Setup ───────────────────────────────────────────────────────────────── + +log "Setting up in ${WORK_DIR}..." +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" "$LOG_DIR" +cd "$WORK_DIR" + +# ─── Step 1: Scaffold project ────────────────────────────────────────────── + +log "Step 1: Creating SPEL project..." +spel init "$PROJECT_NAME" > "$LOG_DIR/init.log" 2>&1 || fail "spel init failed" +cd "$PROJECT_NAME" +log " ✅ Project scaffolded" + +# ─── Step 2: Modify guest program for privacy test ──────────────────────── + +log "Step 2: Setting up test program..." + +# Replace the default scaffold with a simple greet instruction +cat > "methods/guest/src/bin/${PROJECT_NAME}.rs" << 'RUSTEOF' +#![no_main] +use spel_framework::prelude::*; +use nssa_core::account::Data; + +risc0_zkvm::guest::entry!(main); + +#[lez_program] +mod privacy_test { + use super::*; + + /// Greet: appends greeting bytes to account data. + /// For default (unclaimed) accounts: claims and writes data. + /// For already-owned accounts: returns unchanged (privacy TX compatible). + #[instruction] + pub fn greet( + #[account(mut)] + account: AccountWithMetadata, + greeting: Vec, + ) -> SpelResult { + let acc = account.account.clone(); + + let post = if acc.program_owner == nssa_core::program::DEFAULT_PROGRAM_ID { + // Unclaimed account: claim it and write greeting + let mut acc = acc; + let mut data: Vec = acc.data.into(); + data.extend_from_slice(&greeting); + acc.data = Data::try_from(data) + .map_err(|_| SpelError::custom(999, "data too big"))?; + AccountPostState::new_claimed(acc) + } else { + // Already owned (e.g. by auth-transfer): return unchanged + AccountPostState::new(acc) + }; + + Ok(SpelOutput::states_only(vec![post])) + } +} +RUSTEOF + +log " ✅ Guest program configured" + +# ─── Step 3: Build guest binary ─────────────────────────────────────────── + +log "Step 3: Building guest binary (RISC0_DEV_MODE=1)..." +RISC0_SKIP_BUILD= make build > "$LOG_DIR/build.log" 2>&1 || { cat "$LOG_DIR/build.log"; fail "Build failed"; } +GUEST_BIN=$(find . -name "*.bin" -path "*/riscv32im*" | head -1) +[ -n "$GUEST_BIN" ] || fail "No guest binary found" +GUEST_BIN_ABS="$(realpath "$GUEST_BIN")" +log " ✅ Built: $(basename "$GUEST_BIN")" + +# ─── Step 4: Generate IDL ───────────────────────────────────────────────── + +log "Step 4: Generating IDL..." +make idl > "$LOG_DIR/idl.log" 2>&1 || fail "IDL generation failed" +IDL_FILE=$(find . -name "*-idl.json" | head -1) +[ -n "$IDL_FILE" ] || fail "No IDL found" +IDL_ABS="$(realpath "$IDL_FILE")" +log " ✅ IDL: $(basename "$IDL_FILE")" + +# ─── Step 5: Start sequencer ────────────────────────────────────────────── + +log "Step 5: Starting sequencer..." +pgrep -f 'sequencer_service.*configs' | xargs -r kill 2>/dev/null || true +sleep 1 +rm -rf "${LSSA_DIR}/rocksdb" + +SEQ_CONFIGS="${LSSA_DIR}/sequencer/service/configs/debug/sequencer_config.json" +[ -f "$SEQ_CONFIGS" ] || fail "Sequencer config not found" + +cd "$LSSA_DIR" +RUST_LOG=warn $SEQUENCER_BIN "$SEQ_CONFIGS" > "$LOG_DIR/sequencer.log" 2>&1 & +SEQ_PID=$! +cd "$WORK_DIR/$PROJECT_NAME" + +log " Waiting for sequencer..." +for i in $(seq 1 60); do + if curl -sf -o /dev/null -w '%{http_code}' "$SEQUENCER_URL" 2>/dev/null | grep -qE '200|405'; then + log " ✅ Sequencer up"; break + fi + kill -0 "$SEQ_PID" 2>/dev/null || fail "Sequencer died" + sleep 1 +done + +# Wait for first block to be produced before proceeding +log " Waiting for first block..." +for i in $(seq 1 60); do + LAST_BLOCK=$(curl -sf -X POST "$SEQUENCER_URL" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"getLastBlockId","params":[],"id":1}' 2>/dev/null \ + | python3 -c "import json,sys; r=json.load(sys.stdin); print(r.get('result',0))" 2>/dev/null || echo 0) + if [ "${LAST_BLOCK:-0}" -gt 0 ] 2>/dev/null; then + log " ✅ First block produced (block $LAST_BLOCK)" + break + fi + sleep 2 +done + +# ─── Step 6: Deploy ─────────────────────────────────────────────────────── + +log "Step 6: Deploying program..." +printf '%s\n' "$WALLET_PASSWORD" | $WALLET_BIN deploy-program "$GUEST_BIN_ABS" \ + > "$LOG_DIR/deploy.log" 2>&1 || fail "Deploy failed" +log " ✅ Program deployed" + +# ─── Step 7: Generate test accounts ─────────────────────────────────────── + +log "Step 7: Generating test accounts..." + +# Create a public account (random) +PUBLIC_ACCOUNT="0x$(openssl rand -hex 32)" +log " Public account: ${PUBLIC_ACCOUNT:0:20}..." + +# Create a private account via wallet (wallet holds the ZK keys) +PRIVATE_ACCOUNT=$(echo "$WALLET_PASSWORD" | $WALLET_BIN account new private 2>&1 | grep -o "Private/[^ ]*" | head -1) +[ -n "$PRIVATE_ACCOUNT" ] || fail "Could not create private account from wallet" +log " Private account: ${PRIVATE_ACCOUNT:0:30}..." + +# ─── Step 8: Test PUBLIC transaction ──────────────────────────────────── + +log "Step 8: Testing PUBLIC transaction..." +FRESH_ACCOUNT="0x$(openssl rand -hex 32)" + +SEQUENCER_URL="$SEQUENCER_URL" spel --idl "$IDL_ABS" -p "$GUEST_BIN_ABS" \ + greet \ + --account "$FRESH_ACCOUNT" \ + --greeting "72,101,108,108,111,32,80,117,98,108,105,99" \ + > "$LOG_DIR/public-tx.log" 2>&1 || fail "Public TX failed (see $LOG_DIR/public-tx.log)" + +log " ✅ Public TX submitted and confirmed" + +# ─── Step 9: Init auth-transfer for private account ───────────────────── + +log "Step 9: Initializing auth-transfer for private account..." +echo "$WALLET_PASSWORD" | $WALLET_BIN auth-transfer init --account-id "$PRIVATE_ACCOUNT" \ + > "$LOG_DIR/auth-transfer.log" 2>&1 || fail "auth-transfer init failed (see $LOG_DIR/auth-transfer.log)" +log " ✅ auth-transfer initialized" + +# Wait for auth-transfer TX to be included in a block +log " Waiting for auth-transfer to be confirmed..." +sleep 20 + +# ─── Step 10: Test PRIVACY-PRESERVING transaction ─────────────────────── + +log "Step 10: Testing PRIVACY-PRESERVING transaction..." +SEQUENCER_URL="$SEQUENCER_URL" spel --idl "$IDL_ABS" -p "$GUEST_BIN_ABS" \ + greet \ + --account "$PRIVATE_ACCOUNT" \ + --greeting "72,101,108,108,111,32,80,114,105,118,97,116,101" \ + > "$LOG_DIR/private-tx.log" 2>&1 || { cat "$LOG_DIR/private-tx.log"; fail "Private TX failed"; } + +log " ✅ Privacy-preserving TX submitted and confirmed" + +# ─── Done ───────────────────────────────────────────────────────────────── + +log "" +log "🎉 Privacy smoke test PASSED!" +log " Public TX: $LOG_DIR/public-tx.log" +log " Auth-transfer: $LOG_DIR/auth-transfer.log" +log " Private TX: $LOG_DIR/private-tx.log" +log " Sequencer: $LOG_DIR/sequencer.log" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100644 index 00000000..ca1d09ba --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# spel-framework end-to-end smoke test +# Tests the full pipeline: init → build guest → deploy → submit tx +# +# Prerequisites: +# - spel in PATH (cargo install --path spel) +# - cargo-risczero installed (cargo risczero --version) +# - Docker running (for risc0 guest builds) +# - sequencer_service in PATH or ~/bin/ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK_DIR="${WORK_DIR:-/tmp/spel-smoke-test}" +SEQUENCER_PORT="${SEQUENCER_PORT:-3040}" +SEQUENCER_URL="http://127.0.0.1:${SEQUENCER_PORT}" +PROJECT_NAME="smoke_test_program" +LOG_DIR="${WORK_DIR}/logs" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[SMOKE]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; } + +cleanup() { + log "Cleaning up..." + if [ -n "${SEQ_PID:-}" ] && kill -0 "$SEQ_PID" 2>/dev/null; then + kill "$SEQ_PID" 2>/dev/null || true + wait "$SEQ_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# ─── Prerequisites ──────────────────────────────────────────────────────── + +log "Checking prerequisites..." + +command -v spel >/dev/null 2>&1 || fail "spel not found in PATH" +command -v cargo >/dev/null 2>&1 || fail "cargo not found" +command -v cargo-risczero >/dev/null 2>&1 || warn "cargo-risczero not found — guest build may fail" +docker info >/dev/null 2>&1 || warn "Docker not running — guest build may fail" + +LSSA_DIR="${LSSA_DIR:-$HOME/lssa}" +SEQUENCER_BIN="" +if command -v sequencer_service >/dev/null 2>&1; then + SEQUENCER_BIN="sequencer_service" +elif [ -x "$HOME/bin/sequencer_service" ]; then + SEQUENCER_BIN="$HOME/bin/sequencer_service" +elif [ -x "$LSSA_DIR/target/release/sequencer_service" ]; then + SEQUENCER_BIN="$LSSA_DIR/target/release/sequencer_service" +elif [ -x "$LSSA_DIR/target/debug/sequencer_service" ]; then + SEQUENCER_BIN="$LSSA_DIR/target/debug/sequencer_service" +else + warn "sequencer_service not found — will skip deploy/submit steps" +fi + +# ─── Step 1: Scaffold project ──────────────────────────────────────────── + +log "Step 1: Scaffolding project..." +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" "$LOG_DIR" +cd "$WORK_DIR" + +spel init "$PROJECT_NAME" > "$LOG_DIR/init.log" 2>&1 || fail "spel init failed (see $LOG_DIR/init.log)" +cd "$PROJECT_NAME" + +# Verify scaffold structure +[ -f "Cargo.toml" ] || fail "Missing Cargo.toml" +[ -f "Makefile" ] || fail "Missing Makefile" +[ -d "methods/guest/src/bin" ] || fail "Missing guest binary dir" +log " ✅ Project scaffolded" + +# ─── Step 2: Build guest binary ─────────────────────────────────────────── + +log "Step 2: Building guest binary (this may take a while)..." +make build > "$LOG_DIR/build.log" 2>&1 || fail "Guest build failed (see $LOG_DIR/build.log)" + +GUEST_BIN=$(find . -name "*.bin" -path "*/riscv32im*" | head -1) +[ -n "$GUEST_BIN" ] || fail "No guest binary found after build" +log " ✅ Guest binary built: $GUEST_BIN" + +# ─── Step 3: Generate IDL ──────────────────────────────────────────────── + +log "Step 3: Generating IDL..." +make idl > "$LOG_DIR/idl.log" 2>&1 || fail "IDL generation failed (see $LOG_DIR/idl.log)" + +IDL_FILE=$(find . -name "*-idl.json" | head -1) +[ -n "$IDL_FILE" ] || fail "No IDL file found after generation" + +# Validate IDL is valid JSON with instructions +python3 -c " +import json, sys +with open('$IDL_FILE') as f: + idl = json.load(f) +assert 'instructions' in idl, 'IDL missing instructions' +assert len(idl['instructions']) > 0, 'IDL has no instructions' +print(f' IDL: {len(idl[\"instructions\"])} instructions') +" || fail "IDL validation failed" +log " ✅ IDL generated: $IDL_FILE" + +# ─── Step 4: Deploy to sequencer ───────────────────────────────────────── + +if [ -z "$SEQUENCER_BIN" ]; then + warn "Skipping deploy/submit (no sequencer)" + log "Smoke test passed (scaffold + build + IDL only)" + exit 0 +fi + +log "Step 4: Starting sequencer and deploying..." + +# Kill any existing sequencer +pgrep -f 'sequencer_service.*configs' | xargs -r kill 2>/dev/null || true +sleep 1 + +# Clean old state +rm -rf "${LSSA_DIR}/.sequencer_db" "${LSSA_DIR}/rocksdb" + +# Start sequencer with lssa configs +SEQ_CONFIGS="${LSSA_DIR}/sequencer/service/configs/debug" +if [ ! -d "$SEQ_CONFIGS" ]; then + fail "Sequencer configs not found at $SEQ_CONFIGS" +fi + +cd "$LSSA_DIR" +RUST_LOG=info $SEQUENCER_BIN "$SEQ_CONFIGS/sequencer_config.json" > "$LOG_DIR/sequencer.log" 2>&1 & +SEQ_PID=$! +cd "$WORK_DIR/$PROJECT_NAME" + +# Wait for sequencer to be ready (up to 60s) +log " Waiting for sequencer (PID $SEQ_PID)..." +for i in $(seq 1 60); do + if curl -s -o /dev/null -w '%{http_code}' "$SEQUENCER_URL" 2>/dev/null | grep -qE '(200|405)'; then + log " Sequencer up after ${i}s" + break + fi + if ! kill -0 "$SEQ_PID" 2>/dev/null; then + fail "Sequencer died (see $LOG_DIR/sequencer.log)" + fi + sleep 1 +done + +if ! curl -s -o /dev/null -w '%{http_code}' "$SEQUENCER_URL" 2>/dev/null | grep -qE '(200|405)'; then + fail "Sequencer failed to start after 60s (see $LOG_DIR/sequencer.log)" +fi + +# Deploy using wallet CLI (same as `make deploy`) +GUEST_BIN_ABS="$(cd "$(dirname "$GUEST_BIN")" && pwd)/$(basename "$GUEST_BIN")" +IDL_FILE_ABS="$(cd "$(dirname "$IDL_FILE")" && pwd)/$(basename "$IDL_FILE")" + +WALLET_BIN="" +if command -v wallet >/dev/null 2>&1; then + WALLET_BIN="wallet" +elif [ -x "$LSSA_DIR/target/release/wallet" ]; then + WALLET_BIN="$LSSA_DIR/target/release/wallet" +elif [ -x "$LSSA_DIR/target/debug/wallet" ]; then + WALLET_BIN="$LSSA_DIR/target/debug/wallet" +else + warn "wallet CLI not found — skipping deploy/submit" + log "Smoke test passed (scaffold + build + IDL + sequencer start)" + exit 0 +fi + +export NSSA_WALLET_HOME_DIR="${NSSA_WALLET_HOME_DIR:-${LSSA_DIR}/wallet/configs/debug}" +WALLET_PASSWORD="${WALLET_PASSWORD:-test}" + +# Wallet needs password on stdin; first run creates storage +printf '%s\n' "$WALLET_PASSWORD" | $WALLET_BIN deploy-program "$GUEST_BIN_ABS" > "$LOG_DIR/deploy.log" 2>&1 \ + || fail "Deploy failed (see $LOG_DIR/deploy.log)" +log " ✅ Program deployed" + +# ─── Step 5: Submit a transaction ───────────────────────────────────────── + +log "Step 5: Submitting test transaction..." + +# Get the first instruction name from IDL +FIRST_IX=$(python3 -c " +import json +with open('$IDL_FILE') as f: + idl = json.load(f) +print(idl['instructions'][0]['name']) +") + +# Try submitting the first instruction (may fail if it needs specific args — that's OK) +SEQUENCER_URL="$SEQUENCER_URL" spel --idl "$IDL_FILE_ABS" -p "$GUEST_BIN_ABS" \ + "$FIRST_IX" > "$LOG_DIR/submit.log" 2>&1 \ + && log " ✅ Transaction submitted" \ + || warn "Submit failed (may need args — see $LOG_DIR/submit.log). Deploy was successful." + +# ─── Done ───────────────────────────────────────────────────────────────── + +log "" +log "🎉 Smoke test PASSED!" +log " Project: $WORK_DIR/$PROJECT_NAME" +log " Guest: $GUEST_BIN" +log " IDL: $IDL_FILE" +log " Logs: $LOG_DIR/" diff --git a/spel-cli/Cargo.toml b/spel-cli/Cargo.toml new file mode 100644 index 00000000..ebdd0808 --- /dev/null +++ b/spel-cli/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "spel" +version = "0.2.0" +edition = "2021" +description = "Generic IDL-driven CLI for SPEL programs" + +[[bin]] +name = "spel" +path = "src/bin/main.rs" + +[dependencies] +spel-framework-core = { path = "../spel-framework-core", features = ["idl-gen"] } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b" } +nssa = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b" } +common = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b" } +sequencer_service_rpc = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b", features = ["client"] } +wallet = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b" } +risc0-zkvm = { version = "3.0.3", features = ["std"] } +base58 = "0.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +borsh = "1.5" +tokio = { version = "1.28.2", features = ["net", "rt-multi-thread", "sync", "macros"] } +hex = "0.4" diff --git a/spel-cli/src/account_inspect.rs b/spel-cli/src/account_inspect.rs new file mode 100644 index 00000000..c9db6d70 --- /dev/null +++ b/spel-cli/src/account_inspect.rs @@ -0,0 +1,290 @@ +//! Account data inspection: fetch from sequencer, borsh-decode using IDL types, +//! and pretty-print as JSON. + +use spel_framework_core::idl::{IdlEnumVariant, IdlField, IdlType, IdlTypeDef, SpelIdl}; +use serde_json::{json, Value}; +use std::process; + +use crate::hex::{decode_bytes_32, hex_decode, hex_encode}; + +/// Inspect an on-chain account: fetch its data, borsh-decode it using the IDL +/// type definition, and print the result as JSON. +pub async fn inspect_account( + account_id_str: &str, + idl: &SpelIdl, + type_name: &str, + data_hex: Option<&str>, +) { + // Parse account ID (base58 or hex) + let account_bytes = decode_bytes_32(account_id_str).unwrap_or_else(|e| { + eprintln!("Invalid account ID '{}': {}", account_id_str, e); + process::exit(1); + }); + let account_id = nssa::AccountId::new(account_bytes); + + // Get raw account data: from --data flag or from sequencer + let data = if let Some(hex) = data_hex { + hex_decode(hex).unwrap_or_else(|e| { + eprintln!("Invalid --data hex: {}", e); + process::exit(1); + }) + } else { + fetch_account_data(account_id).await + }; + + eprintln!("Account: {}", account_id); + eprintln!("Data: {} bytes", data.len()); + eprintln!("Hex: {}", hex_encode(&data)); + eprintln!(); + + if data.is_empty() { + eprintln!("Account data is empty (account may not exist or has no data)."); + process::exit(1); + } + + // Find type definition in IDL + let type_def = find_type_def(idl, type_name).unwrap_or_else(|| { + eprintln!("Type '{}' not found in IDL.", type_name); + eprintln!("Available account types:"); + for acc in &idl.accounts { + eprintln!(" {}", acc.name); + } + process::exit(1); + }); + + // Borsh decode + let mut cursor: &[u8] = &data; + match decode_type_def(&mut cursor, type_def, idl) { + Ok(value) => { + let remaining = cursor.len(); + println!("{}", serde_json::to_string_pretty(&value).unwrap()); + if remaining > 0 { + eprintln!("{} trailing bytes after decoding", remaining); + } + } + Err(e) => { + eprintln!("Borsh decode failed: {}", e); + process::exit(1); + } + } +} + +async fn fetch_account_data(account_id: nssa::AccountId) -> Vec { + let wallet_core = wallet::WalletCore::from_env().unwrap_or_else(|e| { + eprintln!("Failed to initialize wallet: {:?}", e); + eprintln!("Set NSSA_WALLET_HOME_DIR or use --data "); + process::exit(1); + }); + + let account = wallet_core + .get_account_public(account_id) + .await + .unwrap_or_else(|e| { + eprintln!("Failed to fetch account {}: {:?}", account_id, e); + process::exit(1); + }); + + account.data.to_vec() +} + +fn find_type_def<'a>(idl: &'a SpelIdl, name: &str) -> Option<&'a IdlTypeDef> { + idl.accounts + .iter() + .find(|a| a.name == name) + .map(|a| &a.type_) +} + +// ── Borsh decoding from IDL types ──────────────────────────────────── + +fn decode_type_def( + cursor: &mut &[u8], + def: &IdlTypeDef, + idl: &SpelIdl, +) -> Result { + match def.kind.as_str() { + "struct" => decode_struct(cursor, &def.fields, idl), + "enum" => decode_enum(cursor, &def.variants, idl), + other => Err(format!("Unknown type kind: {}", other)), + } +} + +fn decode_struct( + cursor: &mut &[u8], + fields: &[IdlField], + idl: &SpelIdl, +) -> Result { + let mut map = serde_json::Map::new(); + for field in fields { + let value = decode_borsh_value(cursor, &field.type_, idl) + .map_err(|e| format!("field '{}': {}", field.name, e))?; + map.insert(field.name.clone(), value); + } + Ok(Value::Object(map)) +} + +fn decode_enum( + cursor: &mut &[u8], + variants: &[IdlEnumVariant], + idl: &SpelIdl, +) -> Result { + let variant_idx = read_u8(cursor)? as usize; + if variant_idx >= variants.len() { + return Err(format!( + "Enum variant index {} out of range (max {})", + variant_idx, + variants.len() - 1 + )); + } + let variant = &variants[variant_idx]; + if variant.fields.is_empty() { + Ok(json!(variant.name)) + } else { + let mut map = serde_json::Map::new(); + for field in &variant.fields { + let value = decode_borsh_value(cursor, &field.type_, idl)?; + map.insert(field.name.clone(), value); + } + Ok(json!({ &variant.name: map })) + } +} + +fn decode_borsh_value( + cursor: &mut &[u8], + ty: &IdlType, + idl: &SpelIdl, +) -> Result { + match ty { + IdlType::Primitive(name) => decode_primitive(cursor, name), + IdlType::Array { + array: (inner, len), + } => { + // [u8; N] → hex string + if matches!(inner.as_ref(), IdlType::Primitive(s) if s == "u8") { + let mut buf = vec![0u8; *len]; + read_exact(cursor, &mut buf)?; + Ok(json!(hex_encode(&buf))) + } else { + let mut arr = Vec::with_capacity(*len); + for _ in 0..*len { + arr.push(decode_borsh_value(cursor, inner, idl)?); + } + Ok(json!(arr)) + } + } + IdlType::Vec { vec: inner } => { + let len = read_u32(cursor)? as usize; + // Vec → hex string + if matches!(inner.as_ref(), IdlType::Primitive(s) if s == "u8") { + let mut buf = vec![0u8; len]; + read_exact(cursor, &mut buf)?; + Ok(json!(hex_encode(&buf))) + } else { + let mut arr = Vec::with_capacity(len); + for _ in 0..len { + arr.push(decode_borsh_value(cursor, inner, idl)?); + } + Ok(json!(arr)) + } + } + IdlType::Option { option: inner } => { + let tag = read_u8(cursor)?; + match tag { + 0 => Ok(Value::Null), + 1 => decode_borsh_value(cursor, inner, idl), + _ => Err(format!("Invalid Option tag: {}", tag)), + } + } + IdlType::Defined { defined: name } => match find_type_def(idl, name) { + Some(def) => decode_type_def(cursor, def, idl), + None => Err(format!("Undefined type: {}", name)), + }, + } +} + +fn decode_primitive(cursor: &mut &[u8], name: &str) -> Result { + match name { + "u8" => Ok(json!(read_u8(cursor)?)), + "u16" => Ok(json!(read_u16(cursor)?)), + "u32" => Ok(json!(read_u32(cursor)?)), + "u64" => { + let v = read_u64(cursor)?; + // Use string for u64 to avoid JSON precision loss + Ok(json!(v.to_string())) + } + "u128" => { + let v = read_u128(cursor)?; + Ok(json!(v.to_string())) + } + "i8" => Ok(json!(read_u8(cursor)? as i8)), + "i16" => Ok(json!(read_u16(cursor)? as i16)), + "i32" => Ok(json!(read_u32(cursor)? as i32)), + "i64" => { + let v = read_u64(cursor)? as i64; + Ok(json!(v.to_string())) + } + "i128" => { + let v = read_u128(cursor)? as i128; + Ok(json!(v.to_string())) + } + "bool" => Ok(json!(read_u8(cursor)? != 0)), + "string" => { + let len = read_u32(cursor)? as usize; + let mut buf = vec![0u8; len]; + read_exact(cursor, &mut buf)?; + let s = String::from_utf8(buf).map_err(|e| format!("Invalid UTF-8: {}", e))?; + Ok(json!(s)) + } + "program_id" => { + // ProgramId is [u32; 8] = 32 bytes + let mut buf = [0u8; 32]; + read_exact(cursor, &mut buf)?; + Ok(json!(hex_encode(&buf))) + } + other => Err(format!("Unknown primitive type: {}", other)), + } +} + +// ── Cursor helpers ─────────────────────────────────────────────────── + +fn read_exact(cursor: &mut &[u8], buf: &mut [u8]) -> Result<(), String> { + if cursor.len() < buf.len() { + return Err(format!( + "Unexpected end of data: need {} bytes, have {}", + buf.len(), + cursor.len() + )); + } + buf.copy_from_slice(&cursor[..buf.len()]); + *cursor = &cursor[buf.len()..]; + Ok(()) +} + +fn read_u8(cursor: &mut &[u8]) -> Result { + let mut buf = [0u8; 1]; + read_exact(cursor, &mut buf)?; + Ok(buf[0]) +} + +fn read_u16(cursor: &mut &[u8]) -> Result { + let mut buf = [0u8; 2]; + read_exact(cursor, &mut buf)?; + Ok(u16::from_le_bytes(buf)) +} + +fn read_u32(cursor: &mut &[u8]) -> Result { + let mut buf = [0u8; 4]; + read_exact(cursor, &mut buf)?; + Ok(u32::from_le_bytes(buf)) +} + +fn read_u64(cursor: &mut &[u8]) -> Result { + let mut buf = [0u8; 8]; + read_exact(cursor, &mut buf)?; + Ok(u64::from_le_bytes(buf)) +} + +fn read_u128(cursor: &mut &[u8]) -> Result { + let mut buf = [0u8; 16]; + read_exact(cursor, &mut buf)?; + Ok(u128::from_le_bytes(buf)) +} diff --git a/spel-cli/src/bin/main.rs b/spel-cli/src/bin/main.rs new file mode 100644 index 00000000..9542a40a --- /dev/null +++ b/spel-cli/src/bin/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() { + spel::run().await; +} diff --git a/nssa-framework-cli/src/cli.rs b/spel-cli/src/cli.rs similarity index 93% rename from nssa-framework-cli/src/cli.rs rename to spel-cli/src/cli.rs index 90bf5bee..630a5a34 100644 --- a/nssa-framework-cli/src/cli.rs +++ b/spel-cli/src/cli.rs @@ -1,10 +1,10 @@ //! CLI helpers: help text, argument parsing, string utilities. use std::collections::HashMap; -use nssa_framework_core::idl::{IdlType, IdlInstruction, NssaIdl}; +use spel_framework_core::idl::{IdlType, IdlInstruction, SpelIdl}; /// Print help for all commands derived from the IDL. -pub fn print_help(idl: &NssaIdl, binary_name: &str) { +pub fn print_help(idl: &SpelIdl, binary_name: &str) { println!("🔧 {} v{} — IDL-driven CLI", idl.name, idl.version); println!(); println!("USAGE:"); @@ -18,6 +18,7 @@ pub fn print_help(idl: &NssaIdl, binary_name: &str) { println!(); println!("COMMANDS:"); println!(" inspect [FILE...] Print ProgramId for ELF binary(ies)"); + println!(" generate-idl [PATH] Generate IDL JSON (auto-detects methods/guest/src/bin/ if no path given)"); println!(" idl Print IDL information"); for ix in &idl.instructions { @@ -27,7 +28,7 @@ pub fn print_help(idl: &NssaIdl, binary_name: &str) { .collect(); let acct_desc: Vec = ix.accounts.iter() .filter(|a| a.pda.is_none()) - .map(|a| format!("--{}-account ", snake_to_kebab(&a.name))) + .map(|a| format!("--{} ", snake_to_kebab(&a.name))) .collect(); let all_args: Vec = args_desc.into_iter().chain(acct_desc).collect(); println!(" {:<20} {}", cmd, all_args.join(" ")); @@ -64,7 +65,7 @@ pub fn print_instruction_help(ix: &IdlInstruction) { } for acc in &ix.accounts { if acc.pda.is_none() { - println!(" --{}-account Account ID for '{}' (64 hex chars)", snake_to_kebab(&acc.name), acc.name); + println!(" --{:<25} Account ID for '{}'", snake_to_kebab(&acc.name), acc.name); } } } diff --git a/spel-cli/src/generate_idl.rs b/spel-cli/src/generate_idl.rs new file mode 100644 index 00000000..07ce4ab6 --- /dev/null +++ b/spel-cli/src/generate_idl.rs @@ -0,0 +1,294 @@ +//! Source file discovery for `generate-idl`. +//! +//! The CLI calls [`discover_sources`] to turn an optional path argument into +//! a concrete list of `.rs` files. The library crate (`spel-framework-core`) +//! only ever receives a single resolved path. +//! +//! ## Resolution (no argument) +//! Searches `./methods/guest/src/bin/*.rs` in the current working directory. +//! +//! ## Resolution (argument given) +//! - `.rs` file → used directly (backwards-compatible). +//! - directory → `/methods/guest/src/bin/*.rs` is searched. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Resolve the list of SPEL program source files for IDL generation. +/// +/// `arg` is the optional positional argument passed to `generate-idl`: +/// - `None` → auto-detect from `./methods/guest/src/bin/` +/// - `Some("*.rs")` → that file only +/// - `Some(dir)` → `/methods/guest/src/bin/*.rs` +/// +/// Returns an error string when no sources can be found. +pub fn discover_sources(arg: Option<&str>) -> Result, String> { + match arg { + Some(p) => { + let path = PathBuf::from(p); + if path.extension().map_or(false, |e| e == "rs") { + if !path.exists() { + return Err(format!("File not found: {}", p)); + } + Ok(vec![path]) + } else if path.is_dir() { + let sources = search_methods_dir(&path)?; + if sources.is_empty() { + Err(format!( + "No .rs files found in '{}/methods/guest/src/bin/'.\n\ + Pass a .rs file directly instead.", + p + )) + } else { + Ok(sources) + } + } else { + Err(format!("'{}' is not a .rs file or a directory", p)) + } + } + None => { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + let sources = search_methods_dir(&cwd)?; + if !sources.is_empty() { + return Ok(sources); + } + Err( + "No SPEL program sources found.\n\ + Searched: ./methods/guest/src/bin/*.rs\n\ + \n\ + Options:\n\ + - Run from your project root (where 'methods/' lives)\n\ + - Pass a project directory: generate-idl \n\ + - Pass a source file: generate-idl " + .to_string(), + ) + } + } +} + +/// Scan `/methods/guest/src/bin/*.rs`. Returns an empty vec — not an +/// error — when the directory doesn't exist. +pub fn search_methods_dir(root: &Path) -> Result, String> { + let bin_dir = root.join("methods").join("guest").join("src").join("bin"); + if !bin_dir.exists() { + return Ok(vec![]); + } + let entries = fs::read_dir(&bin_dir) + .map_err(|e| format!("Cannot read {}: {}", bin_dir.display(), e))?; + let mut sources: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().map_or(false, |e| e == "rs")) + .collect(); + sources.sort(); + Ok(sources) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + /// Self-cleaning temporary directory. + struct TempDir(PathBuf); + + impl TempDir { + fn new(label: &str) -> Self { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("spel-idl-test-{}-{}", label, n)); + fs::create_dir_all(&path).unwrap(); + TempDir(path) + } + + fn path(&self) -> &Path { + &self.0 + } + + fn write(&self, rel: &str, content: &str) -> PathBuf { + let p = self.0.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(&p, content).unwrap(); + p + } + + /// Write a minimal valid SPEL program to `methods/guest/src/bin/.rs`. + fn write_program(&self, name: &str) -> PathBuf { + self.write( + &format!("methods/guest/src/bin/{}.rs", name), + &format!( + "#[lez_program]\npub mod {name} {{\n \ + #[instruction]\n \ + pub fn init(acc: AccountWithMetadata) {{}}\n}}\n" + ), + ) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + // ── search_methods_dir ────────────────────────────────────────────────── + + #[test] + fn methods_dir_absent_returns_empty() { + let tmp = TempDir::new("absent"); + let sources = search_methods_dir(tmp.path()).unwrap(); + assert!(sources.is_empty()); + } + + #[test] + fn methods_dir_single_program() { + let tmp = TempDir::new("single"); + let expected = tmp.write_program("my_prog"); + let sources = search_methods_dir(tmp.path()).unwrap(); + assert_eq!(sources, vec![expected]); + } + + #[test] + fn methods_dir_multiple_programs_sorted() { + let tmp = TempDir::new("multi"); + tmp.write_program("beta"); + tmp.write_program("alpha"); + let sources = search_methods_dir(tmp.path()).unwrap(); + assert_eq!(sources.len(), 2); + assert!(sources[0].ends_with("alpha.rs")); + assert!(sources[1].ends_with("beta.rs")); + } + + #[test] + fn methods_dir_ignores_non_rs_files() { + let tmp = TempDir::new("non-rs"); + tmp.write_program("prog"); + tmp.write("methods/guest/src/bin/README.md", "# readme"); + tmp.write("methods/guest/src/bin/data.bin", "binary"); + let sources = search_methods_dir(tmp.path()).unwrap(); + assert_eq!(sources.len(), 1); + assert!(sources[0].ends_with("prog.rs")); + } + + // ── discover_sources — explicit .rs file ─────────────────────────────── + + #[test] + fn explicit_rs_file_accepted() { + let tmp = TempDir::new("explicit-ok"); + let file = tmp.write("program.rs", "fn main() {}"); + let sources = discover_sources(Some(file.to_str().unwrap())).unwrap(); + assert_eq!(sources, vec![file]); + } + + #[test] + fn explicit_rs_file_missing_errors() { + let tmp = TempDir::new("explicit-missing"); + let missing = tmp.path().join("does_not_exist.rs"); + let err = discover_sources(Some(missing.to_str().unwrap())).unwrap_err(); + assert!(err.contains("not found"), "unexpected error: {err}"); + } + + // ── discover_sources — directory argument ────────────────────────────── + + #[test] + fn directory_with_methods_finds_program() { + let tmp = TempDir::new("dir-ok"); + let expected = tmp.write_program("vault"); + let sources = discover_sources(Some(tmp.path().to_str().unwrap())).unwrap(); + assert_eq!(sources, vec![expected]); + } + + #[test] + fn directory_with_multiple_programs() { + let tmp = TempDir::new("dir-multi"); + tmp.write_program("alpha"); + tmp.write_program("beta"); + let sources = discover_sources(Some(tmp.path().to_str().unwrap())).unwrap(); + assert_eq!(sources.len(), 2); + } + + #[test] + fn directory_without_methods_errors() { + let tmp = TempDir::new("dir-empty"); + let err = discover_sources(Some(tmp.path().to_str().unwrap())).unwrap_err(); + assert!(err.contains("No .rs files found"), "unexpected error: {err}"); + } + + #[test] + fn non_rs_non_dir_path_errors() { + let tmp = TempDir::new("invalid"); + let file = tmp.write("archive.tar", "data"); + let err = discover_sources(Some(file.to_str().unwrap())).unwrap_err(); + assert!( + err.contains("not a .rs file or a directory"), + "unexpected error: {err}" + ); + } + + // ── end-to-end round-trips ───────────────────────────────────────────── + + #[test] + fn explicit_file_round_trip() { + use spel_framework_core::idl_gen::generate_idl_from_file; + + let tmp = TempDir::new("roundtrip-file"); + let file = tmp.write( + "token.rs", + r#" + #[lez_program] + pub mod token { + #[instruction] + pub fn transfer( + #[account(signer)] sender: AccountWithMetadata, + recipient: AccountWithMetadata, + amount: u64, + ) -> SpelResult { todo!() } + } + "#, + ); + + let sources = discover_sources(Some(file.to_str().unwrap())).unwrap(); + assert_eq!(sources.len(), 1); + + let idl = generate_idl_from_file(&sources[0]).unwrap(); + assert_eq!(idl.name, "token"); + assert_eq!(idl.instructions.len(), 1); + assert_eq!(idl.instructions[0].name, "transfer"); + assert_eq!(idl.instructions[0].accounts.len(), 2); + assert!(idl.instructions[0].accounts[0].signer); + assert_eq!(idl.instructions[0].args.len(), 1); + assert_eq!(idl.instructions[0].args[0].name, "amount"); + } + + #[test] + fn directory_discovery_round_trip() { + use spel_framework_core::idl_gen::generate_idl_from_file; + + let tmp = TempDir::new("roundtrip-dir"); + tmp.write( + "methods/guest/src/bin/counter.rs", + r#" + #[lez_program] + pub mod counter { + #[instruction] + pub fn increment( + #[account(mut, pda = literal("count"))] + state: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + ) -> SpelResult { todo!() } + } + "#, + ); + + let sources = discover_sources(Some(tmp.path().to_str().unwrap())).unwrap(); + assert_eq!(sources.len(), 1); + + let idl = generate_idl_from_file(&sources[0]).unwrap(); + assert_eq!(idl.name, "counter"); + assert!(idl.instructions[0].accounts[0].writable); + assert!(idl.instructions[0].accounts[0].pda.is_some()); + assert!(idl.instructions[0].accounts[1].signer); + } +} diff --git a/spel-cli/src/hex.rs b/spel-cli/src/hex.rs new file mode 100644 index 00000000..6edbe0d4 --- /dev/null +++ b/spel-cli/src/hex.rs @@ -0,0 +1,116 @@ +//! Hex encoding/decoding utilities. + +use base58::FromBase58; + +pub fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +pub fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err(format!("Hex string has odd length: {}", hex.len())); + } + let mut bytes = Vec::with_capacity(hex.len() / 2); + for i in (0..hex.len()).step_by(2) { + let byte = u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|e| format!("Invalid hex at position {}: {}", i, e))?; + bytes.push(byte); + } + Ok(bytes) +} + +/// Decode a 32-byte value from base58 or hex string. +/// Strips "Public/" or "Private/" prefix if present before decoding. +pub fn decode_bytes_32(input: &str) -> Result<[u8; 32], String> { + let input = input + .strip_prefix("Public/") + .or_else(|| input.strip_prefix("Private/")) + .unwrap_or(input); + + if let Ok(bytes) = input.from_base58() { + if bytes.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + return Ok(arr); + } + return Err(format!( + "Base58 decoded to {} bytes, expected 32", + bytes.len() + )); + } + + let hex = input + .strip_prefix("0x") + .or_else(|| input.strip_prefix("0X")) + .unwrap_or(input); + let bytes = hex_decode(hex)?; + if bytes.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(arr) + } else { + Err(format!( + "Expected 32 bytes, got {} (provide base58 or 64 hex chars)", + bytes.len() + )) + } +} + +/// Parse an account ID, returning the decoded bytes and whether it had a "Private/" prefix. +pub fn parse_account_id(input: &str) -> Result<([u8; 32], bool), String> { + let is_private = input.starts_with("Private/"); + let bytes = decode_bytes_32(input)?; + Ok((bytes, is_private)) +} + + + + +#[cfg(test)] +mod tests { + use super::*; + + fn test_hex() -> String { + // Use 0x prefix to force hex (not base58) decoding + format!("0x{}", "ab".repeat(32)) + } + + #[test] + fn test_parse_account_id_not_private() { + let (bytes, is_priv) = parse_account_id(&test_hex()).unwrap(); + assert_eq!(bytes, [0xab; 32]); + assert!(!is_priv); + } + + #[test] + fn test_parse_account_id_private_prefix_hex() { + let input = format!("Private/{}", test_hex()); + let (bytes, is_priv) = parse_account_id(&input).unwrap(); + assert_eq!(bytes, [0xab; 32]); + assert!(is_priv, "Private/ prefix should set is_priv=true"); + } + + #[test] + fn test_parse_account_id_public_prefix_not_private() { + let input = format!("Public/{}", test_hex()); + let (_, is_priv) = parse_account_id(&input).unwrap(); + assert!(!is_priv, "Public/ prefix should not set is_priv"); + } + + #[test] + fn test_decode_bytes_32_strips_private_prefix() { + let with_prefix = format!("Private/{}", test_hex()); + let without = decode_bytes_32(&with_prefix).unwrap(); + let direct = decode_bytes_32(&test_hex()).unwrap(); + assert_eq!(without, direct); + } + + #[test] + fn test_parse_account_id_private_prefix_0x() { + let hex = format!("0x{}", "cd".repeat(32)); + let input = format!("Private/{}", hex); + let (bytes, is_priv) = parse_account_id(&input).unwrap(); + assert_eq!(bytes, [0xcd; 32]); + assert!(is_priv); + } +} diff --git a/nssa-framework-cli/src/init.rs b/spel-cli/src/init.rs similarity index 75% rename from nssa-framework-cli/src/init.rs rename to spel-cli/src/init.rs index 985e7330..c685a5f2 100644 --- a/nssa-framework-cli/src/init.rs +++ b/spel-cli/src/init.rs @@ -1,4 +1,4 @@ -//! Project scaffolding: `nssa-cli init ` +//! Project scaffolding: `spel init ` use std::fs; use std::path::Path; @@ -10,9 +10,19 @@ pub fn init_project(name: &str) { std::process::exit(1); } - println!("🚀 Creating NSSA project '{}'...", name); + // Extract just the directory name for use as the project name, + // so absolute paths like "/tmp/my-project" yield "my-project". + let project_name = root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_else(|| { + eprintln!("❌ Could not extract project name from '{}'", name); + std::process::exit(1); + }); + + println!("🚀 Creating SPEL project '{}'...", project_name); - let snake_name = name.replace('-', "_"); + let snake_name = project_name.replace('-', "_"); // Create directories let dirs = [ @@ -52,7 +62,7 @@ methods/guest/target/ "#)); // Makefile - write_file(root, "Makefile", &format!(r#"# {name} — NSSA Program + write_file(root, "Makefile", &format!(r#"# {project_name} — SPEL Program # # Quick start: # make build idl deploy setup @@ -61,7 +71,7 @@ methods/guest/target/ SHELL := /bin/bash STATE_FILE := .{snake_name}-state -IDL_FILE := {name}-idl.json +IDL_FILE := {project_name}-idl.json PROGRAMS_DIR := methods/guest/target/riscv32im-risc0-zkvm-elf/docker PROGRAM_BIN := $(PROGRAMS_DIR)/{snake_name}.bin @@ -77,7 +87,7 @@ endef .PHONY: help build idl cli deploy setup inspect status clean help: ## Show this help - @echo "{name} — NSSA Program" + @echo "{project_name} — SPEL Program" @echo "" @echo " make build Build the guest binary (needs risc0 toolchain)" @echo " make idl Generate IDL from program source" @@ -123,7 +133,7 @@ setup: ## Create accounts needed for the program @echo "✅ Account saved to $(STATE_FILE)" status: ## Show saved state and binary info - @echo "{name} Status" + @echo "{project_name} Status" @echo "──────────────────────────────────────" @if [ -f "$(STATE_FILE)" ]; then cat $(STATE_FILE); else echo "(no state — run 'make setup')"; fi @echo "" @@ -139,9 +149,9 @@ clean: ## Remove saved state "#)); // README - write_file(root, "README.md", &format!(r#"# {name} + write_file(root, "README.md", &format!(r#"# {project_name} -An NSSA/LEZ program built with [nssa-framework](https://github.com/jimmy-claw/nssa-framework). +A SPEL program built with [spel-framework](https://github.com/logos-co/spel). ## Prerequisites @@ -155,7 +165,7 @@ An NSSA/LEZ program built with [nssa-framework](https://github.com/jimmy-claw/ns # 1. Build the guest binary make build -# 2. Generate the IDL (auto-extracts from #[nssa_program] annotations) +# 2. Generate the IDL (auto-extracts from #[lez_program] annotations) make idl # 3. Deploy to sequencer @@ -189,7 +199,7 @@ make cli ARGS="--dry-run -p methods/guest/target/riscv32im-risc0-zkvm-elf/docker ## Project Structure ``` -{name}/ +{project_name}/ ├── {snake_name}_core/ # Shared types (used by guest + host) │ └── src/lib.rs ├── methods/ @@ -200,12 +210,12 @@ make cli ARGS="--dry-run -p methods/guest/target/riscv32im-risc0-zkvm-elf/docker │ ├── generate_idl.rs # One-liner IDL generator │ └── {snake_name}_cli.rs # Three-line CLI wrapper ├── Makefile -└── {name}-idl.json # Auto-generated IDL +└── {project_name}-idl.json # Auto-generated IDL ``` ## How It Works -The `#[nssa_program]` macro in your guest binary defines your on-chain program. +The `#[lez_program]` macro in your guest binary defines your on-chain program. The framework automatically: 1. **Generates an `Instruction` enum** from your function signatures @@ -224,6 +234,7 @@ edition = "2021" [dependencies] serde = {{ version = "1.0", features = ["derive"] }} borsh = "1.5" + "#)); write_file(root, &format!("{}_core/src/lib.rs", snake_name), r#"use serde::{Deserialize, Serialize}; @@ -243,10 +254,10 @@ version = "0.1.0" edition = "2021" [build-dependencies] -risc0-build = "3.0" +risc0-build = "=3.0.5" [dependencies] -risc0-zkvm = {{ version = "3.0.3", features = ["std"] }} +risc0-zkvm = {{ version = "=3.0.5", features = ["std"] }} {snake_name}_core = {{ path = "../{snake_name}_core" }} "#)); @@ -273,25 +284,23 @@ name = "{snake_name}" path = "src/bin/{snake_name}.rs" [dependencies] -nssa-framework = {{ git = "https://github.com/jimmy-claw/nssa-framework.git" }} -nssa-framework-core = {{ git = "https://github.com/jimmy-claw/nssa-framework.git" }} -nssa_core = {{ git = "https://github.com/logos-blockchain/lssa.git", branch = "schouhy/full-bedrock-integration" }} -risc0-zkvm = {{ version = "3.0.3", default-features = false }} +spel-framework = {{ git = "https://github.com/logos-co/spel.git" }} +nssa_core = {{ git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b" }} +risc0-zkvm = {{ version = "=3.0.5", features = ["std"] }} {snake_name}_core = {{ path = "../../{snake_name}_core" }} serde = {{ version = "1.0", features = ["derive"] }} borsh = "1.5" + "#)); // Guest program skeleton write_file(root, &format!("methods/guest/src/bin/{}.rs", snake_name), &format!(r#"#![no_main] -use nssa_core::account::AccountWithMetadata; -use nssa_core::program::AccountPostState; -use nssa_framework::prelude::*; +use spel_framework::prelude::*; risc0_zkvm::guest::entry!(main); -#[nssa_program] +#[lez_program] mod {snake_name} {{ #[allow(unused_imports)] use super::*; @@ -303,9 +312,9 @@ mod {snake_name} {{ state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, - ) -> NssaResult {{ + ) -> SpelResult {{ // TODO: implement initialization logic - Ok(NssaOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new_claimed(state.account.clone()), AccountPostState::new(owner.account.clone()), ])) @@ -319,9 +328,9 @@ mod {snake_name} {{ #[account(signer)] owner: AccountWithMetadata, amount: u64, - ) -> NssaResult {{ + ) -> SpelResult {{ // TODO: implement your logic - Ok(NssaOutput::states_only(vec![ + Ok(SpelOutput::states_only(vec![ AccountPostState::new(state.account.clone()), AccountPostState::new(owner.account.clone()), ])) @@ -344,32 +353,58 @@ name = "{snake_name}_cli" path = "src/bin/{snake_name}_cli.rs" [dependencies] -nssa-framework = {{ git = "https://github.com/jimmy-claw/nssa-framework.git" }} -nssa-framework-core = {{ git = "https://github.com/jimmy-claw/nssa-framework.git" }} -nssa-framework-cli = {{ git = "https://github.com/jimmy-claw/nssa-framework.git" }} +spel-framework = {{ git = "https://github.com/logos-co/spel.git" }} +nssa_core = {{ git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b" }} +spel = {{ git = "https://github.com/logos-co/spel.git" }} {snake_name}_core = {{ path = "../{snake_name}_core" }} serde_json = "1.0" tokio = {{ version = "1.28.2", features = ["net", "rt-multi-thread", "sync", "macros"] }} "#)); // generate_idl.rs - write_file(root, "examples/src/bin/generate_idl.rs", &format!(r#"/// Generate IDL JSON for the {name} program. + write_file(root, "examples/src/bin/generate_idl.rs", &format!(r#"/// Generate IDL JSON for the {project_name} program. /// /// Usage: -/// cargo run --bin generate_idl > {name}-idl.json +/// cargo run --bin generate_idl > {project_name}-idl.json -nssa_framework::generate_idl!("../methods/guest/src/bin/{snake_name}.rs"); +spel_framework::generate_idl!("../methods/guest/src/bin/{snake_name}.rs"); "#)); // CLI wrapper write_file(root, &format!("examples/src/bin/{}_cli.rs", snake_name), r#"#[tokio::main] async fn main() { - nssa_framework_cli::run().await; + spel::run().await; } "#); println!(); - println!("✅ Project '{}' created!", name); + // Generate Cargo.lock for the guest to pin dependency versions + // (prevents getrandom 0.3.x breakage in Docker builds) + let guest_dir = root.join("methods/guest"); + let status = std::process::Command::new("cargo") + .arg("generate-lockfile") + .current_dir(&guest_dir) + .status(); + match status { + Ok(s) if s.success() => {} + Ok(s) => eprintln!("⚠ïļ cargo generate-lockfile exited with: {}", s), + Err(e) => eprintln!("⚠ïļ Failed to generate Cargo.lock (cargo not found?): {}", e), + } + + // Generate Cargo.lock for the guest to pin dependency versions + // (prevents getrandom 0.3.x resolution issues in Docker builds) + let guest_dir = root.join("methods/guest"); + let status = std::process::Command::new("cargo") + .arg("generate-lockfile") + .current_dir(&guest_dir) + .status(); + match status { + Ok(s) if s.success() => {} + Ok(s) => eprintln!("⚠ïļ cargo generate-lockfile exited with {}", s), + Err(e) => eprintln!("⚠ïļ Could not run cargo generate-lockfile: {}", e), + } + + println!("✅ Project '{}' created!", project_name); println!(); println!("Next steps:"); println!(" cd {}", name); diff --git a/nssa-framework-cli/src/inspect.rs b/spel-cli/src/inspect.rs similarity index 95% rename from nssa-framework-cli/src/inspect.rs rename to spel-cli/src/inspect.rs index a9c5d4b2..31be286d 100644 --- a/nssa-framework-cli/src/inspect.rs +++ b/spel-cli/src/inspect.rs @@ -7,7 +7,7 @@ use std::fs; /// Inspect one or more ELF binary files and print their ProgramIds. pub fn inspect_binaries(paths: &[String]) { if paths.is_empty() { - eprintln!("Usage: nssa-cli inspect [FILE...]"); + eprintln!("Usage: spel inspect [FILE...]"); eprintln!(" Prints the ProgramId ([u32; 8]) for each ELF binary."); std::process::exit(1); } diff --git a/spel-cli/src/lib.rs b/spel-cli/src/lib.rs new file mode 100644 index 00000000..2421c7c5 --- /dev/null +++ b/spel-cli/src/lib.rs @@ -0,0 +1,476 @@ +//! Generic IDL-driven CLI library for SPEL programs. +//! +//! Provides: +//! - IDL parsing and type-aware argument handling +//! - risc0-compatible serialization +//! - Transaction building and submission +//! - PDA computation from IDL seeds +//! - Binary inspection (ProgramId extraction) +//! +//! Use `run()` for a complete CLI entry point, or import individual modules. + +pub mod hex; +pub mod parse; +pub mod serialize; +pub mod pda; +pub mod tx; +pub mod inspect; +pub mod account_inspect; +pub mod cli; +pub mod init; +pub mod generate_idl; + +use cli::{print_help, parse_instruction_args, snake_to_kebab}; +use init::init_project; +use inspect::inspect_binaries; +use tx::execute_instruction; +use pda::compute_pda_from_seeds; +use spel_framework_core::idl::{SpelIdl, IdlSeed}; +use parse::ParsedValue; +use std::collections::HashMap; +use std::{env, fs, process}; + +/// Run the generic IDL-driven CLI. Call this from your program's main(): +/// +/// ```no_run +/// #[tokio::main] +/// async fn main() { +/// spel::run().await; +/// } +/// ``` +pub async fn run() { + let args: Vec = env::args().collect(); + + let mut idl_path = String::new(); + let mut program_path = "program.bin".to_string(); + let mut program_id_hex: Option = None; + let mut dry_run = false; + let mut type_name: Option = None; + let mut data_hex: Option = None; + let mut extra_bins: HashMap = HashMap::new(); + let mut remaining_args: Vec = vec![args[0].clone()]; + let mut i = 1; + + while i < args.len() { + match args[i].as_str() { + "--idl" | "-i" => { + i += 1; + if i < args.len() { idl_path = args[i].clone(); } + } + "--program" | "-p" => { + i += 1; + if i < args.len() { program_path = args[i].clone(); } + } + "--program-id" => { + i += 1; + if i < args.len() { program_id_hex = Some(args[i].clone()); } + } + "--type" | "-t" => { + i += 1; + if i < args.len() { type_name = Some(args[i].clone()); } + } + "--data" | "-d" => { + i += 1; + if i < args.len() { data_hex = Some(args[i].clone()); } + } + "--dry-run" => { dry_run = true; } + s if s.starts_with("--bin-") => { + let name = s.strip_prefix("--bin-").unwrap().to_string(); + i += 1; + if i < args.len() { + extra_bins.insert(format!("{}-program-id", name), args[i].clone()); + } + } + _ => remaining_args.push(args[i].clone()), + } + i += 1; + } + + // Handle commands that don't need an IDL + if let Some(cmd) = remaining_args.get(1).map(|s| s.as_str()) { + match cmd { + "init" => { + let name = remaining_args.get(2).unwrap_or_else(|| { + eprintln!("Usage: {} init ", args[0]); + process::exit(1); + }); + init_project(name); + return; + } + "inspect" if type_name.is_none() && data_hex.is_none() && idl_path.is_empty() => { + inspect_binaries(&remaining_args[2..]); + return; + } + "inspect" => { + // Account inspection mode: --type and --idl required + if idl_path.is_empty() { + eprintln!("Account inspection requires --idl "); + process::exit(1); + } + if type_name.is_none() { + eprintln!("Account inspection requires --type "); + process::exit(1); + } + let account_id = remaining_args.get(2).unwrap_or_else(|| { + eprintln!("Usage: {} inspect --idl --type [--data ]", args[0]); + process::exit(1); + }); + let idl_content = match fs::read_to_string(&idl_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading IDL '{}': {}", idl_path, e); + process::exit(1); + } + }; + let idl: SpelIdl = serde_json::from_str(&idl_content).unwrap_or_else(|e| { + eprintln!("Error parsing IDL: {}", e); + process::exit(1); + }); + account_inspect::inspect_account( + account_id, + &idl, + type_name.as_ref().unwrap(), + data_hex.as_deref(), + ).await; + return; + } + "generate-idl" => { + use spel_framework_core::idl_gen::generate_idl_from_file; + use generate_idl::discover_sources; + + let arg = remaining_args.get(2).map(|s| s.as_str()); + let sources = discover_sources(arg).unwrap_or_else(|e| { + eprintln!("Error: {}", e); + process::exit(1); + }); + + if sources.len() == 1 { + match generate_idl_from_file(&sources[0]) { + Ok(idl) => println!("{}", serde_json::to_string_pretty(&idl).unwrap()), + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + } + } else { + // Multiple programs: write -idl.json for each + let mut had_error = false; + for source in &sources { + match generate_idl_from_file(source) { + Ok(idl) => { + let out_name = format!("{}-idl.json", idl.name); + match fs::write(&out_name, serde_json::to_string_pretty(&idl).unwrap()) { + Ok(_) => eprintln!("✅ {}", out_name), + Err(e) => { + eprintln!("Error writing {}: {}", out_name, e); + had_error = true; + } + } + } + Err(e) => { + eprintln!("Error processing {}: {}", source.display(), e); + had_error = true; + } + } + } + if had_error { + process::exit(1); + } + } + return; + } + "pda" if program_id_hex.is_some() && remaining_args.get(2).map(|s| !s.starts_with("--")).unwrap_or(false) => { + // Raw PDA mode: no IDL needed + // Triggered when --program-id is passed as a global flag + pda command + // Usage: --program-id pda [seed2] ... + let mut raw_args = vec!["--program-id".to_string(), program_id_hex.clone().unwrap()]; + raw_args.extend_from_slice(&remaining_args[2..]); + compute_pda_raw(&raw_args); + return; + } + _ => {} + } + } + + if idl_path.is_empty() { + eprintln!("Usage: {} --idl [ARGS]", args[0]); + eprintln!(); + eprintln!("Commands that don't need --idl:"); + eprintln!(" init Scaffold a new SPEL project"); + eprintln!(" inspect [FILE...] Print ProgramId for ELF binary(ies)"); + eprintln!(" inspect --idl --type Decode account data"); + eprintln!(" generate-idl [PATH] Generate IDL JSON from a program source file or project directory"); + eprintln!(); + eprintln!(" pda [--seed-arg VALUE...] Compute a PDA defined in the IDL"); + eprintln!(" pda --program-id [SEED...] Compute arbitrary PDA (no IDL needed)"); + eprintln!("For all other commands, provide an IDL JSON file."); + process::exit(1); + } + + let idl_content = match fs::read_to_string(&idl_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading IDL '{}': {}", idl_path, e); + process::exit(1); + } + }; + let idl: SpelIdl = serde_json::from_str(&idl_content).unwrap_or_else(|e| { + eprintln!("Error parsing IDL: {}", e); + process::exit(1); + }); + + let subcmd = remaining_args.get(1).map(|s| s.as_str()); + let binary_name = std::path::Path::new(&args[0]) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| args[0].clone()); + + match subcmd { + Some("--help") | Some("-h") | None => { + print_help(&idl, &binary_name); + } + Some("idl") => { + println!("{}", serde_json::to_string_pretty(&idl).unwrap()); + } + Some("inspect") if type_name.is_some() => { + let account_id = remaining_args.get(2).unwrap_or_else(|| { + eprintln!("Usage: {} inspect --idl --type [--data ]", args[0]); + process::exit(1); + }); + account_inspect::inspect_account( + account_id, + &idl, + type_name.as_ref().unwrap(), + data_hex.as_deref(), + ).await; + } + Some("inspect") => { + inspect_binaries(&remaining_args[2..]); + } + Some("pda") => { + compute_pda_command(&idl, &program_path, program_id_hex.as_deref(), &remaining_args[2..]); + } + Some(cmd) => { + let instruction = idl.instructions.iter().find(|ix| { + snake_to_kebab(&ix.name) == cmd || ix.name == cmd + }); + + match instruction { + Some(ix) => { + let cli_args = parse_instruction_args(&remaining_args[2..], ix); + execute_instruction( + &idl, ix, &cli_args, &program_path, program_id_hex.as_deref(), dry_run, &extra_bins, + ).await; + } + None => { + eprintln!("Unknown command: {}", cmd); + print_help(&idl, &binary_name); + process::exit(1); + } + } + } + } +} + +/// Compute and print a PDA from the IDL definition. +/// +/// Usage: --idl pda [-- ...] +/// +/// Looks up the named account across all instructions, finds its PDA seeds, +/// resolves them using provided args, and prints the base58 AccountId. +fn compute_pda_command(idl: &SpelIdl, program_path: &str, program_id_hex: Option<&str>, args: &[String]) { + let account_name = match args.first() { + Some(n) => n.as_str(), + None => { + eprintln!("Usage: pda [-- ...]"); + eprintln!(); + eprintln!("Available PDA accounts:"); + for ix in &idl.instructions { + for acc in &ix.accounts { + if acc.pda.is_some() { + eprintln!(" {} (in instruction: {})", acc.name, ix.name); + } + } + } + std::process::exit(1); + } + }; + + // Find account definition with PDA seeds + let pda_def = idl.instructions.iter() + .flat_map(|ix| &ix.accounts) + .find(|acc| acc.name == account_name || snake_to_kebab(&acc.name) == account_name) + .and_then(|acc| acc.pda.as_ref()); + + let pda_def = match pda_def { + Some(p) => p, + None => { + eprintln!("❌ No PDA account named '{}' found in IDL", account_name); + eprintln!(" Available PDAs:"); + for ix in &idl.instructions { + for acc in &ix.accounts { + if acc.pda.is_some() { + eprintln!(" {} ({})", acc.name, ix.name); + } + } + } + std::process::exit(1); + } + }; + + // Parse --key value pairs from remaining args + let mut seed_args: HashMap = HashMap::new(); + let mut i = 1; + while i < args.len() { + if let Some(key) = args[i].strip_prefix("--") { + if i + 1 < args.len() { + let raw = &args[i + 1]; + // Try to parse as string (covers bytes32, u64, etc via parse_value) + // Use Raw as fallback — seed resolution handles Str type + seed_args.insert( + key.replace('-', "_").to_string(), + ParsedValue::Str(raw.clone()), + ); + i += 2; + } else { + eprintln!("❌ Missing value for --{}", key); + std::process::exit(1); + } + } else { + i += 1; + } + } + + // Get program_id: from global --program-id flag, or by loading the binary + use nssa::program::Program; + use crate::hex::decode_bytes_32; + + let program_id: nssa_core::program::ProgramId = if let Some(hex) = program_id_hex { + let bytes = decode_bytes_32(hex).unwrap_or_else(|e| { + eprintln!("❌ Invalid --program-id '{}': {}", hex, e); + std::process::exit(1); + }); + let mut pid = [0u32; 8]; + for (i, chunk) in bytes.chunks(4).enumerate() { + pid[i] = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + pid + } else if !program_path.is_empty() && std::path::Path::new(program_path).exists() { + let program_bytes = std::fs::read(program_path).unwrap_or_else(|e| { + eprintln!("❌ Cannot read program binary '{}': {}", program_path, e); + std::process::exit(1); + }); + Program::new(program_bytes).unwrap_or_else(|e| { + eprintln!("❌ Invalid program binary: {:?}", e); + std::process::exit(1); + }).id() + } else { + eprintln!("❌ Program ID required to compute PDA."); + eprintln!(" Pass --program-id <64-char-hex> (preferred)"); + eprintln!(" Or --program "); + std::process::exit(1); + }; + + // Compute PDA + match compute_pda_from_seeds(&pda_def.seeds, &program_id, &HashMap::new(), &seed_args) { + Ok(account_id) => { + println!("{}", account_id); + } + Err(e) => { + eprintln!("❌ Failed to compute PDA: {}", e); + eprintln!(); + eprintln!("Seeds for '{}':", account_name); + for seed in &pda_def.seeds { + match seed { + IdlSeed::Const { value } => eprintln!(" const: {:?}", value), + IdlSeed::Arg { path } => eprintln!(" arg: --{}", path.replace('_', "-")), + IdlSeed::Account { path } => eprintln!(" account: {}", path), + } + } + std::process::exit(1); + } + } +} + +/// Compute an arbitrary PDA from a program ID and raw seeds — no IDL required. +/// +/// Usage: pda --program-id <64-char-hex> [seed2] ... +/// +/// Seeds can be: +/// - hex string (64 chars = 32 bytes) +/// - plain string (zero-padded to 32 bytes) +/// +/// Output: base58 AccountId = SHA-256(PREFIX || program_id || SHA-256(seed1_32 || seed2_32 || ...)) +/// +/// Example: +/// multisig --program-id abc123... pda multisig_vault__ +fn compute_pda_raw(args: &[String]) { + use crate::hex::decode_bytes_32; + use nssa_core::program::{PdaSeed, ProgramId}; + use nssa::AccountId; + + // Parse --program-id + let pid_hex = match args.windows(2).find(|w| w[0] == "--program-id") { + Some(w) => &w[1], + None => { + eprintln!("Usage: pda --program-id <64-char-hex> [seed2] ..."); + std::process::exit(1); + } + }; + + let pid_bytes = decode_bytes_32(pid_hex).unwrap_or_else(|e| { + eprintln!("❌ Invalid --program-id '{}': {}", pid_hex, e); + std::process::exit(1); + }); + let mut program_id: ProgramId = [0u32; 8]; + for (i, chunk) in pid_bytes.chunks(4).enumerate() { + program_id[i] = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + + // Collect seed args (everything that's not --program-id or its value) + let mut seeds: Vec<[u8; 32]> = Vec::new(); + let mut skip_next = false; + for arg in args { + if skip_next { skip_next = false; continue; } + if arg == "--program-id" { skip_next = true; continue; } + if arg.starts_with("--") { continue; } + + // Try as 64-char hex first, then as zero-padded string + let seed_bytes: [u8; 32] = if arg.len() == 64 && arg.chars().all(|c| c.is_ascii_hexdigit()) { + decode_bytes_32(arg).unwrap_or_else(|e| { + eprintln!("❌ Invalid hex seed '{}': {}", arg, e); + std::process::exit(1); + }) + } else { + let mut bytes = [0u8; 32]; + let src = arg.as_bytes(); + if src.len() > 32 { + eprintln!("❌ Seed '{}' is {} bytes, max 32", arg, src.len()); + std::process::exit(1); + } + bytes[..src.len()].copy_from_slice(src); + bytes + }; + seeds.push(seed_bytes); + } + + if seeds.is_empty() { + eprintln!("❌ At least one seed required"); + eprintln!("Usage: pda --program-id [seed2] ..."); + std::process::exit(1); + } + + // Combine seeds via SHA-256(seed1 || seed2 || ...) + use risc0_zkvm::sha::{Impl, Sha256}; + let combined: [u8; 32] = if seeds.len() == 1 { + seeds[0] + } else { + let mut input = Vec::with_capacity(seeds.len() * 32); + for s in &seeds { input.extend_from_slice(s); } + Impl::hash_bytes(&input).as_bytes().try_into().expect("SHA-256 is 32 bytes") + }; + + let pda_seed = PdaSeed::new(combined); + let account_id = AccountId::from((&program_id, &pda_seed)); + println!("{}", account_id); +} diff --git a/nssa-framework-cli/src/parse.rs b/spel-cli/src/parse.rs similarity index 90% rename from nssa-framework-cli/src/parse.rs rename to spel-cli/src/parse.rs index 8cc80922..685b184c 100644 --- a/nssa-framework-cli/src/parse.rs +++ b/spel-cli/src/parse.rs @@ -1,6 +1,6 @@ //! IDL type-aware value parsing from CLI strings. -use nssa_framework_core::idl::IdlType; +use spel_framework_core::idl::IdlType; use crate::hex::{hex_decode, hex_encode}; /// A parsed CLI value with type information preserved. @@ -184,6 +184,26 @@ fn parse_vec(raw: &str, elem_type: &IdlType) -> Result { } _ => Ok(ParsedValue::Raw(raw.to_string())), }, + // Vec — comma-separated decimal values + IdlType::Primitive(p) if p == "u8" => { + let bytes: Result, _> = raw.split(',') + .map(|s| s.trim().parse::()) + .collect(); + match bytes { + Ok(b) => Ok(ParsedValue::ByteArray(b)), + Err(_) => Ok(ParsedValue::Raw(raw.to_string())), + } + } + // Vec — comma-separated decimal values + IdlType::Primitive(p) if p == "u32" => { + let vals: Result, _> = raw.split(',') + .map(|s| s.trim().parse::()) + .collect(); + match vals { + Ok(v) => Ok(ParsedValue::U32Array(v)), + Err(_) => Ok(ParsedValue::Raw(raw.to_string())), + } + } _ => Ok(ParsedValue::Raw(raw.to_string())), } } diff --git a/spel-cli/src/pda.rs b/spel-cli/src/pda.rs new file mode 100644 index 00000000..9f4a527f --- /dev/null +++ b/spel-cli/src/pda.rs @@ -0,0 +1,220 @@ +//! PDA (Program Derived Address) computation from IDL seed definitions. + +use std::collections::HashMap; +use nssa::AccountId; +use nssa_core::program::{PdaSeed, ProgramId}; +use spel_framework_core::idl::IdlSeed; +use crate::parse::ParsedValue; + +/// Resolve a single seed to 32 bytes. +fn resolve_seed( + seed: &IdlSeed, + _program_id: &ProgramId, + account_map: &HashMap, + parsed_args: &HashMap, +) -> Result<[u8; 32], String> { + match seed { + IdlSeed::Const { value } => { + let mut bytes = [0u8; 32]; + let src = value.as_bytes(); + if src.len() > 32 { + return Err(format!("Const seed '{}' exceeds 32 bytes", value)); + } + bytes[..src.len()].copy_from_slice(src); + Ok(bytes) + } + IdlSeed::Account { path } => { + let account_id = account_map + .get(path) + .ok_or_else(|| { + format!( + "PDA seed references account '{}' which hasn't been resolved yet", + path + ) + })?; + Ok(*account_id.value()) + } + IdlSeed::Arg { path } => { + let val = parsed_args + .get(path) + .ok_or_else(|| { + format!( + "PDA seed references arg '{}' which wasn't provided", + path + ) + })?; + // Convert ParsedValue to 32 bytes + match val { + ParsedValue::ByteArray(b) => { + if b.len() != 32 { + return Err(format!("Arg '{}' is {} bytes, expected 32", path, b.len())); + } + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(b); + Ok(bytes) + } + ParsedValue::U64(n) => { + let mut bytes = [0u8; 32]; + bytes[24..32].copy_from_slice(&n.to_be_bytes()); + Ok(bytes) + } + ParsedValue::U128(n) => { + let mut bytes = [0u8; 32]; + bytes[16..32].copy_from_slice(&n.to_be_bytes()); + Ok(bytes) + } + ParsedValue::Str(s) => { + let mut bytes = [0u8; 32]; + let src = s.as_bytes(); + if src.len() > 32 { + return Err(format!("String arg '{}' exceeds 32 bytes", path)); + } + bytes[..src.len()].copy_from_slice(src); + Ok(bytes) + } + _ => Err(format!( + "Arg '{}' has unsupported type for PDA seed. Expected bytes32, u64, u128, or string.", + path + )), + } + } + } +} + +/// Hash multiple 32-byte seeds via SHA-256(seed1 || seed2 || ...). +/// +/// Uses concatenation + SHA-256 (not XOR) to avoid commutativity and +/// self-cancellation issues. Matches the on-chain nssa derivation pattern. +fn hash_seeds(seeds: &[[u8; 32]]) -> [u8; 32] { + use risc0_zkvm::sha::{Impl, Sha256}; + let mut bytes = Vec::with_capacity(seeds.len() * 32); + for seed in seeds { + bytes.extend_from_slice(seed); + } + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("SHA-256 output must be exactly 32 bytes") +} + +/// Compute PDA AccountId from IDL seed definitions. +/// +/// Supports single and multi-seed PDAs: +/// - Single seed: used directly as PDA seed +/// - Multi-seed: SHA-256(seed1 || seed2 || ...) combined into a single 32-byte seed +/// +/// Supports all seed types: `const`, `account`, and `arg`. +pub fn compute_pda_from_seeds( + seeds: &[IdlSeed], + program_id: &ProgramId, + account_map: &HashMap, + parsed_args: &HashMap, +) -> Result { + if seeds.is_empty() { + return Err("PDA requires at least one seed".to_string()); + } + + // Resolve all seeds to bytes + let resolved: Vec<[u8; 32]> = seeds + .iter() + .map(|s| resolve_seed(s, program_id, account_map, parsed_args)) + .collect::, _>>()?; + + // Single seed: use directly. Multi-seed: SHA-256(seed1 || seed2 || ...) + // This avoids XOR commutativity and self-cancellation issues. + let combined = if resolved.len() == 1 { + resolved[0] + } else { + hash_seeds(&resolved) + }; + + let pda_seed = PdaSeed::new(combined); + Ok(AccountId::from((program_id, &pda_seed))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_single_const_seed() { + let seeds = vec![IdlSeed::Const { value: "test_seed".to_string() }]; + let program_id: ProgramId = [1u32; 8]; + let result = compute_pda_from_seeds(&seeds, &program_id, &HashMap::new(), &HashMap::new()); + assert!(result.is_ok()); + } + + #[test] + fn test_arg_seed_bytes32() { + let seeds = vec![ + IdlSeed::Const { value: "multisig_state__".to_string() }, + IdlSeed::Arg { path: "create_key".to_string() }, + ]; + let program_id: ProgramId = [1u32; 8]; + let mut args = HashMap::new(); + args.insert("create_key".to_string(), ParsedValue::ByteArray(vec![42u8; 32])); + let result = compute_pda_from_seeds(&seeds, &program_id, &HashMap::new(), &args); + assert!(result.is_ok()); + } + + #[test] + fn test_arg_seed_u64() { + let seeds = vec![ + IdlSeed::Const { value: "proposal".to_string() }, + IdlSeed::Arg { path: "index".to_string() }, + ]; + let program_id: ProgramId = [1u32; 8]; + let mut args = HashMap::new(); + args.insert("index".to_string(), ParsedValue::U64(5)); + let result = compute_pda_from_seeds(&seeds, &program_id, &HashMap::new(), &args); + assert!(result.is_ok()); + } + + #[test] + fn test_missing_arg_errors() { + let seeds = vec![IdlSeed::Arg { path: "missing".to_string() }]; + let program_id: ProgramId = [1u32; 8]; + let result = compute_pda_from_seeds(&seeds, &program_id, &HashMap::new(), &HashMap::new()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("missing")); + } + + #[test] + fn test_hash_seeds_not_commutative() { + use risc0_zkvm::sha::{Impl, Sha256}; + // SHA-256(A || B) != SHA-256(B || A) for A != B + let a = [0x01u8; 32]; + let b = [0x02u8; 32]; + let ab = hash_seeds(&[a, b]); + let ba = hash_seeds(&[b, a]); + assert_ne!(ab, ba, "seed order must matter (non-commutative)"); + } + + #[test] + fn test_hash_seeds_no_self_cancellation() { + // SHA-256(A || A) != zero + let a = [0xFFu8; 32]; + let result = hash_seeds(&[a, a]); + assert_ne!(result, [0u8; 32], "identical seeds must not cancel out"); + } + + #[test] + fn test_multi_seed_differs_from_single() { + let seeds_multi = vec![ + IdlSeed::Const { value: "test".to_string() }, + IdlSeed::Arg { path: "key".to_string() }, + ]; + let seeds_single = vec![ + IdlSeed::Const { value: "test".to_string() }, + ]; + let program_id: ProgramId = [1u32; 8]; + let mut args = HashMap::new(); + args.insert("key".to_string(), ParsedValue::ByteArray(vec![0u8; 32])); + + let multi = compute_pda_from_seeds(&seeds_multi, &program_id, &HashMap::new(), &args).unwrap(); + let single = compute_pda_from_seeds(&seeds_single, &program_id, &HashMap::new(), &HashMap::new()).unwrap(); + + // Multi-seed SHA-256 must differ from single seed (no zero-cancellation) + assert_ne!(multi, single); + } +} diff --git a/nssa-framework-cli/src/serialize.rs b/spel-cli/src/serialize.rs similarity index 81% rename from nssa-framework-cli/src/serialize.rs rename to spel-cli/src/serialize.rs index 8f400fea..45adbe9c 100644 --- a/nssa-framework-cli/src/serialize.rs +++ b/spel-cli/src/serialize.rs @@ -1,6 +1,6 @@ //! risc0-compatible serialization for IDL instruction data. -use nssa_framework_core::idl::IdlType; +use spel_framework_core::idl::IdlType; use crate::parse::ParsedValue; /// Serialize an instruction to risc0 serde format (Vec). @@ -91,6 +91,30 @@ fn serialize_array_risc0(out: &mut Vec, elem_type: &IdlType, _size: usize, fn serialize_vec_risc0(out: &mut Vec, elem_type: &IdlType, val: &ParsedValue) { match (elem_type, val) { + // Vec — comma-separated decimal values + (IdlType::Primitive(p), ParsedValue::U32Array(vals)) if p == "u32" => { + out.push(vals.len() as u32); + for v in vals { + out.push(*v); + } + } + // Vec — byte array (already parsed) + (IdlType::Primitive(p), ParsedValue::ByteArray(bytes)) if p == "u8" => { + out.push(bytes.len() as u32); + for b in bytes { + out.push(*b as u32); + } + } + // Vec — passed as Raw CSV string (e.g. "0,200,0,0,0") + (IdlType::Primitive(p), ParsedValue::Raw(s)) if p == "u32" => { + let vals: Vec = s.split(',') + .filter_map(|x| x.trim().parse::().ok()) + .collect(); + out.push(vals.len() as u32); + for v in vals { + out.push(v); + } + } (IdlType::Array { array }, ParsedValue::ByteArrayVec(vecs)) => { out.push(vecs.len() as u32); match &*array.0 { diff --git a/spel-cli/src/tx.rs b/spel-cli/src/tx.rs new file mode 100644 index 00000000..757595e2 --- /dev/null +++ b/spel-cli/src/tx.rs @@ -0,0 +1,425 @@ +//! Transaction building and submission. + +use std::collections::HashMap; +use std::fs; +use std::process; +use nssa::program::Program; +use nssa::public_transaction::{Message, WitnessSet}; +use nssa::{AccountId, PublicTransaction}; +use nssa_core::program::ProgramId; +use spel_framework_core::idl::{IdlSeed, SpelIdl, IdlInstruction}; +use crate::hex::{hex_encode, decode_bytes_32, parse_account_id}; +use crate::parse::{parse_value, ParsedValue}; +use crate::serialize::serialize_to_risc0; +use crate::pda::compute_pda_from_seeds; +use crate::cli::{snake_to_kebab, to_pascal_case}; +use common::transaction::NSSATransaction; +use hex; +use sequencer_service_rpc::RpcClient as _; +use wallet::WalletCore; + +/// Execute an instruction: parse args, build TX, optionally submit. +pub async fn execute_instruction( + idl: &SpelIdl, + ix: &IdlInstruction, + args: &HashMap, + program_path: &str, + program_id_hex: Option<&str>, + dry_run: bool, + extra_bins: &HashMap, +) { + println!("📋 Instruction: {}", ix.name); + println!(); + + let mut args = args.clone(); + + // Auto-fill program-id args from binary paths + for (key, bin_path) in extra_bins { + if !args.contains_key(key) { + if let Ok(bytes) = fs::read(bin_path) { + if let Ok(program) = Program::new(bytes) { + let id = program.id(); + let id_str: Vec = id.iter().map(|w| w.to_string()).collect(); + let val = id_str.join(","); + println!(" â„đïļ Auto-filled --{} from {}", key, bin_path); + args.insert(key.clone(), val); + } + } + } + } + + // Validate required args + let mut missing = vec![]; + for arg in &ix.args { + let key = snake_to_kebab(&arg.name); + if !args.contains_key(&key) { + missing.push(format!("--{}", key)); + } + } + for acc in &ix.accounts { + // rest accounts are variadic (0 or more) — never required + if acc.pda.is_none() && !acc.rest { + let key = snake_to_kebab(&acc.name); + if !args.contains_key(&key) { + missing.push(format!("--{}", key)); + } + } + } + if !missing.is_empty() { + eprintln!("❌ Missing required arguments: {}", missing.join(", ")); + process::exit(1); + } + + // Parse instruction args + let mut parsed_args: Vec<(&str, &spel_framework_core::idl::IdlType, ParsedValue)> = Vec::new(); + let mut has_errors = false; + for arg in &ix.args { + let key = snake_to_kebab(&arg.name); + let raw = args.get(&key).unwrap(); + match parse_value(raw, &arg.type_) { + Ok(val) => parsed_args.push((&arg.name, &arg.type_, val)), + Err(e) => { eprintln!("❌ --{}: {}", key, e); has_errors = true; } + } + } + + // Parse non-PDA account IDs + let mut parsed_accounts: Vec<(&str, Vec, bool)> = Vec::new(); + // rest accounts are variadic: each expands to 0 or more AccountIds + let mut rest_accounts: Vec<(&str, Vec<(Vec, bool)>)> = Vec::new(); + for acc in &ix.accounts { + if acc.pda.is_some() { continue; } + if acc.rest { let key = snake_to_kebab(&acc.name); if !args.contains_key(&key) { continue; } } + let key = snake_to_kebab(&acc.name); + if acc.rest { + // variadic: optional, comma-separated list of account IDs (0 entries is valid) + let entries: Vec<(Vec, bool)> = if let Some(raw) = args.get(&key) { + raw.split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + match parse_account_id(s) { + Ok((bytes, is_priv)) => (bytes.to_vec(), is_priv), + Err(e) => { eprintln!("❌ --{}: {}", key, e); has_errors = true; (vec![], false) } + } + }) + .collect() + } else { + vec![] // rest accounts are optional — 0 is valid + }; + rest_accounts.push((&acc.name, entries)); + } else { + let raw = args.get(&key).unwrap(); + match parse_account_id(raw) { + Ok((bytes, is_priv)) => parsed_accounts.push((&acc.name, bytes.to_vec(), is_priv)), + Err(e) => { eprintln!("❌ --{}: {}", key, e); has_errors = true; } + } + } + } + if has_errors { process::exit(1); } + + // Build risc0 serialized data + let ix_index = idl.instructions.iter().position(|i| i.name == ix.name).unwrap_or(0); + let risc0_args: Vec<_> = parsed_args.iter().map(|(_, ty, val)| (*ty, val)).collect(); + let instruction_data = serialize_to_risc0(ix_index as u32, &risc0_args); + + // Display + println!("Accounts:"); + for acc in &ix.accounts { + if acc.pda.is_some() { + println!(" ðŸ“Ķ {} → auto-computed (PDA)", acc.name); + } else if acc.rest { + if let Some((_, entries)) = rest_accounts.iter().find(|(n, _)| *n == acc.name) { + if entries.is_empty() { + println!(" ðŸ“Ķ {} → (none — variadic rest)", acc.name); + } else { + for (e, _) in entries { + println!(" ðŸ“Ķ {} → 0x{}", acc.name, hex_encode(e)); + } + } + } + } else { + let account_bytes = parsed_accounts.iter().find(|(n, _, _)| *n == acc.name).unwrap(); + println!(" ðŸ“Ķ {} → 0x{}", acc.name, hex_encode(&account_bytes.1)); + } + } + println!(); + println!("Arguments (parsed):"); + for (name, _, val) in &parsed_args { + println!(" {} = {}", name, val); + } + println!(); + println!("🔧 Transaction:"); + if let Some(pid) = program_id_hex { + println!(" program-id: {}", pid); + } else { + println!(" program: {}", program_path); + } + println!(" instruction index: {}", ix_index); + println!(" instruction: {} {{", to_pascal_case(&ix.name)); + for (name, _, val) in &parsed_args { + println!(" {}: {},", name, val); + } + println!(" }}"); + println!(); + println!(" Serialized instruction data ({} u32 words):", instruction_data.len()); + let hex_words: Vec = instruction_data.iter().map(|w| format!("{:08x}", w)).collect(); + println!(" [{}]", hex_words.join(", ")); + println!(); + + if dry_run { + println!("⚠ïļ Dry run — omit --dry-run to submit the transaction."); + return; + } + + // ─── Transaction submission ────────────────────────────────── + println!("ðŸ“Ī Submitting transaction..."); + + // Resolve program_id: from --program-id hex flag, or by loading the binary + let (program_id, program_obj): (ProgramId, Option) = if let Some(hex) = program_id_hex { + let bytes = decode_bytes_32(hex).unwrap_or_else(|e| { + eprintln!("❌ Invalid --program-id '{}': {}", hex, e); + process::exit(1); + }); + let mut pid = [0u32; 8]; + for (i, chunk) in bytes.chunks(4).enumerate() { + pid[i] = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + (pid, None) + } else { + let program_bytecode = fs::read(program_path).unwrap_or_else(|e| { + eprintln!("❌ Failed to read program binary '{}': {}", program_path, e); + eprintln!(" Hint: pass --program-id to skip loading the binary"); + process::exit(1); + }); + let program = Program::new(program_bytecode).unwrap_or_else(|e| { + eprintln!("❌ Failed to load program: {:?}", e); + process::exit(1); + }); + let pid = program.id(); + (pid, Some(program)) + }; + println!(" Program ID: {:?}", program_id); + + // Build account map for PDA resolution + let mut account_map: HashMap = HashMap::new(); + for (name, bytes, _) in &parsed_accounts { + let mut arr = [0u8; 32]; + arr.copy_from_slice(bytes); + account_map.insert(name.to_string(), AccountId::new(arr)); + } + // Note: rest accounts are variadic; store first entry (if any) for PDA seed resolution + for (name, entries) in &rest_accounts { + if let Some((first, _)) = entries.first() { + let mut arr = [0u8; 32]; + arr.copy_from_slice(first); + account_map.insert(name.to_string(), AccountId::new(arr)); + } + } + + // Resolve external account references needed by PDA seeds + for acc in &ix.accounts { + if let Some(pda) = &acc.pda { + for seed in &pda.seeds { + if let IdlSeed::Account { path } = seed { + if !account_map.contains_key(path) { + let key = snake_to_kebab(path); + if let Some(raw) = args.get(&key) { + match decode_bytes_32(raw) { + Ok(bytes) => { + println!(" â„đïļ Using --{} for PDA seed '{}'", key, path); + account_map.insert(path.clone(), AccountId::new(bytes)); + } + Err(e) => { eprintln!("❌ --{}: {}", key, e); process::exit(1); } + } + } else { + eprintln!("❌ PDA '{}' requires account '{}' — provide --{}", acc.name, path, key); + process::exit(1); + } + } + } + } + } + } + + let mut parsed_arg_map: HashMap = HashMap::new(); + for (name, _, val) in &parsed_args { + parsed_arg_map.insert(name.to_string(), val.clone()); + } + + // Resolve PDA accounts + for acc in &ix.accounts { + if let Some(pda) = &acc.pda { + match compute_pda_from_seeds(&pda.seeds, &program_id, &account_map, &parsed_arg_map) { + Ok(id) => { + println!(" PDA {} → {}", acc.name, id); + account_map.insert(acc.name.clone(), id); + } + Err(e) => { + eprintln!("❌ Failed to compute PDA for '{}': {}", acc.name, e); + process::exit(1); + } + } + } + } + + let wallet_core = WalletCore::from_env().unwrap_or_else(|e| { + eprintln!("❌ Failed to initialize wallet: {:?}", e); + eprintln!(" Set NSSA_WALLET_HOME_DIR environment variable"); + process::exit(1); + }); + + // Check if any account has a Private/ prefix + let has_private = parsed_accounts.iter().any(|(_, _, is_priv)| *is_priv) + || rest_accounts.iter().any(|(_, entries)| entries.iter().any(|(_, is_priv)| *is_priv)); + + if has_private { + // ─── Privacy-preserving transaction ────────────────── + use wallet::PrivacyPreservingAccount; + use nssa::privacy_preserving_transaction::circuit::ProgramWithDependencies; + + let program = program_obj.unwrap_or_else(|| { + eprintln!("❌ Privacy-preserving transactions require the program binary (not --program-id)"); + process::exit(1); + }); + + // Build dependencies from extra_bins + let mut dependencies = HashMap::new(); + for (_, bin_path) in extra_bins { + if let Ok(bytes) = fs::read(bin_path) { + if let Ok(dep_program) = Program::new(bytes) { + dependencies.insert(dep_program.id(), dep_program); + } + } + } + let program_with_deps = ProgramWithDependencies::new(program, dependencies); + + // Build privacy-preserving account list + let mut pp_accounts: Vec = Vec::new(); + for acc in &ix.accounts { + if acc.rest { + if let Some((_, entries)) = rest_accounts.iter().find(|(n, _)| *n == acc.name) { + for (bytes, is_priv) in entries { + let mut arr = [0u8; 32]; + arr.copy_from_slice(bytes); + let account_id = AccountId::new(arr); + if *is_priv { + pp_accounts.push(PrivacyPreservingAccount::PrivateOwned(account_id)); + } else { + pp_accounts.push(PrivacyPreservingAccount::Public(account_id)); + } + } + } + } else if let Some((_, _, is_priv)) = parsed_accounts.iter().find(|(n, _, _)| *n == acc.name) { + let id = *account_map.get(&acc.name).unwrap_or_else(|| { + eprintln!("❌ Account '{}' not resolved", acc.name); + process::exit(1); + }); + if *is_priv { + pp_accounts.push(PrivacyPreservingAccount::PrivateOwned(id)); + } else { + pp_accounts.push(PrivacyPreservingAccount::Public(id)); + } + } else { + // PDA account — always public + let id = *account_map.get(&acc.name).unwrap_or_else(|| { + eprintln!("❌ Account '{}' not resolved", acc.name); + process::exit(1); + }); + pp_accounts.push(PrivacyPreservingAccount::Public(id)); + } + } + + let (response, _shared_secrets) = wallet_core.send_privacy_preserving_tx( + pp_accounts, + instruction_data, + &program_with_deps, + ).await.unwrap_or_else(|e| { + eprintln!("❌ Failed to submit privacy-preserving transaction: {:?}", e); + process::exit(1); + }); + + println!("ðŸ“Ī Privacy-preserving transaction submitted!"); + println!(" tx_hash: {}", hex::encode(response.0)); + println!(" Waiting for confirmation..."); + + let poller = wallet::poller::TxPoller::new( + wallet_core.config(), + wallet_core.sequencer_client.clone(), + ); + + match poller.poll_tx(response).await { + Ok(_) => println!("✅ Transaction confirmed — included in a block."), + Err(e) => { + eprintln!("❌ Transaction NOT confirmed: {e:#}"); + process::exit(1); + } + } + } else { + // ─── Public transaction (existing path) ────────────── + let mut account_ids: Vec = Vec::new(); + for acc in &ix.accounts { + if acc.rest { + if let Some((_, entries)) = rest_accounts.iter().find(|(n, _)| *n == acc.name) { + for (bytes, _) in entries { + let mut arr = [0u8; 32]; + arr.copy_from_slice(bytes); + account_ids.push(AccountId::new(arr)); + } + } + } else { + let id = account_map.get(&acc.name).unwrap_or_else(|| { + eprintln!("❌ Account '{}' not resolved", acc.name); + process::exit(1); + }); + account_ids.push(*id); + } + } + + let signer_accounts: Vec = ix.accounts.iter() + .filter(|a| a.signer) + .map(|a| *account_map.get(&a.name).unwrap()) + .collect(); + + let nonces = if signer_accounts.is_empty() { + vec![] + } else { + wallet_core.get_accounts_nonces(signer_accounts.clone()).await.unwrap_or_else(|e| { + eprintln!("❌ Failed to fetch nonces: {:?}", e); + process::exit(1); + }) + }; + + let signing_keys: Vec<_> = signer_accounts.iter().map(|id| { + wallet_core.storage().user_data.get_pub_account_signing_key(*id).unwrap_or_else(|| { + eprintln!("❌ Signing key not found for account {}", id); + process::exit(1); + }) + }).collect(); + + let message = Message::new_preserialized(program_id, account_ids, nonces, instruction_data); + let witness_set = WitnessSet::for_message(&message, &signing_keys); + let tx = PublicTransaction::new(message, witness_set); + + let tx_hash = wallet_core.sequencer_client.send_transaction(NSSATransaction::Public(tx)).await.unwrap_or_else(|e| { + eprintln!("❌ Failed to submit transaction: {:?}", e); + process::exit(1); + }); + + println!("ðŸ“Ī Transaction submitted!"); + println!(" tx_hash: {}", tx_hash); + println!(" Waiting for confirmation..."); + + let poller = wallet::poller::TxPoller::new( + wallet_core.config(), + wallet_core.sequencer_client.clone(), + ); + + match poller.poll_tx(tx_hash).await { + Ok(_) => println!("✅ Transaction confirmed — included in a block."), + Err(e) => { + eprintln!("❌ Transaction NOT confirmed: {e:#}"); + process::exit(1); + } + } +} +} \ No newline at end of file diff --git a/spel-client-gen/Cargo.toml b/spel-client-gen/Cargo.toml new file mode 100644 index 00000000..b776b6b0 --- /dev/null +++ b/spel-client-gen/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "spel-client-gen" +version = "0.2.0" +edition = "2021" +description = "Generate typed Rust client and C FFI bindings from SPEL program IDL" +license = "MIT" + +[dependencies] +spel-framework-core = { path = "../spel-framework-core" } +serde_json = "1" +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/spel-client-gen/README.md b/spel-client-gen/README.md new file mode 100644 index 00000000..8d81396b --- /dev/null +++ b/spel-client-gen/README.md @@ -0,0 +1,104 @@ +# spel-client-gen + +Generate typed Rust client code and C FFI wrappers from SPEL program IDL JSON. + +## Overview + +`spel-client-gen` reads the IDL JSON that SPEL programs produce (via `#[lez_program]` macro) and generates: + +1. **Typed Rust client** — a struct with async methods per instruction, correct account ordering, PDA computation helpers, and proper type conversions +2. **C FFI wrappers** — `extern "C"` functions accepting/returning JSON strings, matching the pattern used by `lez-multisig-ffi` +3. **C header file** — for inclusion in C/C++ projects (e.g., Qt plugins) + +## Usage + +### As a CLI tool + +```bash +cargo run -p spel-client-gen -- --idl path/to/idl.json --out-dir generated/ +``` + +This produces three files: +- `_client.rs` — typed Rust client module +- `_ffi.rs` — C FFI wrapper +- `.h` — C header + +### As a library + +```rust +use spel_client_gen::generate_from_idl_json; + +let idl_json = std::fs::read_to_string("my_program_idl.json")?; +let output = generate_from_idl_json(&idl_json)?; + +std::fs::write("src/generated_client.rs", &output.client_code)?; +std::fs::write("src/generated_ffi.rs", &output.ffi_code)?; +std::fs::write("include/my_program.h", &output.header)?; +``` + +## IDL Input Format + +The input is the standard SPEL IDL JSON format generated by the `#[lez_program]` macro: + +```json +{ + "version": "0.1.0", + "name": "my_program", + "instructions": [ + { + "name": "create", + "accounts": [ + { + "name": "state", + "writable": true, + "signer": false, + "init": true, + "pda": { + "seeds": [ + {"kind": "const", "value": "my_state"}, + {"kind": "arg", "path": "key"} + ] + } + }, + {"name": "creator", "writable": false, "signer": true, "init": false} + ], + "args": [ + {"name": "key", "type": "[u8; 32]"}, + {"name": "threshold", "type": "u64"} + ] + } + ], + "accounts": [], + "types": [], + "errors": [] +} +``` + +## Generated Output + +### Client Code + +- `Instruction` enum matching the IDL +- `Accounts` structs with correctly-typed fields +- `Client` struct with: + - One async method per instruction + - Correct account ordering (matching IDL) + - Automatic signer detection and key lookup + - PDA computation helpers (e.g., `compute_state_pda(...)`) + +### FFI Code + +- One `extern "C"` function per instruction: `_(args_json) -> *mut c_char` +- JSON-in / JSON-out pattern matching existing FFI crates +- Proper type parsing: + - `AccountId`: base58 (native) or hex fallback + - `ProgramId`: hex → `[u32; 8]` with **little-endian** byte order +- PDA computation inline where the IDL specifies seeds +- Memory management: `_free_string()` to free returned strings + +## Key Design Decisions + +- **Little-endian ProgramId**: `[u32; 8]` words are parsed from hex using `u32::from_le_bytes()`, matching RISC Zero Digest byte order +- **Base58-first AccountId**: FFI functions try base58 parsing first (native format), falling back to hex +- **Account order preserved**: The generated account list exactly matches the IDL order — this fixes bugs in hand-written FFI where accounts were in wrong order +- **Rest accounts**: Accounts marked with `"rest": true` become `Vec` and are appended after fixed accounts diff --git a/spel-client-gen/src/codegen.rs b/spel-client-gen/src/codegen.rs new file mode 100644 index 00000000..168d6b3c --- /dev/null +++ b/spel-client-gen/src/codegen.rs @@ -0,0 +1,301 @@ +//! Typed Rust client generation from SPEL IDL. + +use spel_framework_core::idl::*; +use std::collections::HashSet; +use std::fmt::Write; +use crate::util::*; + +/// Generate a typed Rust client module from an IDL. +pub fn generate_client(idl: &SpelIdl) -> Result { + let mut out = String::new(); + let program_pascal = pascal_case(&idl.name); + + // Header + writeln!(out, "//! Auto-generated client for the {} program.", idl.name).unwrap(); + writeln!(out, "//! Generated by spel-client-gen from IDL v{}.", idl.version).unwrap(); + writeln!(out, "//! DO NOT EDIT — regenerate from IDL instead.").unwrap(); + writeln!(out).unwrap(); + + // Imports + writeln!(out, "use sequencer_service_rpc::RpcClient as _;").unwrap(); + writeln!(out, "use nssa::{{").unwrap(); + writeln!(out, " AccountId, ProgramId, PublicTransaction,").unwrap(); + writeln!(out, " public_transaction::{{Message, WitnessSet}},").unwrap(); + writeln!(out, "}};").unwrap(); + writeln!(out, "use borsh::BorshDeserialize;").unwrap(); + writeln!(out, "use serde::{{Deserialize, Serialize}};").unwrap(); + writeln!(out, "use wallet::WalletCore;").unwrap(); + writeln!(out).unwrap(); + + // Parse helpers + writeln!(out, "/// Parse a hex string into ProgramId [u32; 8] (little-endian byte order).").unwrap(); + writeln!(out, "pub fn parse_program_id_hex(s: &str) -> Result {{").unwrap(); + writeln!(out, " let s = s.trim_start_matches(\"0x\");").unwrap(); + writeln!(out, " if s.len() != 64 {{").unwrap(); + writeln!(out, " return Err(format!(\"program_id hex must be 64 chars, got {{}}\", s.len()));").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " let bytes = hex::decode(s).map_err(|e| format!(\"invalid hex: {{}}\", e))?;").unwrap(); + writeln!(out, " let mut pid = [0u32; 8];").unwrap(); + writeln!(out, " for (i, chunk) in bytes.chunks(4).enumerate() {{").unwrap(); + writeln!(out, " pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " Ok(pid)").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // --- Standalone PDA computation helpers --- + let pda_helpers = collect_pda_helpers(idl); + for helper in &pda_helpers { + writeln!(out, "/// Compute PDA for the `{}` account.", helper.account_name).unwrap(); + write!(out, "pub fn compute_{}_pda(program_id: &ProgramId", helper.account_name).unwrap(); + for (pname, pty) in &helper.params { + write!(out, ", {}: {}", pname, pty).unwrap(); + } + writeln!(out, ") -> AccountId {{").unwrap(); + for binding in &helper.let_bindings { + writeln!(out, " {}", binding).unwrap(); + } + writeln!(out, " spel_framework_core::pda::compute_pda(program_id, &[").unwrap(); + for expr in &helper.seed_exprs { + writeln!(out, " {},", expr).unwrap(); + } + writeln!(out, " ])").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + } + + // Instruction enum + writeln!(out, "#[derive(Clone, Debug, Serialize, Deserialize)]").unwrap(); + writeln!(out, "pub enum {}Instruction {{", program_pascal).unwrap(); + for ix in &idl.instructions { + let variant = pascal_case(&ix.name); + if ix.args.is_empty() { + writeln!(out, " {},", variant).unwrap(); + } else { + writeln!(out, " {} {{", variant).unwrap(); + for arg in &ix.args { + writeln!(out, " {}: {},", rust_ident(&arg.name), idl_type_to_rust(&arg.type_)).unwrap(); + } + writeln!(out, " }},").unwrap(); + } + } + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // Per-instruction account structs + for ix in &idl.instructions { + let accounts_name = format!("{}Accounts", pascal_case(&ix.name)); + writeln!(out, "pub struct {} {{", accounts_name).unwrap(); + for acc in &ix.accounts { + if acc.rest { + writeln!(out, " pub {}: Vec,", rust_ident(&acc.name)).unwrap(); + } else { + writeln!(out, " pub {}: AccountId,", rust_ident(&acc.name)).unwrap(); + } + } + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + } + + // Client struct + writeln!(out, "pub struct {}Client<'w> {{", program_pascal).unwrap(); + writeln!(out, " pub wallet: &'w WalletCore,").unwrap(); + writeln!(out, " pub program_id: ProgramId,").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + writeln!(out, "impl<'w> {}Client<'w> {{", program_pascal).unwrap(); + writeln!(out, " pub fn new(wallet: &'w WalletCore, program_id: ProgramId) -> Self {{").unwrap(); + writeln!(out, " Self {{ wallet, program_id }}").unwrap(); + writeln!(out, " }}").unwrap(); + + for ix in &idl.instructions { + let method = rust_ident(&ix.name); + let accounts_name = format!("{}Accounts", pascal_case(&ix.name)); + let variant = pascal_case(&ix.name); + + writeln!(out).unwrap(); + write!(out, " pub async fn {}(\n &self,\n accounts: {}", method, accounts_name).unwrap(); + for arg in &ix.args { + write!(out, ",\n {}: {}", rust_ident(&arg.name), idl_type_to_rust(&arg.type_)).unwrap(); + } + writeln!(out, ",\n ) -> Result {{").unwrap(); + + // Build instruction + if ix.args.is_empty() { + writeln!(out, " let instruction = {}Instruction::{};", program_pascal, variant).unwrap(); + } else { + writeln!(out, " let instruction = {}Instruction::{} {{", program_pascal, variant).unwrap(); + for arg in &ix.args { + writeln!(out, " {},", rust_ident(&arg.name)).unwrap(); + } + writeln!(out, " }};").unwrap(); + } + + // Account IDs + writeln!(out, " let mut account_ids: Vec = vec![").unwrap(); + for acc in ix.accounts.iter().filter(|a| !a.rest) { + writeln!(out, " accounts.{},", rust_ident(&acc.name)).unwrap(); + } + writeln!(out, " ];").unwrap(); + for acc in ix.accounts.iter().filter(|a| a.rest) { + writeln!(out, " account_ids.extend_from_slice(&accounts.{});", rust_ident(&acc.name)).unwrap(); + } + + // Signers + let signers: Vec<_> = ix.accounts.iter().filter(|a| a.signer).collect(); + writeln!(out, " let signer_ids: Vec = vec![").unwrap(); + for s in &signers { + writeln!(out, " accounts.{},", rust_ident(&s.name)).unwrap(); + } + writeln!(out, " ];").unwrap(); + writeln!(out, " let nonces = self.wallet.get_accounts_nonces(signer_ids.clone()).await").unwrap(); + writeln!(out, " .map_err(|e| format!(\"nonces: {{}}\", e))?;").unwrap(); + writeln!(out, " let mut signing_keys = Vec::new();").unwrap(); + writeln!(out, " for sid in &signer_ids {{").unwrap(); + writeln!(out, " let key = self.wallet.storage().user_data").unwrap(); + writeln!(out, " .get_pub_account_signing_key(*sid)").unwrap(); + writeln!(out, " .ok_or_else(|| format!(\"signing key not found for {{}}\", sid))?;").unwrap(); + writeln!(out, " signing_keys.push(key);").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " let message = Message::try_new(self.program_id, account_ids, nonces, instruction)").unwrap(); + writeln!(out, " .map_err(|e| format!(\"message: {{:?}}\", e))?;").unwrap(); + writeln!(out, " let witness_set = WitnessSet::for_message(&message, &signing_keys);").unwrap(); + writeln!(out, " let tx = PublicTransaction::new(message, witness_set);").unwrap(); + writeln!(out, " let response = self.wallet.sequencer_client.send_transaction(common::transaction::NSSATransaction::Public(tx)).await").unwrap(); + writeln!(out, " .map_err(|e| format!(\"submit: {{}}\", e))?;").unwrap(); + writeln!(out, " Ok(hex::encode(response.0))").unwrap(); + writeln!(out, " }}").unwrap(); + } + + // --- State fetch+deserialize helpers --- + for helper in &pda_helpers { + writeln!(out).unwrap(); + writeln!(out, " /// Fetch and deserialize the `{}` account state.", helper.account_name).unwrap(); + write!(out, " pub async fn fetch_{}(\n &self", helper.account_name).unwrap(); + for (pname, pty) in &helper.params { + write!(out, ",\n {}: {}", pname, pty).unwrap(); + } + writeln!(out, ",\n ) -> Result {{").unwrap(); + write!(out, " let account_id = compute_{}_pda(&self.program_id", helper.account_name).unwrap(); + for (pname, _) in &helper.params { + write!(out, ", {}", pname).unwrap(); + } + writeln!(out, ");").unwrap(); + writeln!(out, " let account = self.wallet.sequencer_client").unwrap(); + writeln!(out, " .get_account(account_id).await").unwrap(); + writeln!(out, " .map_err(|e| format!(\"fetch {}: {{}}\", e))?;", helper.account_name).unwrap(); + writeln!(out, " T::try_from_slice(&account.data)").unwrap(); + writeln!(out, " .map_err(|e| format!(\"deserialize {}: {{}}\", e))", helper.account_name).unwrap(); + writeln!(out, " }}").unwrap(); + } + + writeln!(out, "}}").unwrap(); + Ok(out) +} + +/// Information about a PDA helper to generate. +struct PdaHelper { + account_name: String, + params: Vec<(String, String)>, // (param_name, param_type) for non-const seeds + let_bindings: Vec, // let bindings needed before compute_pda call + seed_exprs: Vec, // seed slice expressions for compute_pda +} + +/// Collect unique PDA accounts across all instructions, deduplicating by account name. +fn collect_pda_helpers(idl: &SpelIdl) -> Vec { + let mut seen = HashSet::new(); + let mut helpers = Vec::new(); + + for ix in &idl.instructions { + for acc in &ix.accounts { + if let Some(pda) = &acc.pda { + let account_name = snake_case(&acc.name); + if !seen.insert(account_name.clone()) { + continue; + } + + let mut params = Vec::new(); + let mut let_bindings = Vec::new(); + let mut seed_exprs = Vec::new(); + + for seed in &pda.seeds { + match seed { + IdlSeed::Const { value } => { + let var = format!("seed_const_{}", let_bindings.len()); + let_bindings.push(format!( + "let {} = spel_framework_core::pda::seed_from_str(\"{}\");", + var, value + )); + seed_exprs.push(format!("&{}", var)); + } + IdlSeed::Account { path } => { + let name = snake_case(path); + params.push((name.clone(), "&AccountId".to_string())); + seed_exprs.push(format!("{}.value()", name)); + } + IdlSeed::Arg { path } => { + let name = snake_case(path); + let ty = ix.args.iter() + .find(|a| a.name == *path) + .map(|a| idl_type_to_rust(&a.type_)) + .unwrap_or_else(|| "String".to_string()); + + let (param_ty, binding, expr) = seed_arg_codegen(&name, &ty); + params.push((name, param_ty)); + if let Some(b) = binding { + let_bindings.push(b); + } + seed_exprs.push(expr); + } + } + } + + helpers.push(PdaHelper { + account_name, + params, + let_bindings, + seed_exprs, + }); + } + } + } + helpers +} + +/// Generate codegen expressions for a seed argument based on its Rust type. +/// Returns (param_type, optional_let_binding, seed_expression). +fn seed_arg_codegen(name: &str, rust_type: &str) -> (String, Option, String) { + match rust_type { + "AccountId" => ( + "&AccountId".to_string(), + None, + format!("{}.value()", name), + ), + "[u8; 32]" | "[u8;32]" => ( + format!("&{}", rust_type), + None, + format!("{}", name), + ), + "ProgramId" | "[u32; 8]" | "[u32;8]" => ( + "&ProgramId".to_string(), + Some(format!("let {name}_seed: [u8; 32] = {name}.iter().flat_map(|w| w.to_le_bytes()).collect::>().try_into().unwrap();")), + format!("&{name}_seed"), + ), + "u64" | "u32" | "u16" | "u8" | "u128" | "i64" | "i32" | "i16" | "i8" | "i128" => ( + rust_type.to_string(), + Some(format!("let {name}_be = {name}.to_be_bytes();\n let mut {name}_seed = [0u8; 32];\n {name}_seed[..{name}_be.len()].copy_from_slice(&{name}_be);")), + format!("&{name}_seed"), + ), + "String" => ( + "&str".to_string(), + Some(format!("let {name}_seed = spel_framework_core::pda::seed_from_str({name});")), + format!("&{name}_seed"), + ), + _ => ( + format!("&{}", rust_type), + Some(format!("let {name}_seed = spel_framework_core::pda::seed_from_str(&{name}.to_string());")), + format!("&{name}_seed"), + ), + } +} diff --git a/spel-client-gen/src/ffi_codegen.rs b/spel-client-gen/src/ffi_codegen.rs new file mode 100644 index 00000000..4f8cebd9 --- /dev/null +++ b/spel-client-gen/src/ffi_codegen.rs @@ -0,0 +1,469 @@ +//! C FFI wrapper generation from SPEL IDL. +//! +//! Generates `extern "C"` functions that accept JSON strings and return JSON strings. +//! The generated FFI includes full transaction building via `wallet::WalletCore`. +//! +//! If the IDL has `instruction_type` set (e.g. `"multisig_core::Instruction"`), +//! the generated code imports and uses that type directly — ensuring correct +//! serde/borsh representation when the instruction is sent to the zkVM guest. +//! +//! If `instruction_type` is absent, a local enum is generated with +//! `#[derive(Serialize, Deserialize)]` which works for simple programs. + +use spel_framework_core::idl::*; +use std::fmt::Write; +use crate::util::*; + +/// Generate C FFI wrapper source code from an IDL. +pub fn generate_ffi(idl: &SpelIdl) -> Result { + let mut out = String::new(); + let prefix = snake_case(&idl.name); + let local_enum = pascal_case(&idl.name) + "Instruction"; + + // When instruction_type is set, we import it as `ProgramInstruction`. + // When absent, we generate a local enum named `{Program}Instruction`. + let instr_type = if idl.instruction_type.is_some() { + "ProgramInstruction".to_string() + } else { + local_enum.clone() + }; + + // Header + writeln!(out, "//! Auto-generated C FFI for the {} program.", idl.name).unwrap(); + writeln!(out, "//! Generated by spel-client-gen. DO NOT EDIT.").unwrap(); + writeln!(out, "//!").unwrap(); + writeln!(out, "//! Required JSON fields for every instruction call:").unwrap(); + writeln!(out, "//! - `wallet_path`: path to NSSA wallet directory").unwrap(); + writeln!(out, "//! - `sequencer_url`: e.g. \"http://127.0.0.1:3040\"").unwrap(); + writeln!(out, "//! - `program_id_hex`: 64-char hex string identifying the program").unwrap(); + writeln!(out).unwrap(); + + // Imports + writeln!(out, "use std::ffi::{{CStr, CString}};").unwrap(); + writeln!(out, "use std::os::raw::c_char;").unwrap(); + writeln!(out, "use serde_json::{{Value, json}};").unwrap(); + writeln!(out, "use sha2::{{Sha256, Digest}};").unwrap(); + writeln!(out, "use nssa::{{AccountId, ProgramId, PublicTransaction}};").unwrap(); + writeln!(out, "use nssa::public_transaction::{{Message, WitnessSet}};").unwrap(); + writeln!(out, "use sequencer_service_rpc::RpcClient as _;").unwrap(); + writeln!(out, "use wallet::WalletCore;").unwrap(); + + // Import or generate instruction type + if let Some(ref itype) = idl.instruction_type { + writeln!(out, "use {} as ProgramInstruction;", itype).unwrap(); + } else { + // Generate local instruction enum + writeln!(out, "use serde::{{Serialize, Deserialize}};").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "#[derive(Debug, Clone, Serialize, Deserialize)]").unwrap(); + writeln!(out, "pub enum {local_enum} {{").unwrap(); + for ix in &idl.instructions { + let variant = pascal_case(&ix.name); + if ix.args.is_empty() { + writeln!(out, " {variant},").unwrap(); + } else { + writeln!(out, " {variant} {{").unwrap(); + for arg in &ix.args { + let name = rust_ident(&arg.name); + let ty = idl_type_to_rust(&arg.type_); + writeln!(out, " {name}: {ty},").unwrap(); + } + writeln!(out, " }},").unwrap(); + } + } + writeln!(out, "}}").unwrap(); + } + writeln!(out).unwrap(); + + // Helper fns + writeln!(out, "fn cstr_to_str<'a>(ptr: *const c_char) -> Result<&'a str, String> {{").unwrap(); + writeln!(out, " if ptr.is_null() {{ return Err(\"null pointer\".into()); }}").unwrap(); + writeln!(out, " unsafe {{ CStr::from_ptr(ptr) }}.to_str().map_err(|e| format!(\"invalid UTF-8: {{}}\", e))").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "fn to_cstring(s: String) -> *mut c_char {{").unwrap(); + writeln!(out, " CString::new(s).unwrap_or_else(|_|").unwrap(); + writeln!(out, " CString::new(r#\"{{\"success\":false,\"error\":\"null byte\"}}\"#).unwrap()").unwrap(); + writeln!(out, " ).into_raw()").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "fn error_json(msg: &str) -> *mut c_char {{").unwrap(); + writeln!(out, " let v = serde_json::json!(msg).to_string();").unwrap(); + writeln!(out, " let body = format!(\"{{{{\\\"success\\\":false,\\\"error\\\":{{}}}}}}\", v);").unwrap(); + writeln!(out, " to_cstring(body)").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // PDA helper + writeln!(out, "fn compute_pda(seeds: &[&[u8]]) -> AccountId {{").unwrap(); + writeln!(out, " let mut hasher = Sha256::new();").unwrap(); + writeln!(out, " for seed in seeds {{").unwrap(); + writeln!(out, " let mut padded = [0u8; 32];").unwrap(); + writeln!(out, " let len = seed.len().min(32);").unwrap(); + writeln!(out, " padded[..len].copy_from_slice(&seed[..len]);").unwrap(); + writeln!(out, " hasher.update(&padded);").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " let hash: [u8; 32] = hasher.finalize().into();").unwrap(); + writeln!(out, " AccountId::new(hash)").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // parse_program_id_hex + writeln!(out, "fn parse_program_id_hex(s: &str) -> Result {{").unwrap(); + writeln!(out, " let s = s.trim_start_matches(\"0x\");").unwrap(); + writeln!(out, " if s.len() != 64 {{ return Err(format!(\"program_id hex must be 64 chars, got {{}}\", s.len())); }}").unwrap(); + writeln!(out, " let bytes = hex::decode(s).map_err(|e| format!(\"invalid hex: {{}}\", e))?;").unwrap(); + writeln!(out, " let mut pid = [0u32; 8];").unwrap(); + writeln!(out, " for (i, chunk) in bytes.chunks(4).enumerate() {{").unwrap(); + writeln!(out, " pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " Ok(pid)").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // parse_program_id (alias for ProgramId-typed args) + writeln!(out, "fn parse_program_id(s: &str) -> Result {{").unwrap(); + writeln!(out, " parse_program_id_hex(s)").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // parse_account_id + writeln!(out, "fn parse_account_id(s: &str) -> Result {{").unwrap(); + writeln!(out, " if let Ok(id) = s.parse() {{ return Ok(id); }}").unwrap(); + writeln!(out, " let s = s.trim_start_matches(\"0x\");").unwrap(); + writeln!(out, " if s.len() == 64 {{").unwrap(); + writeln!(out, " let bytes = hex::decode(s).map_err(|e| format!(\"invalid hex: {{}}\", e))?;").unwrap(); + writeln!(out, " let mut arr = [0u8; 32]; arr.copy_from_slice(&bytes);").unwrap(); + writeln!(out, " return Ok(AccountId::new(arr));").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " Err(format!(\"invalid AccountId: {{}}\", s))").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // init_wallet + writeln!(out, "fn init_wallet(v: &Value) -> Result {{").unwrap(); + writeln!(out, " if let Some(p) = v[\"wallet_path\"].as_str() {{").unwrap(); + writeln!(out, " std::env::set_var(\"NSSA_WALLET_HOME_DIR\", p);").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " WalletCore::from_env().map_err(|e| format!(\"wallet init: {{}}\", e))").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + // Per-instruction FFI functions + for ix in &idl.instructions { + let fn_name = format!("{}_{}", prefix, snake_case(&ix.name)); + let variant = pascal_case(&ix.name); + let signer_accounts: Vec<&IdlAccountItem> = ix.accounts.iter().filter(|a| a.signer).collect(); + + writeln!(out, "/// FFI: {} instruction.", ix.name).unwrap(); + writeln!(out, "#[no_mangle]").unwrap(); + writeln!(out, "pub extern \"C\" fn {fn_name}(args_json: *const c_char) -> *mut c_char {{").unwrap(); + writeln!(out, " let args = match cstr_to_str(args_json) {{").unwrap(); + writeln!(out, " Ok(s) => s, Err(e) => return error_json(&e),").unwrap(); + writeln!(out, " }};").unwrap(); + writeln!(out, " match {fn_name}_impl(args) {{").unwrap(); + writeln!(out, " Ok(r) => to_cstring(r), Err(e) => error_json(&e),").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + writeln!(out, "fn {fn_name}_impl(args: &str) -> Result {{").unwrap(); + writeln!(out, " let v: Value = serde_json::from_str(args).map_err(|e| format!(\"invalid JSON: {{}}\", e))?;").unwrap(); + writeln!(out, " let program_id = parse_program_id_hex(v[\"program_id_hex\"].as_str().ok_or(\"missing program_id_hex\")?)?;").unwrap(); + writeln!(out, " let wallet = init_wallet(&v)?;").unwrap(); + writeln!(out).unwrap(); + + // Parse args + for arg in &ix.args { + let name = rust_ident(&arg.name); + let parse_expr = idl_type_to_json_parse(&arg.type_, &format!("v[\"{}\"]", arg.name)); + writeln!(out, " let {name} = {parse_expr};").unwrap(); + } + writeln!(out).unwrap(); + + // Build a type map for PDA seed arg type checks (same pattern as generate_pda_helpers) + let param_type_map: std::collections::HashMap = ix.args.iter() + .map(|a| (rust_ident(&a.name), idl_type_to_rust(&a.type_))) + .collect(); + + // Resolve accounts + for acc in &ix.accounts { + let name = rust_ident(&acc.name); + if acc.rest { + // rest accounts parsed from JSON array + writeln!(out, " let {name}: Vec = v[\"{}\"].as_array()", acc.name).unwrap(); + writeln!(out, " .ok_or(\"missing {}\")?", acc.name).unwrap(); + writeln!(out, " .iter().map(|a| parse_account_id(a.as_str().ok_or(\"expected string\")?)).collect::,_>>()?;").unwrap(); + } else if let Some(pda) = &acc.pda { + writeln!(out, " let {name} = compute_pda(&[").unwrap(); + for seed in &pda.seeds { + match seed { + IdlSeed::Const { value } => writeln!(out, " b\"{value}\",").unwrap(), + IdlSeed::Account { path } => writeln!(out, " {}.as_ref(),", rust_ident(path)).unwrap(), + IdlSeed::Arg { path } => { + let pname = rust_ident(path); + let arg_ty = param_type_map.get(&pname).map(|s| s.as_str()).unwrap_or(""); + if arg_ty == "u64" { + writeln!(out, " &{pname}.to_le_bytes(),").unwrap(); + } else { + writeln!(out, " &{pname} as &[u8],").unwrap(); + } + } + } + } + writeln!(out, " ]);").unwrap(); + } else { + writeln!(out, " let {name} = parse_account_id(v[\"{}\"].as_str().ok_or(\"missing {}\")?)?;", + acc.name, acc.name).unwrap(); + } + } + writeln!(out).unwrap(); + + // Build account_ids vec (non-rest accounts first, then rest) + writeln!(out, " let mut account_ids: Vec = vec![").unwrap(); + for acc in ix.accounts.iter().filter(|a| !a.rest) { + writeln!(out, " {},", rust_ident(&acc.name)).unwrap(); + } + writeln!(out, " ];").unwrap(); + for acc in ix.accounts.iter().filter(|a| a.rest) { + writeln!(out, " account_ids.extend({});", rust_ident(&acc.name)).unwrap(); + } + + // Signer IDs + writeln!(out, " let signer_ids: Vec = vec![").unwrap(); + for acc in &signer_accounts { + writeln!(out, " {},", rust_ident(&acc.name)).unwrap(); + } + writeln!(out, " ];").unwrap(); + writeln!(out).unwrap(); + + // Build instruction + if ix.args.is_empty() { + writeln!(out, " let instruction = {instr_type}::{variant};").unwrap(); + } else { + writeln!(out, " let instruction = {instr_type}::{variant} {{").unwrap(); + for arg in &ix.args { + let name = rust_ident(&arg.name); + writeln!(out, " {name},").unwrap(); + } + writeln!(out, " }};").unwrap(); + } + writeln!(out).unwrap(); + + // Submit via tokio block_on + writeln!(out, " let rt = tokio::runtime::Runtime::new().map_err(|e| format!(\"tokio: {{}}\", e))?;").unwrap(); + writeln!(out, " let tx_hash = rt.block_on(async {{").unwrap(); + writeln!(out, " let nonces = wallet.get_accounts_nonces(signer_ids.clone()).await").unwrap(); + writeln!(out, " .map_err(|e| format!(\"nonces: {{}}\", e))?;").unwrap(); + writeln!(out, " let mut signing_keys = Vec::new();").unwrap(); + writeln!(out, " for sid in &signer_ids {{").unwrap(); + writeln!(out, " let key = wallet.storage().user_data").unwrap(); + writeln!(out, " .get_pub_account_signing_key(*sid)").unwrap(); + writeln!(out, " .ok_or_else(|| format!(\"signing key not found for {{}}\", sid))?;").unwrap(); + writeln!(out, " signing_keys.push(key);").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, " let message = Message::try_new(program_id, account_ids, nonces, instruction)").unwrap(); + writeln!(out, " .map_err(|e| format!(\"message: {{:?}}\", e))?;").unwrap(); + writeln!(out, " let witness_set = WitnessSet::for_message(&message, &signing_keys);").unwrap(); + writeln!(out, " let tx = PublicTransaction::new(message, witness_set);").unwrap(); + writeln!(out, " wallet.sequencer_client.send_transaction(common::transaction::NSSATransaction::Public(tx)).await").unwrap(); + writeln!(out, " .map_err(|e| format!(\"submit: {{}}\", e))").unwrap(); + writeln!(out, " .map(|r| hex::encode(r.0))").unwrap(); + writeln!(out, " }})?;").unwrap(); + writeln!(out).unwrap(); + writeln!(out, " Ok(json!({{\"success\": true, \"tx_hash\": tx_hash}}).to_string())").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + } + + // free + version + writeln!(out, "#[no_mangle]").unwrap(); + writeln!(out, "pub extern \"C\" fn {prefix}_free_string(s: *mut c_char) {{").unwrap(); + writeln!(out, " if !s.is_null() {{ unsafe {{ drop(CString::from_raw(s)) }}; }}").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "#[no_mangle]").unwrap(); + writeln!(out, "pub extern \"C\" fn {prefix}_version() -> *mut c_char {{").unwrap(); + writeln!(out, " to_cstring(\"{}\".to_string())", idl.version).unwrap(); + writeln!(out, "}}").unwrap(); + + // PDA compute helpers + out.push_str(&generate_pda_helpers(idl)); + Ok(out) +} + +/// Generate standalone PDA compute helper functions from an IDL. +/// +/// Emits one `pub fn compute_{account}_pda(...)` per unique account that has +/// a `pda` field in the IDL. The generated functions use SHA-256 to combine +/// multiple seeds (matching `spel-cli/src/pda.rs` behaviour) and return an +/// `AccountId` derived from the program ID and the combined seed. +pub fn generate_pda_helpers(idl: &SpelIdl) -> String { + use std::collections::HashSet; + let mut out = String::new(); + let mut seen: HashSet = HashSet::new(); + + for ix in &idl.instructions { + for acc in &ix.accounts { + let acc_name = snake_case(&acc.name); + if let Some(pda) = &acc.pda { + if !seen.insert(acc_name.clone()) { + continue; // already generated for this account name + } + + // Collect function parameters from arg seeds. + // Const seeds are inlined; account seeds get a TODO comment. + let mut params: Vec<(String, String)> = Vec::new(); + for seed in &pda.seeds { + match seed { + IdlSeed::Arg { path } => { + let ty = ix.args.iter().find(|a| a.name == *path) + .map(|a| idl_type_to_rust(&a.type_)) + .unwrap_or_else(|| "[u8; 32]".to_string()); + // Normalise aliases to raw array types for cleaner FFI signatures + let param_ty = match ty.as_str() { + "AccountId" => "[u8; 32]".to_string(), + "ProgramId" => "[u32; 8]".to_string(), + other => other.to_string(), + }; + params.push((rust_ident(path), param_ty)); + } + IdlSeed::Account { .. } => { + // account seeds: less common, skipped (see TODO inside generated fn) + } + IdlSeed::Const { .. } => {} + } + } + + // Doc comment + writeln!(out).unwrap(); + let seed_desc: Vec = pda.seeds.iter().map(|s| match s { + IdlSeed::Const { value } => format!("const(\"{}\")", value), + IdlSeed::Arg { path } => format!("arg({})", path), + IdlSeed::Account { path } => format!("account({})", path), + }).collect(); + writeln!(out, "/// Compute PDA for `{}` account.", acc.name).unwrap(); + writeln!(out, "/// Seeds: [{}]", seed_desc.join(", ")).unwrap(); + + // Build a type map for seed loops to look up arg types + let param_type_map: std::collections::HashMap = + params.iter().cloned().collect(); + + // Function signature + write!(out, "pub fn compute_{}_pda(", acc_name).unwrap(); + write!(out, "program_id: &ProgramId").unwrap(); + for (name, ty) in ¶ms { + // Primitive scalars (u64, u32, etc.) are passed by value + let is_scalar = matches!(ty.as_str(), "u64" | "u32" | "u16" | "u8" | "i64" | "i32" | "i16" | "i8" | "u128" | "i128"); + if is_scalar { + write!(out, ", {}: {}", name, ty).unwrap(); + } else { + write!(out, ", {}: &{}", name, ty).unwrap(); + } + } + writeln!(out, ") -> AccountId {{").unwrap(); + + let n_seeds = pda.seeds.len(); + if n_seeds == 1 { + // Single seed: use directly as PdaSeed bytes + match &pda.seeds[0] { + IdlSeed::Const { value } => { + writeln!(out, " let mut seed_bytes = [0u8; 32];").unwrap(); + writeln!(out, " let src = b\"{}\";", value).unwrap(); + writeln!(out, " seed_bytes[..src.len()].copy_from_slice(src);").unwrap(); + } + IdlSeed::Arg { path } => { + let pname = rust_ident(path); + let arg_ty = param_type_map.get(&pname).map(|s| s.as_str()).unwrap_or(""); + if arg_ty == "u64" { + // u64 single seed: pad little-endian bytes into [u8; 32] + writeln!(out, " let mut seed_bytes = [0u8; 32];").unwrap(); + writeln!(out, " seed_bytes[..8].copy_from_slice(&{}.to_le_bytes());", pname).unwrap(); + } else { + writeln!(out, " let seed_bytes: [u8; 32] = *{};", pname).unwrap(); + } + } + IdlSeed::Account { path } => { + let pname = rust_ident(path); + writeln!(out, " // TODO: account seed '{}' — pass the raw AccountId bytes here", path).unwrap(); + writeln!(out, " let seed_bytes: [u8; 32] = *{};", pname).unwrap(); + } + } + writeln!(out, " let pda_seed = nssa_core::program::PdaSeed::new(seed_bytes);").unwrap(); + writeln!(out, " AccountId::from((program_id, &pda_seed))").unwrap(); + } else { + // Multi-seed: SHA-256(seed1 || seed2 || ...) — matches spel-cli/src/pda.rs + writeln!(out, " use sha2::{{Sha256, Digest}};").unwrap(); + writeln!(out, " let mut hasher = Sha256::new();").unwrap(); + for seed in &pda.seeds { + match seed { + IdlSeed::Const { value } => { + writeln!(out, " {{").unwrap(); + writeln!(out, " let mut padded = [0u8; 32];").unwrap(); + writeln!(out, " let src = b\"{}\";", value).unwrap(); + writeln!(out, " padded[..src.len()].copy_from_slice(src);").unwrap(); + writeln!(out, " hasher.update(&padded);").unwrap(); + writeln!(out, " }}").unwrap(); + } + IdlSeed::Arg { path } => { + let pname = rust_ident(path); + let arg_ty = param_type_map.get(&pname).map(|s| s.as_str()).unwrap_or(""); + if arg_ty == "u64" { + // u64 seed: hash the little-endian bytes directly + writeln!(out, " hasher.update(&{}.to_le_bytes());", pname).unwrap(); + } else { + writeln!(out, " hasher.update({} as &[u8]);", pname).unwrap(); + } + } + IdlSeed::Account { path } => { + let pname = rust_ident(path); + writeln!(out, " // TODO: account seed '{}' — use account bytes", path).unwrap(); + writeln!(out, " hasher.update({} as &[u8]);", pname).unwrap(); + } + } + } + writeln!(out, " let combined: [u8; 32] = hasher.finalize().into();").unwrap(); + writeln!(out, " let pda_seed = nssa_core::program::PdaSeed::new(combined);").unwrap(); + writeln!(out, " AccountId::from((program_id, &pda_seed))").unwrap(); + } + writeln!(out, "}}").unwrap(); + } + } + } + + out +} + +/// Generate a C header file from an IDL. +pub fn generate_header(idl: &SpelIdl) -> Result { + let mut out = String::new(); + let prefix = snake_case(&idl.name); + let guard = format!("{}_FFI_H", prefix.to_uppercase()); + + writeln!(out, "/* Auto-generated C header for {} FFI. DO NOT EDIT. */", idl.name).unwrap(); + writeln!(out, "#ifndef {guard}").unwrap(); + writeln!(out, "#define {guard}").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "#ifdef __cplusplus").unwrap(); + writeln!(out, "extern \"C\" {{").unwrap(); + writeln!(out, "#endif").unwrap(); + writeln!(out).unwrap(); + + for ix in &idl.instructions { + let fn_name = format!("{}_{}", prefix, snake_case(&ix.name)); + writeln!(out, "/* {} instruction */", ix.name).unwrap(); + writeln!(out, "char* {fn_name}(const char* args_json);").unwrap(); + writeln!(out).unwrap(); + } + + writeln!(out, "void {prefix}_free_string(char* s);").unwrap(); + writeln!(out, "char* {prefix}_version(void);").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "#ifdef __cplusplus").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out, "#endif").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "#endif /* {guard} */").unwrap(); + + Ok(out) +} diff --git a/spel-client-gen/src/lib.rs b/spel-client-gen/src/lib.rs new file mode 100644 index 00000000..94d77b92 --- /dev/null +++ b/spel-client-gen/src/lib.rs @@ -0,0 +1,50 @@ +//! # spel-client-gen +//! +//! Generates typed Rust client code and C FFI wrappers from SPEL program IDL JSON. +//! +//! ## Usage +//! +//! ```rust,ignore +//! use spel_client_gen::generate_from_idl_json; +//! use std::fs; +//! +//! let idl_json = fs::read_to_string("my_program_idl.json")?; +//! let output = generate_from_idl_json(&idl_json)?; +//! fs::write("src/generated_client.rs", &output.client_code)?; +//! fs::write("src/generated_ffi.rs", &output.ffi_code)?; +//! ``` + +use spel_framework_core::idl::*; + +mod codegen; +mod ffi_codegen; +mod util; + +#[cfg(test)] +mod tests; + +/// Output of code generation. +#[derive(Debug, Clone)] +pub struct CodegenOutput { + /// Typed Rust client module source code. + pub client_code: String, + /// C FFI wrapper source code. + pub ffi_code: String, + /// C header file content. + pub header: String, +} + +/// Generate client + FFI code from an IDL JSON string. +pub fn generate_from_idl_json(json: &str) -> Result { + let idl: SpelIdl = serde_json::from_str(json) + .map_err(|e| format!("failed to parse IDL JSON: {}", e))?; + generate_from_idl(&idl) +} + +/// Generate client + FFI code from a parsed IDL. +pub fn generate_from_idl(idl: &SpelIdl) -> Result { + let client_code = codegen::generate_client(idl)?; + let ffi_code = ffi_codegen::generate_ffi(idl)?; + let header = ffi_codegen::generate_header(idl)?; + Ok(CodegenOutput { client_code, ffi_code, header }) +} diff --git a/spel-client-gen/src/main.rs b/spel-client-gen/src/main.rs new file mode 100644 index 00000000..44531c23 --- /dev/null +++ b/spel-client-gen/src/main.rs @@ -0,0 +1,76 @@ +//! CLI tool for generating client/FFI code from SPEL program IDL. +//! +//! Usage: +//! spel-client-gen --idl path/to/idl.json --out-dir generated/ + +use std::path::PathBuf; + +fn main() { + if let Err(e) = run() { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let mut idl_path: Option = None; + let mut out_dir: Option = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--idl" => { + idl_path = Some(PathBuf::from(args.get(i + 1).ok_or("--idl requires value")?)); + i += 2; + } + "--out-dir" => { + out_dir = Some(PathBuf::from(args.get(i + 1).ok_or("--out-dir requires value")?)); + i += 2; + } + "--help" | "-h" => { + println!("spel-client-gen - Generate typed Rust client and C FFI from SPEL IDL"); + println!(); + println!("Usage:"); + println!(" spel-client-gen --idl --out-dir "); + println!(); + println!("Options:"); + println!(" --idl Path to IDL JSON file"); + println!(" --out-dir Output directory for generated files"); + return Ok(()); + } + other => return Err(format!("unknown argument: {other}").into()), + } + } + + let idl_path = idl_path.ok_or("missing --idl")?; + let out_dir = out_dir.ok_or("missing --out-dir")?; + + let json = std::fs::read_to_string(&idl_path) + .map_err(|e| format!("failed to read {}: {}", idl_path.display(), e))?; + + let output = spel_client_gen::generate_from_idl_json(&json)?; + + std::fs::create_dir_all(&out_dir) + .map_err(|e| format!("failed to create {}: {}", out_dir.display(), e))?; + + let program_name = { + let idl: serde_json::Value = serde_json::from_str(&json)?; + idl["name"].as_str().unwrap_or("program").to_string() + }; + + let client_path = out_dir.join(format!("{}_client.rs", program_name.replace('-', "_"))); + let ffi_path = out_dir.join(format!("{}_ffi.rs", program_name.replace('-', "_"))); + let header_path = out_dir.join(format!("{}.h", program_name.replace('-', "_"))); + + std::fs::write(&client_path, &output.client_code)?; + std::fs::write(&ffi_path, &output.ffi_code)?; + std::fs::write(&header_path, &output.header)?; + + println!("Generated:"); + println!(" Client: {}", client_path.display()); + println!(" FFI: {}", ffi_path.display()); + println!(" Header: {}", header_path.display()); + + Ok(()) +} diff --git a/spel-client-gen/src/tests.rs b/spel-client-gen/src/tests.rs new file mode 100644 index 00000000..5b994137 --- /dev/null +++ b/spel-client-gen/src/tests.rs @@ -0,0 +1,696 @@ +//! Tests for spel-client-gen. + +use crate::generate_from_idl_json; + +/// Sample IDL similar to what the spel-framework macro generates. +const SAMPLE_IDL: &str = r#"{ + "version": "0.1.0", + "name": "my_multisig", + "instructions": [ + { + "name": "create", + "accounts": [ + { + "name": "multisig_state", + "writable": true, + "signer": false, + "init": true, + "pda": { + "seeds": [ + {"kind": "const", "value": "multisig_state__"}, + {"kind": "arg", "path": "create_key"} + ] + } + }, + { + "name": "creator", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + {"name": "create_key", "type": "[u8; 32]"}, + {"name": "threshold", "type": "u64"}, + {"name": "members", "type": {"vec": "[u8; 32]"}} + ] + }, + { + "name": "approve", + "accounts": [ + { + "name": "multisig_state", + "writable": false, + "signer": false, + "init": false, + "pda": { + "seeds": [ + {"kind": "const", "value": "multisig_state__"} + ] + } + }, + { + "name": "proposal", + "writable": true, + "signer": false, + "init": false + }, + { + "name": "member", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [ + {"name": "proposal_id", "type": "u64"} + ] + } + ], + "accounts": [], + "types": [], + "errors": [] +}"#; + +#[test] +fn test_parse_and_generate() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + + // Client code checks + assert!(output.client_code.contains("pub enum MyMultisigInstruction")); + assert!(output.client_code.contains("Create {")); + assert!(output.client_code.contains("Approve {")); + assert!(output.client_code.contains("pub struct CreateAccounts")); + assert!(output.client_code.contains("pub struct ApproveAccounts")); + assert!(output.client_code.contains("pub struct MyMultisigClient")); + assert!(output.client_code.contains("async fn create(")); + assert!(output.client_code.contains("async fn approve(")); + + // PDA computation — standalone function + assert!(output.client_code.contains("pub fn compute_multisig_state_pda(")); + + + // Correct endianness — in client's parse_program_id_hex + assert!(output.client_code.contains("from_le_bytes")); +} + +#[test] +fn test_ffi_generation() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + + // FFI function names + assert!(output.ffi_code.contains("pub extern \"C\" fn my_multisig_create(")); + assert!(output.ffi_code.contains("pub extern \"C\" fn my_multisig_approve(")); + assert!(output.ffi_code.contains("pub extern \"C\" fn my_multisig_free_string(")); + assert!(output.ffi_code.contains("pub extern \"C\" fn my_multisig_version(")); + + // AccountId parsing helper emitted in FFI + assert!(output.ffi_code.contains("parse_account_id")); + + // FFI is self-contained (inline transaction building, no super::client import) + assert!(!output.ffi_code.contains("use super::client::*")); + + // FFI emits full WalletCore transaction building + assert!(output.ffi_code.contains("use wallet::WalletCore")); + assert!(output.ffi_code.contains("tokio::runtime::Runtime::new")); + assert!(output.ffi_code.contains("rt.block_on")); + assert!(output.ffi_code.contains("send_transaction")); + + // FFI returns tx_hash JSON + assert!(output.ffi_code.contains("tx_hash")); +} + +#[test] +fn test_header_generation() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + + assert!(output.header.contains("MY_MULTISIG_FFI_H")); + assert!(output.header.contains("char* my_multisig_create(const char* args_json)")); + assert!(output.header.contains("char* my_multisig_approve(const char* args_json)")); + assert!(output.header.contains("void my_multisig_free_string(char* s)")); +} + +#[test] +fn test_account_order_in_client() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + + // Account ordering is now enforced in the client (accounts struct + account_ids vec). + // For approve: the IDL order is multisig_state, proposal, member. + let client = &output.client_code; + let approve_struct_start = client.find("pub struct ApproveAccounts").unwrap(); + let approve_section = &client[approve_struct_start..]; + + let ms_pos = approve_section.find("multisig_state").unwrap(); + let prop_pos = approve_section.find("proposal").unwrap(); + let member_pos = approve_section.find("member").unwrap(); + + assert!(ms_pos < prop_pos, "multisig_state should come before proposal in ApproveAccounts"); + assert!(prop_pos < member_pos, "proposal should come before member in ApproveAccounts"); +} + +#[test] +fn test_ffi_calls_client_methods() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + + // The FFI impl builds instruction enum and submits transaction inline + let ffi = &output.ffi_code; + assert!(ffi.contains("Message::try_new"), "FFI should build Message"); + assert!(ffi.contains("send_transaction"), "FFI should submit transaction"); + assert!(ffi.contains("MyMultisigInstruction"), "FFI should reference instruction enum"); +} + +#[test] +fn test_invalid_json_error() { + let result = generate_from_idl_json("not json"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("failed to parse IDL JSON")); +} + +#[test] +fn test_empty_instructions() { + let idl = r#"{ + "version": "0.1.0", + "name": "empty_program", + "instructions": [] + }"#; + let output = generate_from_idl_json(idl).expect("should handle empty instructions"); + assert!(output.client_code.contains("EmptyProgramInstruction")); + assert!(output.ffi_code.contains("empty_program_free_string")); +} + +#[test] +fn test_rest_accounts() { + let idl = r#"{ + "version": "0.1.0", + "name": "test_prog", + "instructions": [{ + "name": "multi_sign", + "accounts": [ + {"name": "state", "writable": true, "signer": false, "init": false}, + {"name": "signers", "writable": false, "signer": true, "init": false, "rest": true} + ], + "args": [] + }], + "accounts": [], + "types": [], + "errors": [] + }"#; + let output = generate_from_idl_json(idl).expect("should handle rest accounts"); + assert!(output.client_code.contains("pub signers: Vec")); + // FFI should handle rest accounts as optional array, defaulting to empty + assert!(output.ffi_code.contains("signers")); +} + +#[test] +fn test_pda_helpers_single_arg_seed() { + use spel_framework_core::idl::*; + use crate::ffi_codegen::generate_pda_helpers; + + let idl = SpelIdl { + version: "0.1.0".to_string(), + name: "test_program".to_string(), + instructions: vec![IdlInstruction { + name: "create".to_string(), + accounts: vec![IdlAccountItem { + name: "multisig_state".to_string(), + writable: true, + signer: false, + init: true, + owner: None, + pda: Some(IdlPda { + seeds: vec![IdlSeed::Arg { path: "create_key".to_string() }], + }), + rest: false, + visibility: vec![], + }], + args: vec![IdlArg { + name: "create_key".to_string(), + type_: IdlType::Primitive("[u8; 32]".to_string()), + + }], + discriminator: None, + execution: None, + variant: None, + }], + accounts: vec![], + types: vec![], + errors: vec![], + spec: None, + metadata: None, + instruction_type: None, + }; + + let output = generate_pda_helpers(&idl); + + // Function signature + assert!(output.contains("pub fn compute_multisig_state_pda("), "missing fn signature: {}", output); + assert!(output.contains("program_id: &ProgramId"), "missing program_id param: {}", output); + assert!(output.contains("create_key: &[u8; 32]"), "missing create_key param: {}", output); + assert!(output.contains("-> AccountId"), "missing return type: {}", output); + + // Single-seed: use directly (no SHA256) + assert!(output.contains("PdaSeed::new(seed_bytes)"), "missing PdaSeed::new: {}", output); + assert!(output.contains("AccountId::from((program_id, &pda_seed))"), "missing AccountId::from: {}", output); + + // Single seed means no SHA256 hasher + assert!(!output.contains("Sha256"), "single-seed should not use SHA256: {}", output); +} + +#[test] +fn test_pda_helpers_multi_seed() { + use spel_framework_core::idl::*; + use crate::ffi_codegen::generate_pda_helpers; + + let idl = SpelIdl { + version: "0.1.0".to_string(), + name: "test_program".to_string(), + instructions: vec![IdlInstruction { + name: "create".to_string(), + accounts: vec![IdlAccountItem { + name: "multisig_state".to_string(), + writable: true, + signer: false, + init: true, + owner: None, + pda: Some(IdlPda { + seeds: vec![ + IdlSeed::Const { value: "multisig_state__".to_string() }, + IdlSeed::Arg { path: "create_key".to_string() }, + ], + }), + rest: false, + visibility: vec![], + }], + args: vec![IdlArg { + name: "create_key".to_string(), + type_: IdlType::Primitive("[u8; 32]".to_string()), + + }], + discriminator: None, + execution: None, + variant: None, + }], + accounts: vec![], + types: vec![], + errors: vec![], + spec: None, + metadata: None, + instruction_type: None, + }; + + let output = generate_pda_helpers(&idl); + + // Function signature + assert!(output.contains("pub fn compute_multisig_state_pda("), "missing fn signature: {}", output); + assert!(output.contains("create_key: &[u8; 32]"), "missing create_key param: {}", output); + + // Multi-seed: must use SHA256 + assert!(output.contains("Sha256"), "multi-seed must use SHA256: {}", output); + assert!(output.contains("hasher.update"), "must call hasher.update: {}", output); + assert!(output.contains("multisig_state__"), "must inline const seed: {}", output); + + // Doc comment seeds annotation + assert!(output.contains("Seeds: ["), "missing Seeds doc comment: {}", output); + assert!(output.contains("arg(create_key)"), "missing arg seed in doc: {}", output); +} + +#[test] +fn test_pda_helpers_deduplication() { + use spel_framework_core::idl::*; + use crate::ffi_codegen::generate_pda_helpers; + + // Same account name appears in two instructions — should only generate one helper + let make_ix = |name: &str| IdlInstruction { + name: name.to_string(), + accounts: vec![IdlAccountItem { + name: "shared_state".to_string(), + writable: true, + signer: false, + init: false, + owner: None, + pda: Some(IdlPda { + seeds: vec![IdlSeed::Arg { path: "my_key".to_string() }], + }), + rest: false, + visibility: vec![], + }], + args: vec![IdlArg { + name: "my_key".to_string(), + type_: IdlType::Primitive("[u8; 32]".to_string()), + }], + discriminator: None, + execution: None, + variant: None, + }; + + let idl = SpelIdl { + version: "0.1.0".to_string(), + name: "test_program".to_string(), + instructions: vec![make_ix("create"), make_ix("update")], + accounts: vec![], + types: vec![], + errors: vec![], + spec: None, + metadata: None, + instruction_type: None, + }; + + let output = generate_pda_helpers(&idl); + + // Should appear exactly once + let count = output.matches("pub fn compute_shared_state_pda(").count(); + assert_eq!(count, 1, "account PDA helper should be generated exactly once, got {}", count); +} + +#[test] +fn test_pda_helpers_in_ffi_output() { + // Verify generate_ffi includes PDA helpers in its output + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + + // The SAMPLE_IDL has multisig_state with a 2-seed PDA (const + arg) + assert!( + output.ffi_code.contains("pub fn compute_multisig_state_pda("), + "FFI output must include PDA helper function" + ); + assert!( + output.ffi_code.contains("create_key: &[u8; 32]"), + "FFI PDA helper must have create_key param" + ); + assert!( + output.ffi_code.contains("Sha256"), + "FFI PDA helper for multi-seed must use SHA256" + ); +} + +#[test] +fn test_pda_helpers_u64_single_seed() { + use spel_framework_core::idl::*; + use crate::ffi_codegen::generate_pda_helpers; + + // A PDA with a single u64 arg seed (e.g. proposal_index) + let idl = SpelIdl { + version: "0.1.0".to_string(), + name: "test_program".to_string(), + instructions: vec![IdlInstruction { + name: "create_proposal".to_string(), + accounts: vec![IdlAccountItem { + name: "proposal".to_string(), + writable: true, + signer: false, + init: true, + owner: None, + pda: Some(IdlPda { + seeds: vec![IdlSeed::Arg { path: "proposal_index".to_string() }], + }), + rest: false, + visibility: vec![], + }], + args: vec![IdlArg { + name: "proposal_index".to_string(), + type_: IdlType::Primitive("u64".to_string()), + }], + discriminator: None, + execution: None, + variant: None, + }], + accounts: vec![], + types: vec![], + errors: vec![], + spec: None, + metadata: None, + instruction_type: None, + }; + + let output = generate_pda_helpers(&idl); + + // Function signature: u64 passed by value (no &) + assert!(output.contains("pub fn compute_proposal_pda("), "missing fn signature: {}", output); + assert!(output.contains("proposal_index: u64"), "u64 param should be by value: {}", output); + assert!(!output.contains("proposal_index: &u64"), "u64 param must not be by reference: {}", output); + assert!(output.contains("-> AccountId"), "missing return type: {}", output); + + // Single u64 seed: uses to_le_bytes() padded into [u8; 32] + assert!(output.contains("to_le_bytes()"), "u64 seed must use to_le_bytes: {}", output); + assert!(output.contains("seed_bytes[..8].copy_from_slice"), "must copy 8 bytes of u64: {}", output); + assert!(output.contains("PdaSeed::new(seed_bytes)"), "must create PdaSeed: {}", output); +} + +#[test] +fn test_pda_helpers_u64_multi_seed() { + use spel_framework_core::idl::*; + use crate::ffi_codegen::generate_pda_helpers; + + // A PDA with const + u64 arg seeds (e.g. proposal with index) + let idl = SpelIdl { + version: "0.1.0".to_string(), + name: "test_program".to_string(), + instructions: vec![IdlInstruction { + name: "create_proposal".to_string(), + accounts: vec![IdlAccountItem { + name: "proposal".to_string(), + writable: true, + signer: false, + init: true, + owner: None, + pda: Some(IdlPda { + seeds: vec![ + IdlSeed::Arg { path: "create_key".to_string() }, + IdlSeed::Arg { path: "proposal_index".to_string() }, + ], + }), + rest: false, + visibility: vec![], + }], + args: vec![ + IdlArg { + name: "create_key".to_string(), + type_: IdlType::Primitive("[u8; 32]".to_string()), + }, + IdlArg { + name: "proposal_index".to_string(), + type_: IdlType::Primitive("u64".to_string()), + }, + ], + discriminator: None, + execution: None, + variant: None, + }], + accounts: vec![], + types: vec![], + errors: vec![], + spec: None, + metadata: None, + instruction_type: None, + }; + + let output = generate_pda_helpers(&idl); + + // Function signature: [u8;32] by ref, u64 by value + assert!(output.contains("pub fn compute_proposal_pda("), "missing fn signature: {}", output); + assert!(output.contains("create_key: &[u8; 32]"), "create_key should be by reference: {}", output); + assert!(output.contains("proposal_index: u64"), "u64 param should be by value: {}", output); + assert!(!output.contains("proposal_index: &u64"), "u64 param must not be by reference: {}", output); + + // Multi-seed: uses SHA256 + assert!(output.contains("Sha256"), "multi-seed must use SHA256: {}", output); + assert!(output.contains("hasher.update"), "must call hasher.update: {}", output); + + // u64 seed uses to_le_bytes, not as &[u8] + assert!(output.contains("proposal_index.to_le_bytes()"), "u64 seed must use to_le_bytes: {}", output); + assert!(!output.contains("proposal_index as &[u8]"), "u64 must not use as &[u8]: {}", output); + + // [u8;32] seed uses as &[u8] + assert!(output.contains("create_key as &[u8]"), "byte array seed must use as &[u8]: {}", output); +} + +#[test] +fn test_standalone_pda_helpers() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + let code = &output.client_code; + + // PDA helper is a standalone pub function (not a method) + assert!( + code.contains("pub fn compute_multisig_state_pda(program_id: &ProgramId"), + "should generate standalone PDA helper with program_id parameter" + ); + + // Should use spel_framework_core::pda::compute_pda + assert!( + code.contains("spel_framework_core::pda::compute_pda(program_id"), + "PDA helper should use framework core compute_pda" + ); + + // Should use create_key seed (from first occurrence); [u8; 32] maps to AccountId + assert!( + code.contains("create_key: &AccountId"), + "PDA helper should take create_key arg seed" + ); + + // Deduplication: only one compute_multisig_state_pda (not two, despite appearing in both instructions) + let count = code.matches("pub fn compute_multisig_state_pda(").count(); + assert_eq!(count, 1, "should deduplicate PDA helpers by account name"); +} + +#[test] +fn test_fetch_helpers() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + let code = &output.client_code; + + // Fetch helper is a method on the client + assert!( + code.contains("pub async fn fetch_multisig_state("), + "should generate fetch helper" + ); + + // Fetch helper calls PDA computation + assert!( + code.contains("compute_multisig_state_pda(&self.program_id"), + "fetch helper should call PDA helper with self.program_id" + ); + + // Fetch helper deserializes with Borsh + assert!( + code.contains("T::try_from_slice("), + "fetch helper should use BorshDeserialize" + ); + + // Fetch helper gets account from sequencer + assert!( + code.contains("get_account(account_id)"), + "fetch helper should fetch account data" + ); + + // Deduplication: only one fetch_multisig_state + let count = code.matches("async fn fetch_multisig_state<").count(); + assert_eq!(count, 1, "should deduplicate fetch helpers by account name"); +} + +#[test] +fn test_borsh_import() { + let output = generate_from_idl_json(SAMPLE_IDL).expect("codegen should succeed"); + assert!( + output.client_code.contains("use borsh::BorshDeserialize;"), + "should import BorshDeserialize" + ); +} + +#[test] +fn test_pda_helper_with_numeric_seed() { + let idl = r#"{ + "version": "0.1.0", + "name": "counter", + "instructions": [{ + "name": "increment", + "accounts": [ + { + "name": "counter_state", + "writable": true, + "signer": false, + "init": false, + "pda": { + "seeds": [ + {"kind": "const", "value": "counter"}, + {"kind": "arg", "path": "counter_id"} + ] + } + } + ], + "args": [ + {"name": "counter_id", "type": "u64"} + ] + }] + }"#; + let output = generate_from_idl_json(idl).expect("codegen should succeed"); + let code = &output.client_code; + + // u64 arg should be passed by value + assert!( + code.contains("counter_id: u64"), + "numeric seed arg should be passed by value" + ); + + // Should use to_be_bytes for u64 and pad to [u8; 32] + assert!( + code.contains("counter_id_be = counter_id.to_be_bytes()"), + "should convert u64 to big-endian bytes" + ); + + // Fetch helper for this account + assert!( + code.contains("async fn fetch_counter_state("), + "should generate fetch helper for counter_state" + ); +} + +#[test] +fn test_pda_helper_with_account_seed() { + let idl = r#"{ + "version": "0.1.0", + "name": "vault", + "instructions": [{ + "name": "create_vault", + "accounts": [ + { + "name": "vault_state", + "writable": true, + "signer": false, + "init": true, + "pda": { + "seeds": [ + {"kind": "const", "value": "vault"}, + {"kind": "account", "path": "owner"} + ] + } + }, + { + "name": "owner", + "writable": false, + "signer": true, + "init": false + } + ], + "args": [] + }] + }"#; + let output = generate_from_idl_json(idl).expect("codegen should succeed"); + let code = &output.client_code; + + // Account seed should be &AccountId + assert!( + code.contains("owner: &AccountId"), + "account seed param should be &AccountId" + ); + + // Should use value() for AccountId to get &[u8; 32] + assert!( + code.contains("owner.value()"), + "should use value() for account seed" + ); +} + +#[test] +fn test_no_pda_no_helpers() { + let idl = r#"{ + "version": "0.1.0", + "name": "simple", + "instructions": [{ + "name": "do_thing", + "accounts": [ + {"name": "state", "writable": true, "signer": false, "init": false} + ], + "args": [{"name": "value", "type": "u64"}] + }] + }"#; + let output = generate_from_idl_json(idl).expect("codegen should succeed"); + let code = &output.client_code; + + // No PDA helpers should be generated + assert!( + !code.contains("pub fn compute_"), + "should not generate PDA helpers when no PDAs" + ); + assert!( + !code.contains("async fn fetch_"), + "should not generate fetch helpers when no PDAs" + ); +} diff --git a/spel-client-gen/src/util.rs b/spel-client-gen/src/util.rs new file mode 100644 index 00000000..4d4fc64f --- /dev/null +++ b/spel-client-gen/src/util.rs @@ -0,0 +1,118 @@ +//! Shared utility functions for code generation. + +/// Convert a name to snake_case. +pub fn snake_case(s: &str) -> String { + let mut out = String::new(); + for (i, ch) in s.chars().enumerate() { + if ch.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + collapse_underscores(&out) +} + +/// Convert a name to PascalCase. +pub fn pascal_case(s: &str) -> String { + let mut out = String::new(); + let mut upper = true; + for ch in s.chars() { + if ch.is_ascii_alphanumeric() { + if upper { + out.push(ch.to_ascii_uppercase()); + upper = false; + } else { + out.push(ch); + } + } else { + upper = true; + } + } + if out.is_empty() { "Program".to_string() } else { out } +} + +/// Make a valid Rust identifier. +pub fn rust_ident(s: &str) -> String { + let ident = snake_case(s); + match ident.as_str() { + "type" | "match" | "mod" | "enum" | "struct" | "fn" | "crate" + | "self" | "super" | "pub" | "use" | "impl" | "trait" | "where" + | "async" | "await" | "move" | "ref" | "mut" | "const" | "static" + | "let" | "if" | "else" | "loop" | "while" | "for" | "in" + | "return" | "break" | "continue" => format!("r#{}", ident), + _ => ident, + } +} + +/// Map IDL type to Rust type string. +pub fn idl_type_to_rust(ty: &spel_framework_core::idl::IdlType) -> String { + use spel_framework_core::idl::IdlType; + match ty { + IdlType::Primitive(p) => match p.as_str() { + "account_id" | "AccountId" | "[u8; 32]" | "[u8;32]" => "AccountId".to_string(), + "ProgramId" | "[u32; 8]" | "[u32;8]" => "ProgramId".to_string(), + s => s.to_string(), + }, + IdlType::Vec { vec } => format!("Vec<{}>", idl_type_to_rust(vec)), + IdlType::Option { option } => format!("Option<{}>", idl_type_to_rust(option)), + IdlType::Defined { defined } => defined.clone(), + IdlType::Array { array: (elem, size) } => { + format!("[{}; {}]", idl_type_to_rust(elem), size) + } + } +} + +/// Map IDL type to a JSON parsing expression for FFI. +/// `var` is the expression to parse from (serde_json::Value). +pub fn idl_type_to_json_parse(ty: &spel_framework_core::idl::IdlType, var: &str) -> String { + use spel_framework_core::idl::IdlType; + match ty { + IdlType::Primitive(p) => match p.as_str() { + "account_id" | "AccountId" | "[u8; 32]" | "[u8;32]" => { + format!("parse_account_id({var}.as_str().ok_or(\"expected string for AccountId\")?)?") + } + "ProgramId" | "[u32; 8]" | "[u32;8]" => { + format!("parse_program_id({var}.as_str().ok_or(\"expected string for ProgramId\")?)?") + } + "String" => format!("{var}.as_str().ok_or(\"expected string\")?.to_string()"), + "bool" => format!("{var}.as_bool().ok_or(\"expected bool\")?"), + "u8" | "u16" | "u32" | "u64" | "u128" => { + format!("{var}.as_u64().ok_or(\"expected number\")? as {p}") + } + "i8" | "i16" | "i32" | "i64" | "i128" => { + format!("{var}.as_i64().ok_or(\"expected number\")? as {p}") + } + _ => format!("serde_json::from_value({var}.clone()).map_err(|e| format!(\"parse error: {{}}\", e))?"), + }, + IdlType::Vec { vec } => { + let inner = idl_type_to_json_parse(vec, "item"); + format!( + "{var}.as_array().ok_or(\"expected array\")?.iter().map(|item| Ok({inner})).collect::, String>>()?" + ) + } + _ => format!("serde_json::from_value({var}.clone()).map_err(|e| format!(\"parse error: {{}}\", e))?"), + } +} + +fn collapse_underscores(s: &str) -> String { + let mut out = String::new(); + let mut prev_underscore = false; + for ch in s.chars() { + if ch == '_' { + if !prev_underscore { + out.push('_'); + prev_underscore = true; + } + } else { + out.push(ch); + prev_underscore = false; + } + } + out.trim_matches('_').to_string() +} diff --git a/spel-framework-core/Cargo.toml b/spel-framework-core/Cargo.toml new file mode 100644 index 00000000..38f72e4f --- /dev/null +++ b/spel-framework-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "spel-framework-core" +version = "0.2.0" +edition = "2021" +description = "Core types for the SPEL program framework" + +[features] +default = [] +idl-gen = ["dep:syn"] + +[dependencies] +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b", features = ["host"] } +borsh = { version = "1.0", features = ["derive"] } +thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +syn = { version = "2.0", features = ["full", "extra-traits"], optional = true } diff --git a/nssa-framework-core/src/error.rs b/spel-framework-core/src/error.rs similarity index 74% rename from nssa-framework-core/src/error.rs rename to spel-framework-core/src/error.rs index d5b970b6..301721cd 100644 --- a/nssa-framework-core/src/error.rs +++ b/spel-framework-core/src/error.rs @@ -1,4 +1,4 @@ -//! Structured error types for NSSA programs. +//! Structured error types for SPEL programs. //! //! Replaces the current pattern of `panic!` and `.expect()` with //! proper Result-based error handling. @@ -6,25 +6,25 @@ use borsh::{BorshDeserialize, BorshSerialize}; use thiserror::Error; -/// Result type alias for NSSA program operations. +/// Result type alias for SPEL program operations. /// All instruction handlers should return this type. -pub type NssaResult = Result; +pub type SpelResult = Result; /// Re-export for convenience in result type -pub use crate::types::NssaOutput; +pub use crate::types::SpelOutput; -/// Structured error type for NSSA programs. +/// Structured error type for SPEL programs. /// /// Programs can use the built-in variants for common errors, /// or use `Custom` for program-specific error codes. /// /// # Example /// ```rust -/// use nssa_framework_core::error::NssaError; +/// use spel_framework_core::error::SpelError; /// -/// fn check_balance(balance: u128, amount: u128) -> Result<(), NssaError> { +/// fn check_balance(balance: u128, amount: u128) -> Result<(), SpelError> { /// if balance < amount { -/// return Err(NssaError::InsufficientBalance { +/// return Err(SpelError::InsufficientBalance { /// available: balance, /// requested: amount, /// }); @@ -33,7 +33,7 @@ pub use crate::types::NssaOutput; /// } /// ``` #[derive(Error, Debug, BorshSerialize, BorshDeserialize)] -pub enum NssaError { +pub enum SpelError { /// Wrong number of accounts provided for this instruction #[error("Expected {expected} accounts, got {actual}")] AccountCountMismatch { @@ -106,10 +106,10 @@ pub enum NssaError { }, } -impl NssaError { +impl SpelError { /// Create a custom error with a code and message. pub fn custom(code: u32, message: impl Into) -> Self { - NssaError::Custom { + SpelError::Custom { code, message: message.into(), } @@ -118,17 +118,17 @@ impl NssaError { /// Get a numeric error code for client-side handling. pub fn error_code(&self) -> u32 { match self { - NssaError::AccountCountMismatch { .. } => 1000, - NssaError::InvalidAccountOwner { .. } => 1001, - NssaError::AccountAlreadyInitialized { .. } => 1002, - NssaError::AccountNotInitialized { .. } => 1003, - NssaError::InsufficientBalance { .. } => 1004, - NssaError::DeserializationError { .. } => 1005, - NssaError::SerializationError { .. } => 1006, - NssaError::Overflow { .. } => 1007, - NssaError::Unauthorized { .. } => 1008, - NssaError::PdaMismatch { .. } => 1009, - NssaError::Custom { code, .. } => 6000 + code, + SpelError::AccountCountMismatch { .. } => 1000, + SpelError::InvalidAccountOwner { .. } => 1001, + SpelError::AccountAlreadyInitialized { .. } => 1002, + SpelError::AccountNotInitialized { .. } => 1003, + SpelError::InsufficientBalance { .. } => 1004, + SpelError::DeserializationError { .. } => 1005, + SpelError::SerializationError { .. } => 1006, + SpelError::Overflow { .. } => 1007, + SpelError::Unauthorized { .. } => 1008, + SpelError::PdaMismatch { .. } => 1009, + SpelError::Custom { code, .. } => 6000 + code, } } } diff --git a/nssa-framework-core/src/idl.rs b/spel-framework-core/src/idl.rs similarity index 57% rename from nssa-framework-core/src/idl.rs rename to spel-framework-core/src/idl.rs index 42663db0..7f036e45 100644 --- a/nssa-framework-core/src/idl.rs +++ b/spel-framework-core/src/idl.rs @@ -1,14 +1,21 @@ -//! IDL (Interface Definition Language) types for NSSA programs. +//! IDL (Interface Definition Language) types for SPEL programs. //! //! The proc-macro generates an IDL JSON file at compile time that //! describes the program's interface. This module defines the //! serializable IDL format. +//! +//! ## LSSA-lang compatibility +//! +//! This IDL format is a superset of the lssa-lang IDL spec. Fields like +//! `discriminator`, `execution`, and `visibility` are included for +//! compatibility with lssa-lang tooling. All new fields are optional +//! and backward-compatible with existing SPEL programs. use serde::{Deserialize, Serialize}; -/// Top-level IDL for an NSSA program. +/// Top-level IDL for an SPEL program. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NssaIdl { +pub struct SpelIdl { pub version: String, pub name: String, pub instructions: Vec, @@ -18,6 +25,35 @@ pub struct NssaIdl { pub types: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub errors: Vec, + /// IDL spec identifier (lssa-lang compat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spec: Option, + /// Program metadata (lssa-lang compat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// Optional fully-qualified Rust path to the program's instruction enum. + /// When set, generated FFI imports this type instead of generating a local enum. + /// Example: "multisig_core::Instruction" + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instruction_type: Option, +} + +/// Program metadata (lssa-lang compat). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdlMetadata { + pub name: String, + pub version: String, +} + +/// Execution mode for an instruction (lssa-lang compat). +/// +/// Maps to lssa-lang's `Execution` type which has `public` and `private_owned` flags. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IdlExecution { + #[serde(default)] + pub public: bool, + #[serde(default)] + pub private_owned: bool, } /// An instruction in the IDL. @@ -26,6 +62,15 @@ pub struct IdlInstruction { pub name: String, pub accounts: Vec, pub args: Vec, + /// SHA256("global:{name}")[..8] discriminator (lssa-lang compat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discriminator: Option>, + /// Execution mode (lssa-lang compat). Defaults to public. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + /// Variant name in PascalCase (lssa-lang compat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, } /// An account expected by an instruction. @@ -42,8 +87,16 @@ pub struct IdlAccountItem { pub owner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pda: Option, + /// If true, this account represents a variable-length trailing list. + #[serde(default, skip_serializing_if = "is_false")] + pub rest: bool, + /// Visibility tags (lssa-lang compat). e.g. ["public"]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub visibility: Vec, } +fn is_false(v: &bool) -> bool { !v } + /// PDA derivation specification. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdlPda { @@ -124,7 +177,18 @@ pub struct IdlError { pub msg: Option, } -impl NssaIdl { +/// Compute the lssa-lang discriminator for an instruction name. +/// +/// This is SHA256("global:{name}")[..8], matching lssa-lang's convention. +pub fn compute_discriminator(name: &str) -> Vec { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(format!("global:{}", name).as_bytes()); + let result = hasher.finalize(); + result[..8].to_vec() +} + +impl SpelIdl { /// Create a new IDL with the given program name. pub fn new(name: impl Into) -> Self { Self { @@ -134,6 +198,9 @@ impl NssaIdl { accounts: vec![], types: vec![], errors: vec![], + spec: None, + metadata: None, + instruction_type: None, } } diff --git a/spel-framework-core/src/idl_gen.rs b/spel-framework-core/src/idl_gen.rs new file mode 100644 index 00000000..a1b3085b --- /dev/null +++ b/spel-framework-core/src/idl_gen.rs @@ -0,0 +1,838 @@ +//! Runtime IDL generation from SPEL program source files. +//! +//! This module is gated behind the `idl-gen` feature and provides +//! `generate_idl_from_file()` for use by `spel-cli generate-idl`. +//! +//! The parsing logic mirrors the `generate_idl!` proc macro in +//! `spel-framework-macros`, but operates at runtime on a file path +//! rather than at compile time. + +use std::fmt; +use std::path::Path; + +use syn::{Attribute, FnArg, Ident, ItemFn, Pat, PatType, Type}; + +use crate::idl::{IdlAccountItem, IdlArg, IdlInstruction, IdlPda, IdlSeed, IdlType, SpelIdl}; + +/// Error type returned by [`generate_idl_from_file`]. +#[derive(Debug)] +pub enum IdlGenError { + Io(std::io::Error), + Parse(syn::Error), + NoProgram(String), + NoInstructions(String), +} + +impl fmt::Display for IdlGenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IdlGenError::Io(e) => write!(f, "IO error: {}", e), + IdlGenError::Parse(e) => write!(f, "Parse error: {}", e), + IdlGenError::NoProgram(path) => { + write!(f, "No #[lez_program] module found in '{}'", path) + } + IdlGenError::NoInstructions(path) => { + write!(f, "No #[instruction] functions found in '{}'", path) + } + } + } +} + +impl From for IdlGenError { + fn from(e: std::io::Error) -> Self { + IdlGenError::Io(e) + } +} + +impl From for IdlGenError { + fn from(e: syn::Error) -> Self { + IdlGenError::Parse(e) + } +} + +/// Parse a SPEL program source file and return its [`SpelIdl`]. +/// +/// The path is resolved relative to the current working directory, +/// which is the natural behavior for a CLI tool. +pub fn generate_idl_from_file(source_path: &Path) -> Result { + let content = std::fs::read_to_string(source_path)?; + generate_idl_from_str(&content, &source_path.display().to_string()) +} + +/// Parse a SPEL program from source text and return its [`SpelIdl`]. +/// +/// `source_label` is used only in error messages. +fn generate_idl_from_str(content: &str, source_label: &str) -> Result { + let path_str = source_label.to_string(); + + let file = syn::parse_file(content)?; + + // Find the #[lez_program] module + let program_mod = file + .items + .iter() + .find_map(|item| { + if let syn::Item::Mod(m) = item { + if m.attrs.iter().any(|a| a.path().is_ident("lez_program")) { + return Some(m); + } + } + None + }) + .ok_or_else(|| IdlGenError::NoProgram(path_str.clone()))?; + + let mod_name = program_mod.ident.to_string(); + + let (_, items) = program_mod + .content + .as_ref() + .ok_or_else(|| IdlGenError::NoProgram(path_str.clone()))?; + + // Collect instruction functions + let mut instructions: Vec = Vec::new(); + for item in items { + if let syn::Item::Fn(func) = item { + if has_instruction_attr(&func.attrs) { + instructions.push(parse_instruction(func.clone())?); + } + } + } + + if instructions.is_empty() { + return Err(IdlGenError::NoInstructions(path_str)); + } + + // Detect external instruction type from #[lez_program(instruction = "...")] + let external_instruction = program_mod + .attrs + .iter() + .find(|a| a.path().is_ident("lez_program")) + .and_then(|attr| { + let mut ext: Option = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("instruction") { + if let Ok(value) = meta.value() { + if let Ok(lit) = value.parse::() { + ext = Some(lit.value()); + } + } + } + Ok(()) + }); + ext + }); + + // Build the SpelIdl struct + let idl_instructions: Vec = instructions + .iter() + .map(|ix| { + let accounts: Vec = ix + .accounts + .iter() + .map(|acc| { + let pda = if acc.constraints.pda_seeds.is_empty() { + None + } else { + let seeds: Vec = acc + .constraints + .pda_seeds + .iter() + .map(|s| match s { + PdaSeedDef::Const(v) => IdlSeed::Const { value: v.clone() }, + PdaSeedDef::Account(p) => IdlSeed::Account { path: p.clone() }, + PdaSeedDef::Arg(p) => IdlSeed::Arg { path: p.clone() }, + }) + .collect(); + Some(IdlPda { seeds }) + }; + + IdlAccountItem { + name: acc.name.to_string(), + writable: acc.constraints.mutable, + signer: acc.constraints.signer, + init: acc.constraints.init, + owner: None, + pda, + rest: acc.is_rest, + visibility: vec![], + } + }) + .collect(); + + let args: Vec = ix + .args + .iter() + .map(|arg| IdlArg { + name: arg.name.to_string(), + type_: syn_type_to_idl_type(&arg.ty), + }) + .collect(); + + IdlInstruction { + name: ix.fn_name.to_string(), + accounts, + args, + discriminator: None, + execution: None, + variant: None, + } + }) + .collect(); + + Ok(SpelIdl { + version: "0.1.0".to_string(), + name: mod_name, + instructions: idl_instructions, + accounts: vec![], + types: vec![], + errors: vec![], + spec: None, + metadata: None, + instruction_type: external_instruction, + }) +} + +// ─── Internal parsing types ─────────────────────────────────────────────── + +struct InstructionInfo { + fn_name: Ident, + accounts: Vec, + args: Vec, +} + +struct AccountParam { + name: Ident, + constraints: AccountConstraints, + is_rest: bool, +} + +#[derive(Default)] +struct AccountConstraints { + mutable: bool, + init: bool, + signer: bool, + pda_seeds: Vec, +} + +#[derive(Clone)] +enum PdaSeedDef { + Const(String), + Account(String), + Arg(String), +} + +struct ArgParam { + name: Ident, + ty: Type, +} + +fn has_instruction_attr(attrs: &[Attribute]) -> bool { + attrs.iter().any(|a| a.path().is_ident("instruction")) +} + +fn parse_instruction(func: ItemFn) -> Result { + let fn_name = func.sig.ident.clone(); + let mut accounts = Vec::new(); + let mut args = Vec::new(); + + for input in &func.sig.inputs { + match input { + FnArg::Typed(pat_type) => { + let param_name = extract_param_name(pat_type)?; + let ty = &*pat_type.ty; + + if is_account_type(ty) { + let constraints = parse_account_constraints(&pat_type.attrs)?; + accounts.push(AccountParam { + name: param_name, + constraints, + is_rest: false, + }); + } else if is_vec_account_type(ty) { + let constraints = parse_account_constraints(&pat_type.attrs)?; + accounts.push(AccountParam { + name: param_name, + constraints, + is_rest: true, + }); + } else { + args.push(ArgParam { + name: param_name, + ty: ty.clone(), + }); + } + } + FnArg::Receiver(_) => { + return Err(IdlGenError::Parse(syn::Error::new_spanned( + input, + "instruction functions cannot have self parameter", + ))); + } + } + } + + Ok(InstructionInfo { + fn_name, + accounts, + args, + }) +} + +fn extract_param_name(pat_type: &PatType) -> Result { + match &*pat_type.pat { + Pat::Ident(pat_ident) => Ok(pat_ident.ident.clone()), + _ => Err(IdlGenError::Parse(syn::Error::new_spanned( + &pat_type.pat, + "expected simple identifier pattern", + ))), + } +} + +fn is_account_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + return segment.ident == "AccountWithMetadata"; + } + } + false +} + +fn is_vec_account_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + if segment.ident == "Vec" { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + return is_account_type(inner); + } + } + } + } + } + false +} + +fn parse_account_constraints(attrs: &[Attribute]) -> Result { + let mut constraints = AccountConstraints::default(); + + for attr in attrs { + if attr.path().is_ident("account") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("mut") { + constraints.mutable = true; + Ok(()) + } else if meta.path.is_ident("init") { + constraints.init = true; + constraints.mutable = true; + Ok(()) + } else if meta.path.is_ident("signer") { + constraints.signer = true; + Ok(()) + } else if meta.path.is_ident("owner") { + let value = meta.value()?; + let _expr: syn::Expr = value.parse()?; + Ok(()) + } else if meta.path.is_ident("pda") { + let value = meta.value()?; + let expr: syn::Expr = value.parse()?; + constraints.pda_seeds = parse_pda_expr(&expr)?; + Ok(()) + } else { + Err(meta.error("unknown account constraint")) + } + }) + .map_err(IdlGenError::Parse)?; + } + } + + Ok(constraints) +} + +fn parse_pda_expr(expr: &syn::Expr) -> Result, syn::Error> { + match expr { + syn::Expr::Call(call) => { + let seed = parse_single_pda_seed(call)?; + Ok(vec![seed]) + } + syn::Expr::Array(arr) => { + let mut seeds = Vec::new(); + for elem in &arr.elems { + if let syn::Expr::Call(call) = elem { + seeds.push(parse_single_pda_seed(call)?); + } else { + return Err(syn::Error::new_spanned( + elem, + "PDA seed must be const(\"...\"), account(\"...\"), or arg(\"...\")", + )); + } + } + Ok(seeds) + } + _ => Err(syn::Error::new_spanned( + expr, + "PDA seed must be const(\"...\"), account(\"...\"), arg(\"...\"), or [seed, ...]", + )), + } +} + +fn parse_single_pda_seed(call: &syn::ExprCall) -> Result { + let func_name = if let syn::Expr::Path(path) = &*call.func { + path.path + .get_ident() + .map(|i| i.to_string()) + .unwrap_or_default() + } else { + String::new() + }; + + if call.args.len() != 1 { + return Err(syn::Error::new_spanned( + call, + "PDA seed function takes exactly one string argument", + )); + } + + let arg = &call.args[0]; + let string_val = if let syn::Expr::Lit(lit) = arg { + if let syn::Lit::Str(s) = &lit.lit { + s.value() + } else { + return Err(syn::Error::new_spanned(arg, "Expected string literal")); + } + } else { + return Err(syn::Error::new_spanned(arg, "Expected string literal")); + }; + + match func_name.as_str() { + "const" | "r#const" | "seed_const" | "literal" => Ok(PdaSeedDef::Const(string_val)), + "account" => Ok(PdaSeedDef::Account(string_val)), + "arg" => Ok(PdaSeedDef::Arg(string_val)), + _ => Err(syn::Error::new_spanned( + call, + format!( + "Unknown PDA seed type '{}'. Use const(\"...\"), account(\"...\"), or arg(\"...\")", + func_name + ), + )), + } +} + +fn syn_type_to_idl_type(ty: &Type) -> IdlType { + match ty { + Type::Path(type_path) => { + let segment = match type_path.path.segments.last() { + Some(s) => s, + None => return IdlType::Primitive("unknown".to_string()), + }; + let ident = segment.ident.to_string(); + match ident.as_str() { + "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" + | "i128" | "bool" | "String" => IdlType::Primitive(ident.to_lowercase()), + "Vec" => { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + return IdlType::Vec { + vec: Box::new(syn_type_to_idl_type(inner)), + }; + } + } + IdlType::Primitive("vec".to_string()) + } + "Option" => { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + return IdlType::Option { + option: Box::new(syn_type_to_idl_type(inner)), + }; + } + } + IdlType::Primitive("option".to_string()) + } + "ProgramId" => IdlType::Primitive("program_id".to_string()), + "AccountId" => IdlType::Primitive("account_id".to_string()), + other => IdlType::Defined { + defined: other.to_string(), + }, + } + } + Type::Array(arr) => { + let elem = syn_type_to_idl_type(&arr.elem); + if let syn::Expr::Lit(lit) = &arr.len { + if let syn::Lit::Int(n) = &lit.lit { + if let Ok(size) = n.base10_parse::() { + return IdlType::Array { + array: (Box::new(elem), size), + }; + } + } + } + IdlType::Array { + array: (Box::new(elem), 0), + } + } + _ => IdlType::Primitive("unknown".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::idl::{IdlSeed, IdlType, SpelIdl}; + + fn ok(src: &str) -> SpelIdl { + generate_idl_from_str(src, "").expect("IDL generation failed") + } + + fn err(src: &str) -> IdlGenError { + generate_idl_from_str(src, "").expect_err("expected an error") + } + + // ── Error cases ────────────────────────────────────────────────────────── + + #[test] + fn error_no_lez_program_module() { + let src = r#" + pub fn some_function() {} + "#; + assert!(matches!(err(src), IdlGenError::NoProgram(_))); + } + + #[test] + fn error_no_instruction_functions() { + let src = r#" + #[lez_program] + pub mod my_program { + pub fn helper() {} + } + "#; + assert!(matches!(err(src), IdlGenError::NoInstructions(_))); + } + + #[test] + fn error_invalid_rust_syntax() { + let src = "this is not valid rust @@@"; + assert!(matches!(err(src), IdlGenError::Parse(_))); + } + + // ── Basic parsing ───────────────────────────────────────────────────────── + + #[test] + fn minimal_program_name_and_version() { + let src = r#" + #[lez_program] + pub mod my_token { + #[instruction] + pub fn transfer(sender: AccountWithMetadata, recipient: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.name, "my_token"); + assert_eq!(idl.version, "0.1.0"); + assert!(idl.instruction_type.is_none()); + } + + #[test] + fn external_instruction_type_attribute() { + let src = r#" + #[lez_program(instruction = "my_core::Instruction")] + pub mod my_program { + #[instruction] + pub fn do_thing(account: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.instruction_type.as_deref(), Some("my_core::Instruction")); + } + + // ── Account constraints ─────────────────────────────────────────────────── + + #[test] + fn account_no_constraints() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + let acc = &idl.instructions[0].accounts[0]; + assert_eq!(acc.name, "acc"); + assert!(!acc.writable); + assert!(!acc.signer); + assert!(!acc.init); + assert!(acc.pda.is_none()); + assert!(!acc.rest); + } + + #[test] + fn account_mut_constraint() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(#[account(mut)] acc: AccountWithMetadata) {} + } + "#; + let acc = &ok(src).instructions[0].accounts[0]; + assert!(acc.writable); + assert!(!acc.signer); + assert!(!acc.init); + } + + #[test] + fn account_signer_constraint() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(#[account(signer)] acc: AccountWithMetadata) {} + } + "#; + let acc = &ok(src).instructions[0].accounts[0]; + assert!(acc.signer); + assert!(!acc.writable); + } + + #[test] + fn account_init_implies_mut() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(#[account(init)] acc: AccountWithMetadata) {} + } + "#; + let acc = &ok(src).instructions[0].accounts[0]; + assert!(acc.init); + assert!(acc.writable, "init must imply writable"); + } + + #[test] + fn account_multiple_constraints() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(#[account(mut, signer)] acc: AccountWithMetadata) {} + } + "#; + let acc = &ok(src).instructions[0].accounts[0]; + assert!(acc.writable); + assert!(acc.signer); + } + + // ── PDA seeds ───────────────────────────────────────────────────────────── + + #[test] + fn account_pda_const_seed() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(#[account(pda = seed_const("pool"))] acc: AccountWithMetadata) {} + } + "#; + let acc = &ok(src).instructions[0].accounts[0]; + let pda = acc.pda.as_ref().expect("pda should be present"); + assert_eq!(pda.seeds.len(), 1); + assert!(matches!(&pda.seeds[0], IdlSeed::Const { value } if value == "pool")); + } + + #[test] + fn account_pda_account_seed() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(#[account(pda = account("owner.id"))] acc: AccountWithMetadata) {} + } + "#; + let pda = ok(src).instructions[0].accounts[0].pda.clone().unwrap(); + assert!(matches!(&pda.seeds[0], IdlSeed::Account { path } if path == "owner.id")); + } + + #[test] + fn account_pda_arg_seed() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(#[account(pda = arg("pool_id"))] acc: AccountWithMetadata) {} + } + "#; + let pda = ok(src).instructions[0].accounts[0].pda.clone().unwrap(); + assert!(matches!(&pda.seeds[0], IdlSeed::Arg { path } if path == "pool_id")); + } + + #[test] + fn account_pda_multiple_seeds() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix( + #[account(pda = [seed_const("amm"), account("base.id"), arg("quote_id")])] + acc: AccountWithMetadata, + ) {} + } + "#; + let pda = ok(src).instructions[0].accounts[0].pda.clone().unwrap(); + assert_eq!(pda.seeds.len(), 3); + assert!(matches!(&pda.seeds[0], IdlSeed::Const { value } if value == "amm")); + assert!(matches!(&pda.seeds[1], IdlSeed::Account { path } if path == "base.id")); + assert!(matches!(&pda.seeds[2], IdlSeed::Arg { path } if path == "quote_id")); + } + + // ── Rest accounts (Vec) ────────────────────────────── + + #[test] + fn vec_account_sets_rest_flag() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(single: AccountWithMetadata, rest: Vec) {} + } + "#; + let accounts = &ok(src).instructions[0].accounts; + assert_eq!(accounts.len(), 2); + assert!(!accounts[0].rest, "single account should not be rest"); + assert!(accounts[1].rest, "Vec should be rest"); + } + + // ── Instruction args ────────────────────────────────────────────────────── + + #[test] + fn primitive_arg_types() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix( + acc: AccountWithMetadata, + a: u64, + b: u32, + c: bool, + d: String, + ) {} + } + "#; + let args = &ok(src).instructions[0].args; + assert_eq!(args.len(), 4); + assert!(matches!(&args[0].type_, IdlType::Primitive(s) if s == "u64")); + assert!(matches!(&args[1].type_, IdlType::Primitive(s) if s == "u32")); + assert!(matches!(&args[2].type_, IdlType::Primitive(s) if s == "bool")); + assert!(matches!(&args[3].type_, IdlType::Primitive(s) if s == "string")); + } + + #[test] + fn vec_arg_type() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(acc: AccountWithMetadata, ids: Vec) {} + } + "#; + let args = &ok(src).instructions[0].args; + assert_eq!(args.len(), 1); + assert!( + matches!(&args[0].type_, IdlType::Vec { vec } if matches!(vec.as_ref(), IdlType::Primitive(s) if s == "u64")) + ); + } + + #[test] + fn option_arg_type() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(acc: AccountWithMetadata, maybe: Option) {} + } + "#; + let args = &ok(src).instructions[0].args; + assert!( + matches!(&args[0].type_, IdlType::Option { option } if matches!(option.as_ref(), IdlType::Primitive(s) if s == "u32")) + ); + } + + #[test] + fn array_arg_type() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(acc: AccountWithMetadata, data: [u8; 32]) {} + } + "#; + let args = &ok(src).instructions[0].args; + assert!( + matches!(&args[0].type_, IdlType::Array { array: (elem, size) } + if matches!(elem.as_ref(), IdlType::Primitive(s) if s == "u8") && *size == 32) + ); + } + + #[test] + fn defined_arg_type() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(acc: AccountWithMetadata, config: MyConfig) {} + } + "#; + let args = &ok(src).instructions[0].args; + assert!(matches!(&args[0].type_, IdlType::Defined { defined } if defined == "MyConfig")); + } + + #[test] + fn program_id_arg_type() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(acc: AccountWithMetadata, prog: ProgramId) {} + } + "#; + let args = &ok(src).instructions[0].args; + assert!(matches!(&args[0].type_, IdlType::Primitive(s) if s == "program_id")); + } + + #[test] + fn account_id_arg_type() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn ix(acc: AccountWithMetadata, id: AccountId) {} + } + "#; + let args = &ok(src).instructions[0].args; + assert!(matches!(&args[0].type_, IdlType::Primitive(s) if s == "account_id")); + } + + // ── Multiple instructions ───────────────────────────────────────────────── + + #[test] + fn multiple_instructions_order_preserved() { + let src = r#" + #[lez_program] + pub mod prog { + #[instruction] + pub fn alpha(acc: AccountWithMetadata) {} + + pub fn not_an_instruction(acc: AccountWithMetadata) {} + + #[instruction] + pub fn beta(acc: AccountWithMetadata, amount: u64) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.instructions.len(), 2); + assert_eq!(idl.instructions[0].name, "alpha"); + assert_eq!(idl.instructions[1].name, "beta"); + // non-annotated function is excluded + assert!(!idl.instructions.iter().any(|i| i.name == "not_an_instruction")); + } +} diff --git a/spel-framework-core/src/lib.rs b/spel-framework-core/src/lib.rs new file mode 100644 index 00000000..88424f34 --- /dev/null +++ b/spel-framework-core/src/lib.rs @@ -0,0 +1,20 @@ +//! # SPEL Framework Core +//! +//! Core types and traits for the SPEL program framework. + +pub mod error; +pub mod types; +pub mod idl; +pub mod pda; +pub mod validation; + +#[cfg(feature = "idl-gen")] +pub mod idl_gen; + +pub mod prelude { + pub use crate::error::{SpelError, SpelResult}; + pub use crate::pda::{compute_pda, seed_from_str}; + pub use crate::types::{SpelOutput, AccountConstraint}; + pub use nssa_core::account::{Account, AccountWithMetadata}; + pub use nssa_core::program::{AccountPostState, ChainedCall, PdaSeed, ProgramId}; +} diff --git a/spel-framework-core/src/pda.rs b/spel-framework-core/src/pda.rs new file mode 100644 index 00000000..0adf6532 --- /dev/null +++ b/spel-framework-core/src/pda.rs @@ -0,0 +1,146 @@ +//! Generic PDA (Program Derived Address) computation utilities. + +use nssa_core::account::AccountId; +use nssa_core::program::{PdaSeed, ProgramId}; +use sha2::{Sha256, Digest}; + +/// Convert a string to a zero-padded 32-byte seed. +/// +/// # Panics +/// +/// Panics if the string is longer than 32 bytes. +pub fn seed_from_str(s: &str) -> [u8; 32] { + let src = s.as_bytes(); + assert!(src.len() <= 32, "seed string '{}' exceeds 32 bytes", s); + let mut bytes = [0u8; 32]; + bytes[..src.len()].copy_from_slice(src); + bytes +} + +/// Compute a PDA `AccountId` from a program ID and one or more 32-byte seeds. +/// +/// - Single seed: used directly as the PDA seed. +/// - Multiple seeds: combined via SHA-256(seed1 || seed2 || ...) into a single +/// 32-byte seed. This avoids XOR commutativity and self-cancellation issues. +/// +/// # Panics +/// +/// Panics if `seeds` is empty. +pub fn compute_pda(program_id: &ProgramId, seeds: &[&[u8; 32]]) -> AccountId { + assert!(!seeds.is_empty(), "PDA requires at least one seed"); + + let combined = if seeds.len() == 1 { + *seeds[0] + } else { + let mut hasher = Sha256::new(); + for seed in seeds { + hasher.update(seed); + } + hasher.finalize().into() + }; + + let pda_seed = PdaSeed::new(combined); + AccountId::from((program_id, &pda_seed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_seed_from_str_basic() { + let seed = seed_from_str("hello"); + assert_eq!(&seed[..5], b"hello"); + assert_eq!(&seed[5..], &[0u8; 27]); + } + + #[test] + fn test_seed_from_str_exact_32() { + let s = "abcdefghijklmnopqrstuvwxyz012345"; // 32 bytes + let seed = seed_from_str(s); + assert_eq!(&seed, s.as_bytes()); + } + + #[test] + #[should_panic(expected = "exceeds 32 bytes")] + fn test_seed_from_str_too_long() { + seed_from_str("abcdefghijklmnopqrstuvwxyz0123456"); // 33 bytes + } + + #[test] + fn test_seed_from_str_empty() { + let seed = seed_from_str(""); + assert_eq!(seed, [0u8; 32]); + } + + #[test] + fn test_compute_pda_single_seed() { + let program_id: ProgramId = [1u32; 8]; + let seed = seed_from_str("test_seed"); + let account = compute_pda(&program_id, &[&seed]); + + // Same input must always produce the same output + let account2 = compute_pda(&program_id, &[&seed]); + assert_eq!(account, account2); + } + + #[test] + fn test_compute_pda_multi_seed() { + let program_id: ProgramId = [1u32; 8]; + let seed1 = seed_from_str("prefix"); + let seed2 = [42u8; 32]; + let account = compute_pda(&program_id, &[&seed1, &seed2]); + + let account2 = compute_pda(&program_id, &[&seed1, &seed2]); + assert_eq!(account, account2); + } + + #[test] + fn test_compute_pda_different_programs() { + let prog_a: ProgramId = [1u32; 8]; + let prog_b: ProgramId = [2u32; 8]; + let seed = seed_from_str("same_seed"); + + let a = compute_pda(&prog_a, &[&seed]); + let b = compute_pda(&prog_b, &[&seed]); + assert_ne!(a, b); + } + + #[test] + fn test_compute_pda_seed_order_matters() { + let program_id: ProgramId = [1u32; 8]; + let a = [0x01u8; 32]; + let b = [0x02u8; 32]; + + let ab = compute_pda(&program_id, &[&a, &b]); + let ba = compute_pda(&program_id, &[&b, &a]); + assert_ne!(ab, ba, "seed order must matter (non-commutative)"); + } + + #[test] + fn test_compute_pda_no_self_cancellation() { + let program_id: ProgramId = [1u32; 8]; + let a = [0xFFu8; 32]; + + let single = compute_pda(&program_id, &[&a]); + let double = compute_pda(&program_id, &[&a, &a]); + assert_ne!(single, double, "identical seeds must not cancel out"); + } + + #[test] + fn test_compute_pda_multi_vs_single() { + let program_id: ProgramId = [1u32; 8]; + let seed = seed_from_str("test"); + + let single = compute_pda(&program_id, &[&seed]); + let multi = compute_pda(&program_id, &[&seed, &[0u8; 32]]); + assert_ne!(single, multi); + } + + #[test] + #[should_panic(expected = "at least one seed")] + fn test_compute_pda_empty_seeds() { + let program_id: ProgramId = [1u32; 8]; + compute_pda(&program_id, &[]); + } +} diff --git a/nssa-framework-core/src/types.rs b/spel-framework-core/src/types.rs similarity index 94% rename from nssa-framework-core/src/types.rs rename to spel-framework-core/src/types.rs index d4cdb76a..be4b0b88 100644 --- a/nssa-framework-core/src/types.rs +++ b/spel-framework-core/src/types.rs @@ -1,18 +1,18 @@ -//! Core types for the NSSA framework. +//! Core types for the SPEL framework. //! //! These are thin wrappers/adapters that bridge framework ergonomics -//! with real NSSA core types. +//! with real SPEL core types. use nssa_core::program::{AccountPostState, ChainedCall}; /// Output from an instruction handler. #[derive(Debug, Clone)] -pub struct NssaOutput { +pub struct SpelOutput { pub post_states: Vec, pub chained_calls: Vec, } -impl NssaOutput { +impl SpelOutput { /// Create output with only post-states and no chained calls. pub fn states_only(post_states: Vec) -> Self { Self { diff --git a/nssa-framework-core/src/validation.rs b/spel-framework-core/src/validation.rs similarity index 90% rename from nssa-framework-core/src/validation.rs rename to spel-framework-core/src/validation.rs index 490658df..ea83b34a 100644 --- a/nssa-framework-core/src/validation.rs +++ b/spel-framework-core/src/validation.rs @@ -3,16 +3,16 @@ //! These functions are called by the macro-generated code to validate //! accounts before passing them to instruction handlers. -use crate::error::NssaError; +use crate::error::SpelError; use crate::types::AccountConstraint; /// Validate that the correct number of accounts was provided. pub fn validate_account_count( actual: usize, expected: usize, -) -> Result<(), NssaError> { +) -> Result<(), SpelError> { if actual != expected { - return Err(NssaError::AccountCountMismatch { expected, actual }); + return Err(SpelError::AccountCountMismatch { expected, actual }); } Ok(()) } @@ -21,7 +21,7 @@ pub fn validate_account_count( /// /// This is the main validation entry point called by generated code. /// In a real implementation, `accounts` would be `&[AccountWithMetadata]` -/// from NSSA core. +/// from SPEL core. /// /// # Generated usage /// ```rust,ignore @@ -35,7 +35,7 @@ pub fn validate_account_count( pub fn validate_accounts( account_count: usize, constraints: &[AccountConstraint], -) -> Result<(), NssaError> { +) -> Result<(), SpelError> { // First check count validate_account_count(account_count, constraints.len())?; @@ -63,9 +63,9 @@ pub fn verify_owner( account_owner: &[u8; 32], expected_owner: &[u8; 32], account_index: usize, -) -> Result<(), NssaError> { +) -> Result<(), SpelError> { if account_owner != expected_owner { - return Err(NssaError::InvalidAccountOwner { + return Err(SpelError::InvalidAccountOwner { account_index, expected_owner: hex::encode(expected_owner), }); diff --git a/spel-framework-core/tests/custom_instruction.rs b/spel-framework-core/tests/custom_instruction.rs new file mode 100644 index 00000000..0c8718d2 --- /dev/null +++ b/spel-framework-core/tests/custom_instruction.rs @@ -0,0 +1,50 @@ +//! Test that #[lez_program(instruction = "path")] accepts an external Instruction type. +//! +//! This tests the contract: programs can bring their own Instruction enum +//! and the framework will use it instead of generating one. + +use spel_framework_core::error::SpelError; +use spel_framework_core::types::SpelOutput; + +/// Simulates what a program with external Instruction would look like after expansion. +mod simulated_external_instruction { + use super::*; + + // This would come from multisig_core or similar external crate + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, borsh::BorshSerialize, borsh::BorshDeserialize)] + pub enum MyInstruction { + DoSomething { value: u64 }, + DoSomethingElse, + } + + // The macro would generate: `use my_crate::Instruction as Instruction;` + #[allow(unused)] + use MyInstruction as Instruction; + + // Verify the alias works for deserialization (what the generated main() does) + #[test] + fn test_external_instruction_deserializes() { + let instr = Instruction::DoSomething { value: 42 }; + let bytes = borsh::to_vec(&instr).unwrap(); + let decoded: Instruction = borsh::from_slice(&bytes).unwrap(); + match decoded { + Instruction::DoSomething { value } => assert_eq!(value, 42), + _ => panic!("Wrong variant"), + } + } + + // Verify handler can return SpelResult using the external instruction + fn handle_do_something(value: u64) -> Result { + if value == 0 { + return Err(SpelError::custom(1, "value cannot be zero")); + } + Ok(SpelOutput::states_only(vec![])) + } + + #[test] + fn test_handler_with_external_instruction() { + assert!(handle_do_something(42).is_ok()); + let err = handle_do_something(0).unwrap_err(); + assert_eq!(err.error_code(), 6001); + } +} diff --git a/spel-framework-core/tests/signer_validation.rs b/spel-framework-core/tests/signer_validation.rs new file mode 100644 index 00000000..05c405d0 --- /dev/null +++ b/spel-framework-core/tests/signer_validation.rs @@ -0,0 +1,131 @@ +//! Test that #[account(signer)] generates runtime validation checks. +//! +//! This is an expansion test — we cannot run the macro in a unit test directly, +//! so we test the validation functions that would be generated. + +use nssa_core::account::{Account, AccountId, AccountWithMetadata}; +use spel_framework_core::error::SpelError; + +/// Simulate the validation function that the macro would generate for: +/// ``` +/// #[instruction] +/// pub fn transfer( +/// #[account(mut)] from: AccountWithMetadata, +/// #[account(signer)] authority: AccountWithMetadata, +/// #[account(mut)] to: AccountWithMetadata, +/// ) -> SpelResult { ... } +/// ``` +fn __validate_transfer(accounts: &[AccountWithMetadata]) -> Result<(), SpelError> { + // Account index 1 has #[account(signer)] + if !accounts[1].is_authorized { + return Err(SpelError::Unauthorized { + message: format!("Account {} (index {}) must be a signer", "authority", 1), + }); + } + Ok(()) +} + +/// Simulate validation for an init + signer instruction: +/// ``` +/// #[instruction] +/// pub fn create_state( +/// #[account(init)] state: AccountWithMetadata, +/// #[account(signer)] creator: AccountWithMetadata, +/// ) -> SpelResult { ... } +/// ``` +fn __validate_create_state(accounts: &[AccountWithMetadata]) -> Result<(), SpelError> { + // Account index 0 has #[account(init)] + if accounts[0].account != Account::default() { + return Err(SpelError::AccountAlreadyInitialized { + account_index: 0, + }); + } + // Account index 1 has #[account(signer)] + if !accounts[1].is_authorized { + return Err(SpelError::Unauthorized { + message: format!("Account {} (index {}) must be a signer", "creator", 1), + }); + } + Ok(()) +} + +fn make_account(id: [u8; 32], authorized: bool) -> AccountWithMetadata { + AccountWithMetadata { + account_id: AccountId::new(id), + account: Account::default(), + is_authorized: authorized, + } +} + +fn make_account_with_data(id: [u8; 32], data: Vec, authorized: bool) -> AccountWithMetadata { + let mut account = Account::default(); + account.data = data.try_into().unwrap(); + AccountWithMetadata { + account_id: AccountId::new(id), + account, + is_authorized: authorized, + } +} + +#[test] +fn test_signer_authorized_passes() { + let accounts = vec![ + make_account([1u8; 32], false), // from (mut, not signer) + make_account([2u8; 32], true), // authority (signer) ← authorized + make_account([3u8; 32], false), // to (mut, not signer) + ]; + assert!(__validate_transfer(&accounts).is_ok()); +} + +#[test] +fn test_signer_unauthorized_fails() { + let accounts = vec![ + make_account([1u8; 32], false), + make_account([2u8; 32], false), // authority NOT authorized + make_account([3u8; 32], false), + ]; + let err = __validate_transfer(&accounts).unwrap_err(); + match err { + SpelError::Unauthorized { message } => { + assert!(message.contains("authority")); + assert!(message.contains("index 1")); + } + _ => panic!("Expected Unauthorized error, got {:?}", err), + } +} + +#[test] +fn test_init_uninitialized_passes() { + let accounts = vec![ + make_account([1u8; 32], false), // state (init, default = uninitialized) + make_account([2u8; 32], true), // creator (signer, authorized) + ]; + assert!(__validate_create_state(&accounts).is_ok()); +} + +#[test] +fn test_init_already_initialized_fails() { + let accounts = vec![ + make_account_with_data([1u8; 32], vec![42], false), // state already has data + make_account([2u8; 32], true), + ]; + let err = __validate_create_state(&accounts).unwrap_err(); + match err { + SpelError::AccountAlreadyInitialized { account_index } => { + assert_eq!(account_index, 0); + } + _ => panic!("Expected AccountAlreadyInitialized, got {:?}", err), + } +} + +#[test] +fn test_init_and_signer_both_checked() { + // Both init account initialized AND signer not authorized + let accounts = vec![ + make_account_with_data([1u8; 32], vec![42], false), // already initialized + make_account([2u8; 32], false), // not authorized + ]; + // Init check runs first, so we get AccountAlreadyInitialized + let err = __validate_create_state(&accounts).unwrap_err(); + assert!(matches!(err, SpelError::AccountAlreadyInitialized { .. })); +} diff --git a/spel-framework-core/tests/variable_accounts.rs b/spel-framework-core/tests/variable_accounts.rs new file mode 100644 index 00000000..600e28f9 --- /dev/null +++ b/spel-framework-core/tests/variable_accounts.rs @@ -0,0 +1,51 @@ +//! Test variable-length account lists (rest accounts). +//! Verifies the IDL serialization round-trip with the `rest` field. + +use spel_framework_core::idl::{IdlAccountItem, IdlPda}; + +#[test] +fn test_rest_account_serializes() { + let acc = IdlAccountItem { + name: "members".to_string(), + writable: false, + signer: false, + init: false, + owner: None, + pda: None, + rest: true, + visibility: vec!["public".to_string()], + }; + let json = serde_json::to_string(&acc).unwrap(); + assert!(json.contains("\"rest\":true"), "JSON: {}", json); +} + +#[test] +fn test_non_rest_account_omits_rest() { + let acc = IdlAccountItem { + name: "state".to_string(), + writable: true, + signer: false, + init: false, + owner: None, + pda: None, + rest: false, + visibility: vec![], + }; + let json = serde_json::to_string(&acc).unwrap(); + assert!(!json.contains("rest"), "rest=false should be omitted, JSON: {}", json); +} + +#[test] +fn test_rest_account_deserializes() { + let json = r#"{"name":"members","writable":false,"signer":false,"init":false,"rest":true}"#; + let acc: IdlAccountItem = serde_json::from_str(json).unwrap(); + assert!(acc.rest); + assert_eq!(acc.name, "members"); +} + +#[test] +fn test_missing_rest_defaults_false() { + let json = r#"{"name":"state","writable":true,"signer":false,"init":false}"#; + let acc: IdlAccountItem = serde_json::from_str(json).unwrap(); + assert!(!acc.rest); +} diff --git a/nssa-framework-macros/Cargo.toml b/spel-framework-macros/Cargo.toml similarity index 57% rename from nssa-framework-macros/Cargo.toml rename to spel-framework-macros/Cargo.toml index 4df5aa3b..35f45f39 100644 --- a/nssa-framework-macros/Cargo.toml +++ b/spel-framework-macros/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "nssa-framework-macros" -version = "0.1.0" +name = "spel-framework-macros" +version = "0.2.0" edition = "2021" -description = "Proc macros for the NSSA/LEZ program framework" +description = "Proc macros for the SPEL program framework" [lib] proc-macro = true @@ -11,3 +11,4 @@ proc-macro = true proc-macro2 = "1.0" quote = "1.0" syn = { version = "2.0", features = ["full", "extra-traits"] } +sha2 = "0.10" diff --git a/nssa-framework-macros/src/lib.rs b/spel-framework-macros/src/lib.rs similarity index 63% rename from nssa-framework-macros/src/lib.rs rename to spel-framework-macros/src/lib.rs index ebd959de..e304205b 100644 --- a/nssa-framework-macros/src/lib.rs +++ b/spel-framework-macros/src/lib.rs @@ -1,22 +1,22 @@ -//! # NSSA Framework Proc Macros +//! # SPEL Framework Proc Macros //! -//! This crate provides the `#[nssa_program]` attribute macro that eliminates -//! boilerplate in NSSA/LEZ guest binaries, and the `generate_idl!` macro +//! This crate provides the `#[lez_program]` attribute macro that eliminates +//! boilerplate in SPEL guest binaries, and the `generate_idl!` macro //! for extracting IDL from program source files. //! //! ## Usage //! //! ```rust,ignore -//! use nssa_framework::prelude::*; +//! use spel_framework::prelude::*; //! -//! #[nssa_program] +//! #[lez_program] //! mod my_program { //! #[instruction] //! pub fn create( //! #[account(init, pda = const("my_state"))] //! state: AccountWithMetadata, //! name: String, -//! ) -> NssaResult { +//! ) -> SpelResult { //! // business logic only //! } //! } @@ -26,17 +26,19 @@ //! //! ```rust,ignore //! // generate_idl.rs — one-liner! -//! nssa_framework::generate_idl!("src/bin/treasury.rs"); +//! spel_framework::generate_idl!("src/bin/treasury.rs"); //! ``` use proc_macro::TokenStream; +use sha2::{Sha256, Digest}; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; use syn::{ + parse::Parser, parse_macro_input, Attribute, FnArg, Ident, ItemFn, ItemMod, Pat, PatType, Type, }; -/// Main entry point: `#[nssa_program]` on a module. +/// Main entry point: `#[lez_program]` on a module. /// /// This macro: /// 1. Finds all `#[instruction]` functions in the module @@ -44,17 +46,57 @@ use syn::{ /// 3. Generates the `fn main()` with read/dispatch/write boilerplate /// 4. Generates account validation code per instruction /// 5. Generates `PROGRAM_IDL_JSON` const with complete IDL (including PDA seeds) +/// Program-level configuration parsed from `#[lez_program(...)]` attributes. +struct ProgramConfig { + /// External instruction enum path, e.g. `my_crate::Instruction`. + /// If set, the macro will NOT generate its own `Instruction` enum. + external_instruction: Option, +} + +impl ProgramConfig { + fn parse(attr: TokenStream) -> syn::Result { + let mut config = ProgramConfig { + external_instruction: None, + }; + if attr.is_empty() { + return Ok(config); + } + let parser = syn::punctuated::Punctuated::::parse_terminated; + let metas = parser.parse(attr)?; + for meta in metas { + if let syn::Meta::NameValue(nv) = &meta { + if nv.path.is_ident("instruction") { + if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(s), .. }) = &nv.value { + config.external_instruction = Some(s.parse()?); + } else { + return Err(syn::Error::new_spanned(&nv.value, "expected string literal")); + } + } else { + return Err(syn::Error::new_spanned(&nv.path, "unknown attribute")); + } + } else { + return Err(syn::Error::new_spanned(&meta, "expected name = value")); + } + } + Ok(config) + } +} + #[proc_macro_attribute] -pub fn nssa_program(_attr: TokenStream, item: TokenStream) -> TokenStream { +pub fn lez_program(attr: TokenStream, item: TokenStream) -> TokenStream { + let config = match ProgramConfig::parse(attr) { + Ok(c) => c, + Err(err) => return err.to_compile_error().into(), + }; let input = parse_macro_input!(item as ItemMod); - match expand_nssa_program(input) { + match expand_lez_program(input, config) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } } -/// Marker attribute for instruction functions within an `#[nssa_program]` module. -/// Processed by `#[nssa_program]`, not standalone. +/// Marker attribute for instruction functions within an `#[lez_program]` module. +/// Processed by `#[lez_program]`, not standalone. #[proc_macro_attribute] pub fn instruction(_attr: TokenStream, item: TokenStream) -> TokenStream { item @@ -62,11 +104,11 @@ pub fn instruction(_attr: TokenStream, item: TokenStream) -> TokenStream { /// Generate IDL from a program source file. /// -/// Parses the given Rust source file, finds the `#[nssa_program]` module, +/// Parses the given Rust source file, finds the `#[lez_program]` module, /// and generates a `fn main()` that prints the complete IDL as JSON. /// /// ```rust,ignore -/// nssa_framework_macros::generate_idl!("../../methods/guest/src/bin/treasury.rs"); +/// spel_framework_macros::generate_idl!("../../methods/guest/src/bin/treasury.rs"); /// ``` #[proc_macro] pub fn generate_idl(input: TokenStream) -> TokenStream { @@ -95,6 +137,8 @@ struct InstructionInfo { struct AccountParam { name: Ident, constraints: AccountConstraints, + /// True if this is a Vec (variable-length trailing accounts) + is_rest: bool, } #[derive(Default)] @@ -122,13 +166,13 @@ struct ArgParam { ty: Type, } -fn expand_nssa_program(input: ItemMod) -> syn::Result { +fn expand_lez_program(input: ItemMod, config: ProgramConfig) -> syn::Result { let mod_name = &input.ident; let (_, items) = input .content .as_ref() - .ok_or_else(|| syn::Error::new_spanned(&input, "nssa_program module must have a body"))?; + .ok_or_else(|| syn::Error::new_spanned(&input, "lez_program module must have a body"))?; // Collect instruction functions and other items let mut instructions: Vec = Vec::new(); @@ -152,16 +196,24 @@ fn expand_nssa_program(input: ItemMod) -> syn::Result { if instructions.is_empty() { return Err(syn::Error::new_spanned( &input.ident, - "nssa_program must contain at least one #[instruction] function", + "lez_program must contain at least one #[instruction] function", )); } - // Generate the Instruction enum - let enum_variants = generate_enum_variants(&instructions); - let enum_def = quote! { - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] - pub enum Instruction { - #(#enum_variants),* + // Generate the Instruction enum (or use external one) + let enum_def = if config.external_instruction.is_none() { + let enum_variants = generate_enum_variants(&instructions); + quote! { + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + pub enum Instruction { + #(#enum_variants),* + } + } + } else { + // External instruction: import it as `Instruction` if it's not already named that + let path = config.external_instruction.as_ref().unwrap(); + quote! { + use #path as Instruction; } }; @@ -185,7 +237,7 @@ fn expand_nssa_program(input: ItemMod) -> syn::Result { // Dispatch to instruction handler let result: Result< (Vec, Vec), - nssa_framework_core::error::NssaError + spel_framework::error::SpelError > = match instruction { #(#match_arms)* }; @@ -199,18 +251,23 @@ fn expand_nssa_program(input: ItemMod) -> syn::Result { }; // Write outputs to zkVM host - nssa_core::program::write_nssa_outputs_with_chained_call( + nssa_core::program::ProgramOutput::new( instruction_words, pre_states_clone, post_states, - chained_calls, - ); + ) + .with_chained_calls(chained_calls) + .write(); } }; // Generate IDL function and const JSON - let idl_fn = generate_idl_fn(mod_name, &instructions); - let idl_json = generate_idl_json(mod_name, &instructions); + let ext_instr_str: Option = config.external_instruction.as_ref().map(|p| { + let segments: Vec = p.segments.iter().map(|s| s.ident.to_string()).collect(); + segments.join("::") + }); + let idl_fn = generate_idl_fn(mod_name, &instructions, ext_instr_str.as_deref()); + let idl_json = generate_idl_json(mod_name, &instructions, ext_instr_str.as_deref()); // Assemble everything let expanded = quote! { @@ -234,7 +291,8 @@ fn expand_nssa_program(input: ItemMod) -> syn::Result { // IDL generation (available at host-side for tooling) #idl_fn - // The guest binary entry point + // The guest binary entry point (cfg-gated so cargo test works on host) + #[cfg(not(test))] #main_fn }; @@ -261,6 +319,14 @@ fn parse_instruction(func: ItemFn) -> syn::Result { accounts.push(AccountParam { name: param_name, constraints, + is_rest: false, + }); + } else if is_vec_account_type(ty) { + let constraints = parse_account_constraints(&pat_type.attrs)?; + accounts.push(AccountParam { + name: param_name, + constraints, + is_rest: true, }); } else { args.push(ArgParam { @@ -305,6 +371,22 @@ fn is_account_type(ty: &Type) -> bool { false } +/// Check if a type is Vec (variable-length account list). +fn is_vec_account_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + if segment.ident == "Vec" { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + return is_account_type(inner); + } + } + } + } + } + false +} + fn parse_account_constraints(attrs: &[Attribute]) -> syn::Result { let mut constraints = AccountConstraints::default(); @@ -461,15 +543,45 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve quote! { Instruction::#variant_name { #(#field_names),* } } }; - let account_names: Vec<&Ident> = ix.accounts.iter().map(|a| &a.name).collect(); - let account_destructure = quote! { - let [#(#account_names),*] = <[_; #num_accounts]>::try_from(pre_states) - .unwrap_or_else(|v: Vec<_>| panic!( - "Account count mismatch: expected {}, got {}", - #num_accounts, v.len() - )); + let has_rest = ix.accounts.iter().any(|a| a.is_rest); + let account_destructure = if has_rest { + // Split into fixed accounts + rest + let fixed_accounts: Vec<&AccountParam> = ix.accounts.iter().filter(|a| !a.is_rest).collect(); + let rest_account = ix.accounts.iter().find(|a| a.is_rest).unwrap(); + let num_fixed = fixed_accounts.len(); + let fixed_names: Vec<&Ident> = fixed_accounts.iter().map(|a| &a.name).collect(); + let rest_name = &rest_account.name; + + quote! { + if pre_states.len() < #num_fixed { + panic!( + "Account count mismatch: expected at least {}, got {}", + #num_fixed, pre_states.len() + ); + } + let (fixed_accounts, rest_accounts) = pre_states.split_at(#num_fixed); + let [#(#fixed_names),*] = <[_; #num_fixed]>::try_from(fixed_accounts.to_vec()) + .unwrap_or_else(|v: Vec<_>| panic!( + "Account count mismatch: expected {}, got {}", + #num_fixed, v.len() + )); + let #rest_name: Vec = rest_accounts.to_vec(); + } + } else { + let account_names: Vec<&Ident> = ix.accounts.iter().map(|a| &a.name).collect(); + quote! { + let [#(#account_names),*] = <[_; #num_accounts]>::try_from(pre_states) + .unwrap_or_else(|v: Vec<_>| panic!( + "Account count mismatch: expected {}, got {}", + #num_accounts, v.len() + )); + } }; + // Check if this instruction has any validation (signer/init checks) + let has_validation = ix.accounts.iter().any(|a| a.constraints.signer || a.constraints.init); + let validate_fn_name = format_ident!("__validate_{}", ix.fn_name); + let call_args: Vec = ix .accounts .iter() @@ -483,9 +595,40 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve })) .collect(); + let validation_call = if has_validation { + if has_rest { + // For instructions with Vec accounts, build the slice dynamically + let fixed_refs: Vec = ix.accounts.iter() + .filter(|a| !a.is_rest) + .map(|a| { let name = &a.name; quote! { #name.clone() } }) + .collect(); + let rest_ref = &ix.accounts.iter().find(|a| a.is_rest).unwrap().name; + quote! { + let mut __all_accounts = vec![#(#fixed_refs),*]; + __all_accounts.extend(#rest_ref.clone()); + #mod_name::#validate_fn_name(&__all_accounts).expect("account validation failed"); + } + } else { + let account_refs: Vec = ix + .accounts + .iter() + .map(|a| { + let name = &a.name; + quote! { #name } + }) + .collect(); + quote! { + #mod_name::#validate_fn_name(&[#(#account_refs.clone()),*]).expect("account validation failed"); + } + } + } else { + quote! {} + }; + quote! { #pattern => { #account_destructure + #validation_call #mod_name::#fn_name(#(#call_args),*) .map(|output| (output.post_states, output.chained_calls)) } @@ -510,8 +653,64 @@ fn generate_handler_fns(instructions: &[InstructionInfo]) -> Vec { .collect() } -fn generate_validation(_instructions: &[InstructionInfo]) -> Vec { - vec![] +fn generate_validation(instructions: &[InstructionInfo]) -> Vec { + instructions + .iter() + .map(|ix| { + let fn_name = format_ident!("__validate_{}", ix.fn_name); + + // Generate signer checks for accounts with #[account(signer)] + let signer_checks: Vec = ix + .accounts + .iter() + .enumerate() + .filter(|(_, acc)| acc.constraints.signer) + .map(|(i, acc)| { + let acc_name = acc.name.to_string(); + let idx = i; + quote! { + if !accounts[#idx].is_authorized { + return Err(spel_framework::error::SpelError::Unauthorized { + message: format!("Account '{}' (index {}) must be a signer", #acc_name, #idx), + }); + } + } + }) + .collect(); + + // Generate init checks for accounts with #[account(init)] + let init_checks: Vec = ix + .accounts + .iter() + .enumerate() + .filter(|(_, acc)| acc.constraints.init) + .map(|(i, acc)| { + let acc_name = acc.name.to_string(); + let idx = i; + quote! { + if accounts[#idx].account != nssa_core::account::Account::default() { + return Err(spel_framework::error::SpelError::AccountAlreadyInitialized { + account_index: #idx, + }); + } + } + }) + .collect(); + + if signer_checks.is_empty() && init_checks.is_empty() { + return quote! {}; + } + + quote! { + #[allow(dead_code)] + pub fn #fn_name(accounts: &[nssa_core::account::AccountWithMetadata]) -> Result<(), spel_framework::error::SpelError> { + #(#signer_checks)* + #(#init_checks)* + Ok(()) + } + } + }) + .collect() } fn to_pascal_case(ident: &Ident) -> Ident { @@ -551,6 +750,7 @@ fn rust_type_to_idl_string(ty: &Type) -> String { } } "ProgramId" => "program_id".to_string(), + "AccountId" => "account_id".to_string(), other => other.to_string(), } } @@ -591,6 +791,7 @@ fn rust_type_to_idl_json(ty: &Type) -> String { } } "ProgramId" => "\"program_id\"".to_string(), + "AccountId" => "\"account_id\"".to_string(), other => format!("{{\"defined\":\"{}\"}}", other), } } @@ -609,7 +810,15 @@ fn rust_type_to_idl_json(ty: &Type) -> String { // ─── IDL generation (code-based, for __program_idl()) ──────────────────── -fn generate_idl_fn(mod_name: &Ident, instructions: &[InstructionInfo]) -> TokenStream2 { +/// Compute SHA256("global:{name}")[..8] discriminator at macro expansion time. +fn compute_discriminator(name: &str) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(format!("global:{}", name).as_bytes()); + let result = hasher.finalize(); + result[..8].to_vec() +} + +fn generate_idl_fn(mod_name: &Ident, instructions: &[InstructionInfo], external_instruction: Option<&str>) -> TokenStream2 { let program_name = mod_name.to_string(); let instruction_literals: Vec = instructions @@ -635,32 +844,35 @@ fn generate_idl_fn(mod_name: &Ident, instructions: &[InstructionInfo]) -> TokenS .iter() .map(|seed| match seed { PdaSeedDef::Const(val) => quote! { - nssa_framework_core::idl::IdlSeed::Const { value: #val.to_string() } + spel_framework::idl::IdlSeed::Const { value: #val.to_string() } }, PdaSeedDef::Account(name) => quote! { - nssa_framework_core::idl::IdlSeed::Account { path: #name.to_string() } + spel_framework::idl::IdlSeed::Account { path: #name.to_string() } }, PdaSeedDef::Arg(name) => quote! { - nssa_framework_core::idl::IdlSeed::Arg { path: #name.to_string() } + spel_framework::idl::IdlSeed::Arg { path: #name.to_string() } }, }) .collect(); quote! { - Some(nssa_framework_core::idl::IdlPda { + Some(spel_framework::idl::IdlPda { seeds: vec![#(#seed_literals),*], }) } }; + let is_rest = acc.is_rest; quote! { - nssa_framework_core::idl::IdlAccountItem { + spel_framework::idl::IdlAccountItem { name: #acc_name.to_string(), writable: #writable, signer: #signer, init: #init, owner: None, pda: #pda_expr, + rest: #is_rest, + visibility: vec!["public".to_string()], } } }) @@ -673,34 +885,70 @@ fn generate_idl_fn(mod_name: &Ident, instructions: &[InstructionInfo]) -> TokenS let arg_name = arg.name.to_string().trim_start_matches('_').to_string(); let type_str = rust_type_to_idl_string(&arg.ty); quote! { - nssa_framework_core::idl::IdlArg { + spel_framework::idl::IdlArg { name: #arg_name.to_string(), - type_: nssa_framework_core::idl::IdlType::Primitive(#type_str.to_string()), + type_: spel_framework::idl::IdlType::Primitive(#type_str.to_string()), } } }) .collect(); + let discriminator_bytes = compute_discriminator(&ix_name); + let disc_bytes_lit: Vec = discriminator_bytes.iter() + .map(|b| { let val = proc_macro2::Literal::u8_unsuffixed(*b); quote! { #val } }) + .collect(); + let variant_name_str = { + let s = &ix_name; + s.split('_') + .map(|w| { + let mut c = w.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }) + .collect::() + }; + quote! { - nssa_framework_core::idl::IdlInstruction { + spel_framework::idl::IdlInstruction { name: #ix_name.to_string(), accounts: vec![#(#account_literals),*], args: vec![#(#arg_literals),*], + discriminator: Some(vec![#(#disc_bytes_lit),*]), + execution: Some(spel_framework::idl::IdlExecution { + public: true, + private_owned: false, + }), + variant: Some(#variant_name_str.to_string()), } } }) .collect(); + // Compute instruction_type at proc-macro expansion time + let instruction_type_expr = if let Some(ext) = external_instruction { + quote! { Some(#ext.to_string()) } + } else { + quote! { None } + }; + quote! { #[allow(dead_code)] - pub fn __program_idl() -> nssa_framework_core::idl::NssaIdl { - nssa_framework_core::idl::NssaIdl { + pub fn __program_idl() -> spel_framework::idl::SpelIdl { + spel_framework::idl::SpelIdl { version: "0.1.0".to_string(), name: #program_name.to_string(), instructions: vec![#(#instruction_literals),*], accounts: vec![], types: vec![], errors: vec![], + spec: Some("0.1.0".to_string()), + instruction_type: #instruction_type_expr, + metadata: Some(spel_framework::idl::IdlMetadata { + name: #program_name.to_string(), + version: "0.1.0".to_string(), + }), } } } @@ -708,7 +956,7 @@ fn generate_idl_fn(mod_name: &Ident, instructions: &[InstructionInfo]) -> TokenS // ─── IDL generation (JSON string, for PROGRAM_IDL_JSON const) ──────────── -fn generate_idl_json(mod_name: &Ident, instructions: &[InstructionInfo]) -> String { +fn generate_idl_json(mod_name: &Ident, instructions: &[InstructionInfo], external_instruction: Option<&str>) -> String { let program_name = mod_name.to_string(); let instructions_json: Vec = instructions @@ -747,9 +995,10 @@ fn generate_idl_json(mod_name: &Ident, instructions: &[InstructionInfo]) -> Stri format!(",\"pda\":{{\"seeds\":[{}]}}", seeds.join(",")) }; + let rest_json = if acc.is_rest { ",\"rest\":true".to_string() } else { String::new() }; format!( - "{{\"name\":\"{}\",\"writable\":{},\"signer\":{},\"init\":{}{}}}", - name, writable, signer, init, pda_json + "{{\"name\":\"{}\",\"writable\":{},\"signer\":{},\"init\":{}{}{}}}", + name, writable, signer, init, pda_json, rest_json ) }) .collect(); @@ -773,10 +1022,16 @@ fn generate_idl_json(mod_name: &Ident, instructions: &[InstructionInfo]) -> Stri }) .collect(); + let instruction_type_suffix = if let Some(ext) = external_instruction { + format!(",\"instruction_type\":\"{}\"", ext) + } else { + String::new() + }; format!( - "{{\"version\":\"0.1.0\",\"name\":\"{}\",\"instructions\":[{}],\"accounts\":[],\"types\":[],\"errors\":[]}}", + "{{\"version\":\"0.1.0\",\"name\":\"{}\",\"instructions\":[{}],\"accounts\":[],\"types\":[],\"errors\":[]{}}}" , program_name, - instructions_json.join(",") + instructions_json.join(","), + instruction_type_suffix ) } @@ -809,11 +1064,11 @@ fn expand_generate_idl(file_path: &str, span_token: &syn::LitStr) -> syn::Result ) })?; - // Find the #[nssa_program] module + // Find the #[lez_program] module let mut program_mod: Option<&ItemMod> = None; for item in &file.items { if let syn::Item::Mod(m) = item { - if m.attrs.iter().any(|a| a.path().is_ident("nssa_program")) { + if m.attrs.iter().any(|a| a.path().is_ident("lez_program")) { program_mod = Some(m); break; } @@ -824,7 +1079,7 @@ fn expand_generate_idl(file_path: &str, span_token: &syn::LitStr) -> syn::Result syn::Error::new_spanned( span_token, format!( - "No #[nssa_program] module found in '{}'", + "No #[lez_program] module found in '{}'", file_path ), ) @@ -833,7 +1088,7 @@ fn expand_generate_idl(file_path: &str, span_token: &syn::LitStr) -> syn::Result let mod_name = &program_mod.ident; let (_, items) = program_mod.content.as_ref().ok_or_else(|| { - syn::Error::new_spanned(span_token, "nssa_program module has no body") + syn::Error::new_spanned(span_token, "lez_program module has no body") })?; // Parse instructions @@ -853,8 +1108,27 @@ fn expand_generate_idl(file_path: &str, span_token: &syn::LitStr) -> syn::Result )); } + // Detect external instruction type from the #[lez_program(...)] attr + let external_instruction_str: Option = program_mod.attrs.iter() + .find(|a| a.path().is_ident("lez_program")) + .and_then(|attr| { + // Try to parse as lez_program(instruction = "some::Path") + let mut ext: Option = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("instruction") { + if let Ok(value) = meta.value() { + if let Ok(lit) = value.parse::() { + ext = Some(lit.value()); + } + } + } + Ok(()) + }); + ext + }); + // Generate the IDL JSON - let idl_json = generate_idl_json(mod_name, &instructions); + let idl_json = generate_idl_json(mod_name, &instructions, external_instruction_str.as_deref()); // Embed the resolved path for cargo tracking let resolved = resolved_path.clone(); diff --git a/spel-framework/Cargo.toml b/spel-framework/Cargo.toml new file mode 100644 index 00000000..2991fa66 --- /dev/null +++ b/spel-framework/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "spel-framework" +version = "0.2.0" +edition = "2021" +description = "Developer framework for building SPEL programs (like Anchor for Solana)" + +[dependencies] +spel-framework-core = { path = "../spel-framework-core" } +spel-framework-macros = { path = "../spel-framework-macros" } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b", features = ["host"] } +borsh = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +spel-client-gen = { path = "../spel-client-gen" } +serde_json = "1" diff --git a/spel-framework/src/lib.rs b/spel-framework/src/lib.rs new file mode 100644 index 00000000..2b0f7c02 --- /dev/null +++ b/spel-framework/src/lib.rs @@ -0,0 +1,19 @@ +//! # SPEL Framework +//! +//! Developer framework for building programs on SPEL, +//! similar to Anchor for Solana. + +// Re-export the proc macros +pub use spel_framework_macros::{lez_program, instruction, generate_idl}; + +// Re-export core types +pub use spel_framework_core::*; + +pub mod prelude { + pub use crate::lez_program; + pub use crate::instruction; + pub use spel_framework_core::prelude::*; + pub use spel_framework_core::types::SpelOutput; + pub use spel_framework_core::error::{SpelError, SpelResult}; + pub use borsh::{BorshSerialize, BorshDeserialize}; +} diff --git a/spel-framework/tests/e2e.rs b/spel-framework/tests/e2e.rs new file mode 100644 index 00000000..f4da4da4 --- /dev/null +++ b/spel-framework/tests/e2e.rs @@ -0,0 +1,220 @@ +//! End-to-end tests for the spel-framework pipeline: +//! scaffold → build → IDL generation → FFI build → test +//! +//! These tests exercise a real #[lez_program] fixture program located at +//! tests/e2e/fixture_program/ by shelling out to cargo commands and +//! validating the generated IDL and client/FFI code. + +use std::path::PathBuf; +use std::process::Command; + +fn fixture_manifest() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../tests/e2e/fixture_program/Cargo.toml") +} + +// --------------------------------------------------------------------------- +// Step 1 + 3: Build — cargo build the fixture program targeting host +// --------------------------------------------------------------------------- + +#[test] +fn e2e_build() { + let output = Command::new("cargo") + .args(["build", "--manifest-path"]) + .arg(fixture_manifest()) + .output() + .expect("Failed to run cargo build"); + + assert!( + output.status.success(), + "cargo build failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +// --------------------------------------------------------------------------- +// Step 2: IDL generation — extract IDL from the fixture and validate +// --------------------------------------------------------------------------- + +#[test] +fn e2e_idl_generation() { + let output = Command::new("cargo") + .args(["run", "--manifest-path"]) + .arg(fixture_manifest()) + .output() + .expect("Failed to run fixture binary"); + + assert!( + output.status.success(), + "cargo run failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let idl_json = String::from_utf8(output.stdout).unwrap(); + let idl_json = idl_json.trim(); + let idl: spel_framework::idl::SpelIdl = + serde_json::from_str(idl_json).expect("IDL JSON should be valid"); + + // Top-level fields + assert_eq!(idl.version, "0.1.0"); + assert_eq!(idl.name, "treasury"); + assert_eq!(idl.instructions.len(), 4); + + // initialize instruction + let init = &idl.instructions[0]; + assert_eq!(init.name, "initialize"); + assert_eq!(init.accounts.len(), 2); + assert!(init.accounts[0].init, "state should be init"); + assert!(init.accounts[0].writable, "init implies writable"); + assert!(init.accounts[0].pda.is_some(), "state should have PDA"); + let pda = init.accounts[0].pda.as_ref().unwrap(); + assert_eq!(pda.seeds.len(), 1); + assert!(init.accounts[1].signer, "authority should be signer"); + assert_eq!(init.args.len(), 1); + assert_eq!(init.args[0].name, "threshold"); + + // create_vault instruction + let vault = &idl.instructions[1]; + assert_eq!(vault.name, "create_vault"); + assert_eq!(vault.accounts.len(), 2); + assert!(vault.accounts[0].init, "vault should be init"); + assert!(vault.accounts[0].pda.is_some(), "vault should have PDA"); + assert!(vault.accounts[1].signer, "owner should be signer"); + assert_eq!(vault.args.len(), 1); + assert_eq!(vault.args[0].name, "owner_key"); + + // create_config instruction + let config = &idl.instructions[2]; + assert_eq!(config.name, "create_config"); + assert_eq!(config.accounts.len(), 2); + assert!(config.accounts[0].init, "config should be init"); + assert!(config.accounts[0].pda.is_some(), "config should have PDA"); + let config_pda = config.accounts[0].pda.as_ref().unwrap(); + assert_eq!(config_pda.seeds.len(), 2); + assert!(config.accounts[1].signer, "admin should be signer"); + assert_eq!(config.args.len(), 1); + assert_eq!(config.args[0].name, "user_id"); + + // transfer instruction + let transfer = &idl.instructions[3]; + assert_eq!(transfer.name, "transfer"); + assert_eq!(transfer.accounts.len(), 3); + assert!(transfer.accounts[0].writable, "from should be writable"); + assert!(transfer.accounts[1].writable, "to should be writable"); + assert!(transfer.accounts[2].signer, "signer should be signer"); + assert_eq!(transfer.args.len(), 2); + assert_eq!(transfer.args[0].name, "amount"); + assert_eq!(transfer.args[1].name, "memo"); +} + +// --------------------------------------------------------------------------- +// Step 4: FFI build — generate client/FFI code from IDL and validate +// --------------------------------------------------------------------------- + +#[test] +fn e2e_ffi_build() { + // Extract IDL from fixture + let output = Command::new("cargo") + .args(["run", "--manifest-path"]) + .arg(fixture_manifest()) + .output() + .expect("Failed to run fixture binary"); + + assert!(output.status.success()); + let idl_json = String::from_utf8(output.stdout).unwrap(); + + // Generate client + FFI code + let codegen = spel_client_gen::generate_from_idl_json(idl_json.trim()) + .expect("Client codegen should succeed"); + + // Client code assertions + assert!(!codegen.client_code.is_empty()); + assert!( + codegen.client_code.contains("TreasuryInstruction"), + "client should contain TreasuryInstruction enum" + ); + assert!( + codegen.client_code.contains("TreasuryClient"), + "client should contain TreasuryClient struct" + ); + assert!( + codegen.client_code.contains("fn initialize"), + "client should have initialize method" + ); + assert!( + codegen.client_code.contains("fn transfer"), + "client should have transfer method" + ); + assert!( + codegen.client_code.contains("InitializeAccounts"), + "client should have InitializeAccounts struct" + ); + assert!( + codegen.client_code.contains("TransferAccounts"), + "client should have TransferAccounts struct" + ); + + // FFI code assertions + assert!(!codegen.ffi_code.is_empty()); + assert!( + codegen.ffi_code.contains("treasury_initialize"), + "FFI should have treasury_initialize function" + ); + assert!( + codegen.ffi_code.contains("treasury_transfer"), + "FFI should have treasury_transfer function" + ); + assert!( + codegen.ffi_code.contains("extern \"C\""), + "FFI should have extern C functions" + ); + assert!( + codegen.ffi_code.contains("treasury_free_string"), + "FFI should have free_string function" + ); + + // Header assertions + assert!(!codegen.header.is_empty()); + assert!( + codegen.header.contains("treasury_initialize"), + "header should declare treasury_initialize" + ); + assert!( + codegen.header.contains("treasury_transfer"), + "header should declare treasury_transfer" + ); + assert!( + codegen.header.contains("TREASURY_FFI_H"), + "header should have include guard" + ); +} + +// --------------------------------------------------------------------------- +// Step 5: Test — cargo test the fixture (validates cfg-gate fix) +// --------------------------------------------------------------------------- + +#[test] +fn e2e_test() { + let output = Command::new("cargo") + .args(["test", "--manifest-path"]) + .arg(fixture_manifest()) + .output() + .expect("Failed to run cargo test"); + + assert!( + output.status.success(), + "cargo test on fixture failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}{}", stdout, stderr); + assert!( + combined.contains("test result: ok"), + "Expected all fixture tests to pass:\nstdout: {}\nstderr: {}", + stdout, + stderr + ); +} diff --git a/tests/e2e/fixture_program/Cargo.toml b/tests/e2e/fixture_program/Cargo.toml new file mode 100644 index 00000000..cb65bfc3 --- /dev/null +++ b/tests/e2e/fixture_program/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] + +[package] +name = "fixture-program" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +spel-framework = { path = "../../../spel-framework" } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", rev = "ffcbc15972adbf557939bf3e2852af276422631b", features = ["host"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/tests/e2e/fixture_program/src/lib.rs b/tests/e2e/fixture_program/src/lib.rs new file mode 100644 index 00000000..1d2142a0 --- /dev/null +++ b/tests/e2e/fixture_program/src/lib.rs @@ -0,0 +1,189 @@ +//! Fixture program for e2e tests. +//! +//! Uses #[lez_program] to exercise the full macro expansion, +//! IDL generation, and handler invocation on the host. + +#![allow(dead_code, unused_imports, unused_variables)] + +use spel_framework::prelude::*; + +#[lez_program] +mod treasury { + #[allow(unused_imports)] + use super::*; + + /// Initialize the treasury state. + #[instruction] + pub fn initialize( + #[account(init, pda = literal("treasury_state"))] + state: AccountWithMetadata, + #[account(signer)] + authority: AccountWithMetadata, + threshold: u64, + ) -> SpelResult { + Ok(SpelOutput::states_only(vec![])) + } + + + /// Create a user vault (PDA from arg seed). + #[instruction] + pub fn create_vault( + #[account(init, pda = arg("owner_key"))] + vault: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + owner_key: [u8; 32], + ) -> SpelResult { + Ok(SpelOutput::states_only(vec![])) + } + + /// Create a user config (PDA from literal + arg multi-seed). + #[instruction] + pub fn create_config( + #[account(init, pda = [literal("config"), arg("user_id")])] + config: AccountWithMetadata, + #[account(signer)] + admin: AccountWithMetadata, + user_id: [u8; 32], + ) -> SpelResult { + Ok(SpelOutput::states_only(vec![])) + } + /// Transfer funds. + #[instruction] + pub fn transfer( + #[account(mut)] + from: AccountWithMetadata, + #[account(mut)] + to: AccountWithMetadata, + #[account(signer)] + signer: AccountWithMetadata, + amount: u64, + memo: String, + ) -> SpelResult { + Ok(SpelOutput::states_only(vec![])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_account(authorized: bool) -> AccountWithMetadata { + AccountWithMetadata { + account_id: nssa_core::account::AccountId::new([0u8; 32]), + account: nssa_core::account::Account::default(), + is_authorized: authorized, + } + } + + #[test] + fn idl_has_expected_instructions() { + let idl = __program_idl(); + assert_eq!(idl.name, "treasury"); + assert_eq!(idl.version, "0.1.0"); + assert_eq!(idl.instructions.len(), 4); + assert_eq!(idl.instructions[0].name, "initialize"); + } + + #[test] + fn idl_json_round_trip() { + let idl: spel_framework::idl::SpelIdl = + serde_json::from_str(PROGRAM_IDL_JSON).expect("PROGRAM_IDL_JSON should parse"); + assert_eq!(idl.name, "treasury"); + assert_eq!(idl.instructions.len(), 4); + } + + #[test] + fn initialize_instruction_metadata() { + let idl = __program_idl(); + let ix = &idl.instructions[0]; + assert_eq!(ix.name, "initialize"); + assert_eq!(ix.accounts.len(), 2); + // First account: init + PDA + assert!(ix.accounts[0].init); + assert!(ix.accounts[0].writable); // init implies writable + assert!(ix.accounts[0].pda.is_some()); + // Second account: signer + assert!(ix.accounts[1].signer); + // Args + assert_eq!(ix.args.len(), 1); + assert_eq!(ix.args[0].name, "threshold"); + } + + #[test] + fn transfer_instruction_metadata() { + let idl = __program_idl(); + let ix = &idl.instructions[3]; + assert_eq!(ix.name, "transfer"); + assert_eq!(ix.accounts.len(), 3); + assert!(ix.accounts[0].writable); // from: mut + assert!(ix.accounts[1].writable); // to: mut + assert!(ix.accounts[2].signer); // signer + assert_eq!(ix.args.len(), 2); + assert_eq!(ix.args[0].name, "amount"); + assert_eq!(ix.args[1].name, "memo"); + } + + /// Validates the cfg-gate fix: handler functions are directly callable + /// from host-side tests without triggering zkVM syscalls. + #[test] + fn handler_initialize_callable() { + let acc = make_account(true); + let result = treasury::initialize(acc.clone(), acc.clone(), 5); + assert!(result.is_ok()); + } + + #[test] + fn handler_transfer_callable() { + let acc = make_account(true); + let result = treasury::transfer( + acc.clone(), + acc.clone(), + acc.clone(), + 100, + "test memo".to_string(), + ); + assert!(result.is_ok()); + } + + #[test] + fn create_vault_instruction_metadata() { + let idl = __program_idl(); + let ix = &idl.instructions[1]; // create_vault is second + assert_eq!(ix.name, "create_vault"); + assert_eq!(ix.accounts.len(), 2); + assert!(ix.accounts[0].init); + assert!(ix.accounts[0].pda.is_some()); + let pda = ix.accounts[0].pda.as_ref().unwrap(); + assert_eq!(pda.seeds.len(), 1); // arg seed + assert_eq!(ix.args.len(), 1); + assert_eq!(ix.args[0].name, "owner_key"); + } + + #[test] + fn create_config_instruction_metadata() { + let idl = __program_idl(); + let ix = &idl.instructions[2]; // create_config is third + assert_eq!(ix.name, "create_config"); + assert_eq!(ix.accounts.len(), 2); + assert!(ix.accounts[0].init); + assert!(ix.accounts[0].pda.is_some()); + let pda = ix.accounts[0].pda.as_ref().unwrap(); + assert_eq!(pda.seeds.len(), 2); // literal + arg + } + + #[test] + fn handler_create_vault_callable() { + let acc = make_account(true); + let result = treasury::create_vault(acc.clone(), acc.clone(), [42u8; 32]); + assert!(result.is_ok()); + } + + #[test] + fn handler_create_config_callable() { + let acc = make_account(true); + let result = treasury::create_config(acc.clone(), acc.clone(), [99u8; 32]); + assert!(result.is_ok()); + } + +} diff --git a/tests/e2e/fixture_program/src/main.rs b/tests/e2e/fixture_program/src/main.rs new file mode 100644 index 00000000..109abc84 --- /dev/null +++ b/tests/e2e/fixture_program/src/main.rs @@ -0,0 +1,5 @@ +/// Prints the program IDL JSON to stdout. +/// Used by the e2e test runner to extract the IDL for validation. +fn main() { + println!("{}", fixture_program::PROGRAM_IDL_JSON); +}