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..6e4a5c64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,15 +5,165 @@ on: push: branches: [main] +env: + CARGO_TERM_COLOR: always + jobs: - test: - name: Tests + sonarcloud: + name: SonarCloud Scan + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones are fine for SonarCloud + - uses: SonarSource/sonarcloud-github-action@master + with: + organization: logos-co + projectKey: logos-co_spel + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + unit-tests: + name: Unit 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: "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 + 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 revision from spel-framework/Cargo.toml + id: lez-ref + run: | + # Support both tag = "..." and rev = "..." formats + LEZ_REV=$(grep 'nssa_core' spel-framework/Cargo.toml | grep -oE '(tag|rev) = "[^"]*"' | cut -d'"' -f2) + if [ -z "$LEZ_REV" ]; then + echo "ERROR: Could not extract LEZ revision from spel-framework/Cargo.toml" + exit 1 + fi + echo "LEZ_REV=$LEZ_REV" >> $GITHUB_OUTPUT + echo "LEZ_TAG=$LEZ_REV" >> $GITHUB_OUTPUT + echo "Using LEZ revision: $LEZ_REV" + + - name: Determine SPEL ref for testing + id: spel-ref + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + # For PRs, use the PR head commit + SPEL_REF="refs/pull/${{ github.event.pull_request.number }}/head" + else + # For pushes to main, use the current commit + SPEL_REF="${{ github.sha }}" + fi + echo "SPEL_REF=$SPEL_REF" >> $GITHUB_OUTPUT + echo "Using SPEL ref: $SPEL_REF" + + - name: Install risc0 toolchain + run: | + curl -L https://risc0.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 revision + run: | + rm -rf /tmp/lssa + git clone https://github.com/logos-blockchain/logos-execution-zone.git /tmp/lssa + cd /tmp/lssa + # Fetch the specific tag/revision + git fetch --depth 1 origin ${{ steps.lez-ref.outputs.LEZ_REV }} + git checkout ${{ steps.lez-ref.outputs.LEZ_REV }} + + - name: Cache sequencer binary + id: cache-sequencer + uses: actions/cache/restore@v4 + with: + path: /tmp/lssa/target/release/sequencer_service + key: sequencer-${{ steps.lez-ref.outputs.LEZ_REV }} + restore-keys: | + sequencer- + fail-on-cache-miss: false + + - name: Build sequencer + if: steps.cache-sequencer.outputs.cache-hit != 'true' + run: | + cd /tmp/lssa + cargo build --release --features standalone -p sequencer_service + + - name: Save sequencer cache + if: always() + uses: actions/cache/save@v4 + with: + path: /tmp/lssa/target/release/sequencer_service + key: sequencer-${{ steps.lez-ref.outputs.LEZ_REV }} + + - 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 + LEZ_TAG: ${{ steps.lez-ref.outputs.LEZ_TAG }} + SPEL_TAG: ${{ steps.spel-ref.outputs.SPEL_REF }} + run: | + export PATH="/tmp/lssa/target/release:$PATH" + scripts/smoke-test-privacy.sh /tmp/privacy-smoke diff --git a/.github/workflows/lez-compat-improved.yml b/.github/workflows/lez-compat-improved.yml new file mode 100644 index 00000000..5703c4a3 --- /dev/null +++ b/.github/workflows/lez-compat-improved.yml @@ -0,0 +1,446 @@ +name: LEZ Compatibility Check + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + workflow_dispatch: + inputs: + lez_commit: + description: 'LEZ commit SHA to test (leave empty for latest main)' + required: false + default: '' + +env: + CARGO_TERM_COLOR: always + +jobs: + check-compatibility: + name: Check LEZ Compatibility + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + + outputs: + lez_commit: ${{ steps.lez.outputs.commit }} + lez_short: ${{ steps.lez.outputs.short }} + current_lez_commit: ${{ steps.current.outputs.commit }} + needs_update: ${{ steps.check-update.outputs.needs_update }} + update_needed: ${{ steps.check-update.outputs.update_needed }} + + steps: + - name: Checkout SPEL + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get current LEZ commit from Cargo.lock + id: current + run: | + # Extract current LEZ commit from Cargo.lock + CURRENT=$(grep -A 5 'name = "nssa_core"' Cargo.lock | grep 'source = "git' | head -1 | grep -oE '[a-f0-9]{40}' || echo "") + if [ -z "$CURRENT" ]; then + # Try alternative: check Cargo.toml files + CURRENT=$(grep -rh 'logos-execution-zone' */Cargo.toml | grep 'rev' | head -1 | grep -oE '[a-f0-9]{40}' || echo "") + fi + echo "commit=$CURRENT" >> "$GITHUB_OUTPUT" + echo "Current LEZ commit: $CURRENT" + + - name: Get target LEZ commit + id: lez + run: | + if [ -n "${{ github.event.inputs.lez_commit }}" ]; then + COMMIT="${{ github.event.inputs.lez_commit }}" + echo "Using manually specified commit: $COMMIT" + else + COMMIT=$(git ls-remote https://github.com/logos-blockchain/logos-execution-zone.git main | cut -f1) + echo "Fetching latest from LEZ main: $COMMIT" + fi + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + echo "short=${COMMIT:0:8}" >> "$GITHUB_OUTPUT" + echo "Target LEZ commit: $COMMIT" + + - name: Check if update needed + id: check-update + run: | + CURRENT="${{ steps.current.outputs.commit }}" + TARGET="${{ steps.lez.outputs.commit }}" + + if [ "$CURRENT" = "$TARGET" ]; then + echo "needs_update=false" >> "$GITHUB_OUTPUT" + echo "update_needed=false" >> "$GITHUB_OUTPUT" + echo "✓ SPEL is already on latest LEZ ($TARGET)" + else + echo "needs_update=true" >> "$GITHUB_OUTPUT" + echo "update_needed=true" >> "$GITHUB_OUTPUT" + echo "⚠ SPEL is on $CURRENT, target is $TARGET" + echo "current_short=${CURRENT:0:8}" >> "$GITHUB_OUTPUT" + echo "target_short=${TARGET:0:8}" >> "$GITHUB_OUTPUT" + fi + + - name: Configure git + if: steps.check-update.outputs.needs_update == 'true' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create update branch + if: steps.check-update.outputs.needs_update == 'true' + id: branch + run: | + LEZ_SHORT="${{ steps.lez.outputs.short }}" + BRANCH="lez-bump/${LEZ_SHORT}" + + # Fetch and checkout main + git fetch origin main + git checkout main + + # Create/update branch + if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git checkout "$BRANCH" + git reset --hard origin/main + else + git checkout -b "$BRANCH" + fi + + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Update LEZ dependencies + if: steps.check-update.outputs.needs_update == 'true' + id: update-deps + run: | + LEZ="${{ steps.lez.outputs.commit }}" + LEZ_SHORT="${{ steps.lez.outputs.short }}" + + echo "Updating LEZ dependencies to $LEZ_SHORT..." + + # Update all Cargo.toml files that reference LEZ + UPDATED_FILES=() + for f in $(find . -name "Cargo.toml" -type f); do + if grep -q "logos-execution-zone" "$f" 2>/dev/null; then + echo " Updating: $f" + sed -i "s|rev = \"[a-f0-9]\{40\}\"|rev = \"$LEZ\"|g" "$f" + UPDATED_FILES+=("$f") + fi + done + + echo "Updated ${#UPDATED_FILES[@]} files:" + printf ' - %s\n' "${UPDATED_FILES[@]}" + + # Commit changes + git add . + if git diff --cached --quiet; then + echo "No changes to commit" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + git commit -m "chore(lez): bump to $LEZ_SHORT" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Push update branch + if: steps.update-deps.outputs.changed == 'true' + run: | + BRANCH="${{ steps.branch.outputs.branch }}" + git push -u origin "$BRANCH" + + - name: Generate compatibility report + if: steps.check-update.outputs.needs_update == 'true' + id: report + run: | + REPORT_FILE="LEZ_COMPAT_REPORT_${{ steps.lez.outputs.short }}.md" + + cat > "$REPORT_FILE" << EOF + # LEZ Compatibility Report + + **SPEL Branch:** ${{ github.ref_name }} + **Current LEZ:** \`${{ steps.current.outputs.short }}\` + **Target LEZ:** \`${{ steps.lez.outputs.short }}\` + **LEZ Commit:** \`${{ steps.lez.outputs.commit }}\` + **Generated:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") + + --- + + ## Update Summary + + - Files to update: See Cargo.toml files modified + - Branch created: ${{ steps.branch.outputs.branch }} + - PR Status: Pending + + ## Next Steps + + 1. Review the automated PR for this LEZ bump + 2. Run full test suite: \`cargo test --all\` + 3. Check for API breaking changes in LEZ + 4. Update SPEL code if needed to match new LEZ API + + ## Manual Verification Required + + - [ ] Build SPEL against new LEZ + - [ ] Run unit tests + - [ ] Run integration tests + - [ ] Check for deprecated APIs + - [ ] Verify FFI bindings still work + + --- + + *This report was generated automatically by the LEZ Compatibility Check workflow.* + EOF + + echo "report_file=$REPORT_FILE" >> "$GITHUB_OUTPUT" + cat "$REPORT_FILE" + + - name: Create PR + if: steps.update-deps.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v5 + with: + branch: ${{ steps.branch.outputs.branch }} + title: "chore(lez): bump to ${{ steps.lez.outputs.short }}" + body: | + ## LEZ Dependency Update + + This PR updates SPEL to use LEZ `${{ steps.lez.outputs.short }}`. + + ### Changes + - Updated `logos-execution-zone` dependency from `${{ steps.current.outputs.short }}` to `${{ steps.lez.outputs.short }}` + + ### Compatibility Check + ⚠️ **Manual verification required** - This is an automated update. Please: + + 1. Review the build and test results + 2. Check for breaking API changes + 3. Run full test suite locally if needed + + See [LEZ Compat Report](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. + + ### LEZ Changes + View LEZ commits: https://github.com/logos-blockchain/logos-execution-zone/compare/${{ steps.current.outputs.short }}...${{ steps.lez.outputs.short }} + + labels: lez-bump, automated-pr + delete-branch: false + base: ${{ github.ref_name }} + + test-compatibility: + name: Test LEZ Compatibility + runs-on: ubuntu-latest + needs: check-compatibility + if: needs.check-compatibility.outputs.needs_update == 'true' + permissions: + contents: read + issues: write + + steps: + - name: Checkout SPEL (update branch) + uses: actions/checkout@v4 + with: + ref: ${{ needs.check-compatibility.outputs.lez_short && format('lez-bump/{0}', needs.check-compatibility.outputs.lez_short) || 'main' }} + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libssl-dev cmake + + - name: Install logos-blockchain-circuits + run: | + if [ -f scripts/setup-logos-blockchain-circuits.sh ]; then + bash scripts/setup-logos-blockchain-circuits.sh + else + echo "Setup script not found, skipping..." + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cargo metadata (dependency analysis) + id: deps + run: | + # Analyze dependency changes + echo "### Dependency Analysis" > deps_report.md + echo "" >> deps_report.md + + # Check for any git dependency changes + echo "```" >> deps_report.md + cargo tree --depth 1 -p spel-framework-core 2>&1 | head -50 >> deps_report.md || true + echo "```" >> deps_report.md + + cat deps_report.md + + - name: Check for breaking changes (cargo check) + id: cargo-check + continue-on-error: true + run: | + echo "Running cargo check on all SPEL crates..." + + FAILED=() + PASSED=() + + for crate in spel-framework-core spel-framework spel-cli; do + if cargo check -p "$crate" 2>&1; then + echo "✓ $crate: PASS" + PASSED+=("$crate") + else + echo "✗ $crate: FAIL" + FAILED+=("$crate") + fi + done + + echo "Passed: ${#PASSED[@]}" + echo "Failed: ${#FAILED[@]}" + + if [ ${#FAILED[@]} -gt 0 ]; then + echo "status=failed" >> "$GITHUB_OUTPUT" + echo "failed_crates=${FAILED[*]}" >> "$GITHUB_OUTPUT" + else + echo "status=passed" >> "$GITHUB_OUTPUT" + fi + + - name: Generate test report + id: test-report + run: | + REPORT="TEST_REPORT_${{ needs.check-compatibility.outputs.lez_short }}.md" + + cat > "$REPORT" << EOF + # LEZ Compatibility Test Report + + **LEZ Commit:** \`${{ needs.check-compatibility.outputs.lez_commit }}\` + **Test Time:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") + **Runner:** ${{ runner.os }} + + ## Cargo Check Results + + Status: ${{ steps.cargo-check.outputs.status == 'passed' && '✅ PASS' || '❌ FAIL' }} + + EOF + + if [ "${{ steps.cargo-check.outputs.status }}" = "failed" ]; then + echo "### Failed Crates" >> "$REPORT" + echo "" >> "$REPORT" + for crate in ${{ steps.cargo-check.outputs.failed_crates }}; do + echo "- ❌ \`$crate\`" >> "$REPORT" + done + echo "" >> "$REPORT" + echo "## Action Required" >> "$REPORT" + echo "" >> "$REPORT" + echo "The following crates failed to compile against the new LEZ version:" >> "$REPORT" + echo "" >> "$REPORT" + echo '```' >> "$REPORT" + echo "Check workflow logs for detailed error messages." >> "$REPORT" + echo '```' >> "$REPORT" + else + echo "### Results" >> "$REPORT" + echo "" >> "$REPORT" + echo "✅ All crates compiled successfully!" >> "$REPORT" + echo "" >> "$REPORT" + echo "## Recommended Next Steps" >> "$REPORT" + echo "" >> "$REPORT" + echo "- [ ] Run full test suite: \`cargo test --all\`" >> "$REPORT" + echo "- [ ] Run clippy: \`cargo clippy --all-targets --all-features\`" >> "$REPORT" + echo "- [ ] Test with localnet if applicable" >> "$REPORT" + fi + + cat "$REPORT" + + # Upload as artifact + mkdir -p reports + cp "$REPORT" reports/ + echo "report_path=$REPORT" >> "$GITHUB_OUTPUT" + + - name: Upload test report + uses: actions/upload-artifact@v4 + with: + name: lez-compat-report-${{ needs.check-compatibility.outputs.lez_short }} + path: reports/ + retention-days: 30 + + - name: Create issue on failure + if: steps.cargo-check.outputs.status == 'failed' + uses: actions/github-script@v7 + with: + script: | + const LEZ = '${{ needs.check-compatibility.outputs.lez_short }}'; + const LEZ_COMMIT = '${{ needs.check-compatibility.outputs.lez_commit }}'; + const FAILED_CRATES = '${{ steps.cargo-check.outputs.failed_crates }}'.split(' '); + + const body = `## LEZ Compatibility Check Failed + + **LEZ Version:** \`${LEZ_COMMIT}\` (${LEZ}) + **Triggered by:** ${{ github.event.schedule && 'Scheduled' || github.event.sender.login }} + **Run:** [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + ### Failed Crates + ${FAILED_CRATES.map(c => `- ❌ \`${c}\``).join('\n')} + + ### Required Actions + + 1. **Investigate compilation errors** - Check the "Check for breaking changes" job logs + 2. **Review LEZ changelog** - See what changed in LEZ: https://github.com/logos-blockchain/logos-execution-zone/commits/main + 3. **Update SPEL code** - Fix any API incompatibilities + 4. **Re-run workflow** - After fixes, trigger manually or wait for next schedule + + ### LEZ Diff + Compare: https://github.com/logos-blockchain/logos-execution-zone/compare/${{ needs.check-compatibility.outputs.current_lez_commit }}...${LEZ} + + --- + *Auto-generated by LEZ Compatibility Check*`; + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `🚨 LEZ Compatibility Check Failed - LEZ ${LEZ}`, + body, + labels: ['lez-bump', 'blocked'], + assignees: ['${{ github.actor }}'] + }); + + - name: Comment on PR with results + if: always() && steps.cargo-check.status + uses: actions/github-script@v7 + with: + script: | + const LEZ = '${{ needs.check-compatibility.outputs.lez_short }}'; + const status = '${{ steps.cargo-check.outputs.status }}'; + + // Find PR for this branch + const branch = `lez-bump/${LEZ}`; + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${branch}` + }); + + if (prs.length === 0) { + console.log('No open PR found for branch:', branch); + return; + } + + const pr = prs[0]; + const emoji = status === 'passed' ? '✅' : '❌'; + const statusText = status === 'passed' ? 'All checks passed' : 'Some checks failed'; + + const comment = `## LEZ Compatibility Test Results ${emoji} + + **LEZ:** \`${LEZ}\` + **Status:** ${statusText} + + ${status === 'passed' + ? '✅ SPEL compiles successfully against the new LEZ version. Ready for manual testing and review.' + : '❌ See above for failed crates. Check logs for detailed error messages.'} + + [View full test report](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + `; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: comment + }); diff --git a/.github/workflows/lez-compat.yml b/.github/workflows/lez-compat.yml new file mode 100644 index 00000000..674dcde5 --- /dev/null +++ b/.github/workflows/lez-compat.yml @@ -0,0 +1,164 @@ +name: LEZ Bump + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + bump: + name: Bump LEZ + PR + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + + outputs: + lez_commit: ${{ steps.lez.outputs.commit }} + lez_short: ${{ steps.lez.outputs.short }} + + steps: + - uses: actions/checkout@v4 + + - name: Get latest LEZ commit + id: lez + run: | + COMMIT=$(git ls-remote https://github.com/logos-blockchain/logos-execution-zone.git main | cut -f1) + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + echo "short=${COMMIT:0:8}" >> "$GITHUB_OUTPUT" + echo "LEZ main = $COMMIT" + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Push LEZ deps to branch + run: | + LEZ="${{ steps.lez.outputs.commit }}" + LEZ_SHORT="${{ steps.lez.outputs.short }}" + BRANCH="lez-bump/main" + + # Find or create branch + if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + echo "Fetching existing branch..." + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git rebase origin/main || true + else + echo "Creating new branch: $BRANCH" + git checkout -b "$BRANCH" + fi + + # Update deps + for f in spel-cli/Cargo.toml spel-framework-core/Cargo.toml spel-framework/Cargo.toml tests/e2e/fixture_program/Cargo.toml; do + [ -f "$f" ] || continue + sed -i "s|rev = \"[a-f0-9]\{40\}\"|rev = \"$LEZ\"|g" "$f" + sed -i "s|logos-blockchain/lssa\.git|logos-blockchain/logos-execution-zone.git|g" "$f" + done + + # Check if anything changed + if git diff --stat | grep -q '0 files changed'; then + echo "No changes — LEZ deps already up to date." + echo "up_to_date=true" >> "$GITHUB_OUTPUT" + else + echo "Changes detected:" + git diff --stat + git add . + git commit --amend --no-edit || git commit -m "chore(bump): update LEZ to $LEZ_SHORT" + git push --force-with-lease origin "$BRANCH" + echo "up_to_date=false" >> "$GITHUB_OUTPUT" + fi + + - name: Find existing PR + if: env.UP_TO_DATE != 'true' + id: find_pr + uses: actions/github-script@v7 + with: + script: | + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:lez-bump/main`, + state: 'open', + }).catch(() => ({ data: [] })); + const pr = prs[0]; + if (pr) { + console.log(`Found PR #${pr.number}`); + core.setOutput('pr_number', String(pr.number)); + } else { + console.log('No existing PR found'); + core.setOutput('pr_number', ''); + } + + ci: + name: Cargo Check + needs: bump + runs-on: ubuntu-latest + if: needs.bump.outputs.up_to_date != 'true' + permissions: + contents: read + issues: write + + steps: + - uses: actions/checkout@v4 + with: + ref: lez-bump/main + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - 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: Cargo check (spel) + run: cargo check -p spel + + - 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 result + if: always() + run: | + LEZ_SHORT="${{ needs.bump.outputs.lez_short }}" + LEZ_COMMIT="${{ needs.bump.outputs.lez_commit }}" + + if [ '${{ job.status }}' = 'success' ]; then + echo "✅ All cargo checks passed!" + echo "" + echo "LEZ bump is ready to merge." + echo "Branch: lez-bump/main" + echo "Commit: $LEZ_COMMIT" + echo "" + echo "Merge manually at: https://github.com/$GITHUB_REPOSITORY/pull/lez-bump/main" + else + echo "❌ Cargo check failed." + echo "LEZ commit: $LEZ_COMMIT" + echo "" + echo "An issue has been created automatically." + fi + + - name: Create issue on failure + if: job.status == 'failure' + uses: actions/github-script@v7 + with: + script: | + const LEZ = '${{ needs.bump.outputs.lez_short }}'; + const COMMIT = '${{ needs.bump.outputs.lez_commit }}'; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `LEZ bump failed — LEZ ${LEZ}`, + body: `## LEZ Compatibility Check Failed\n\n**LEZ:** \`${COMMIT}\`\n**Triggered by:** @${context.actor}\n\nCargo check failed against the latest LEZ. See CI logs for details.\n\n**Action required:** Investigate and fix SPEL before merging LEZ update.`, + labels: ['lez-bump', 'bug'], + }); 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/.github/workflows/weekly-rc.yml b/.github/workflows/weekly-rc.yml new file mode 100644 index 00000000..320e382d --- /dev/null +++ b/.github/workflows/weekly-rc.yml @@ -0,0 +1,169 @@ +name: Weekly RC + +on: + schedule: + - cron: '0 8 * * 1' # Every Monday at 8:00 UTC + workflow_dispatch: # Allow manual trigger + +env: + CARGO_TERM_COLOR: always + +jobs: + weekly-rc: + name: Publish Weekly RC + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Check for new commits since last RC + id: check + run: | + # Find the latest RC tag + LAST_RC=$(git tag --list 'v*-rc.*' --sort=-version:refname | head -1) + + if [ -z "$LAST_RC" ]; then + # No RC tags yet — find last stable release + LAST_RC=$(git tag --list 'v[0-9]*' --sort=-version:refname | grep -v '\-rc\.' | head -1) + fi + + echo "Last RC/release: $LAST_RC" + + if [ -z "$LAST_RC" ]; then + echo "No previous tags found, proceeding with RC" + echo "has_changes=true" >> $GITHUB_OUTPUT + else + COMMIT_COUNT=$(git rev-list ${LAST_RC}..HEAD --count) + echo "Commits since $LAST_RC: $COMMIT_COUNT" + if [ "$COMMIT_COUNT" -gt 0 ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No new commits since $LAST_RC — skipping RC" + fi + fi + + - name: Compute next RC version + id: version + if: steps.check.outputs.has_changes == 'true' + run: | + # Get current stable version from spel-framework/Cargo.toml + STABLE=$(grep '^version = ' spel-framework/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Stable version: $STABLE" + + # Find the highest existing RC number for this stable version + LAST_RC_N=$(git tag --list "v${STABLE}-rc.*" --sort=-version:refname | head -1 | sed "s/v${STABLE}-rc\.//") + + if [ -z "$LAST_RC_N" ]; then + NEXT_N=1 + else + NEXT_N=$((LAST_RC_N + 1)) + fi + + RC_VERSION="${STABLE}-rc.${NEXT_N}" + echo "RC version: $RC_VERSION" + echo "rc_version=$RC_VERSION" >> $GITHUB_OUTPUT + echo "stable_version=$STABLE" >> $GITHUB_OUTPUT + echo "rc_n=$NEXT_N" >> $GITHUB_OUTPUT + + - uses: dtolnay/rust-toolchain@stable + if: steps.check.outputs.has_changes == 'true' + + - name: Install logos-blockchain-circuits + if: steps.check.outputs.has_changes == 'true' + 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: Cargo check + if: steps.check.outputs.has_changes == 'true' + run: cargo check + + - name: Run unit tests + if: steps.check.outputs.has_changes == 'true' + run: cargo test --lib + + - name: Generate changelog since last RC + id: changelog + if: steps.check.outputs.has_changes == 'true' + run: | + LAST_RC=$(git tag --list 'v*-rc.*' --sort=-version:refname | head -1) + if [ -z "$LAST_RC" ]; then + LAST_RC=$(git tag --list 'v[0-9]*' --sort=-version:refname | grep -v '\-rc\.' | head -1) + fi + + if [ -z "$LAST_RC" ]; then + RANGE="HEAD" + else + RANGE="${LAST_RC}..HEAD" + fi + + 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) + OTHER=$(git log $RANGE --pretty=format:"- %s (%h)" --no-merges | grep -vE "^- (feat|fix|chore|ci:)" || true) + + BODY="" + [ -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" + + echo "body<> $GITHUB_OUTPUT + echo -e "$BODY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create RC tag and release + if: steps.check.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RC_VERSION: ${{ steps.version.outputs.rc_version }} + run: | + # Create and push the versioned RC tag + git tag -a "v${RC_VERSION}" -m "RC v${RC_VERSION}" + git push origin "v${RC_VERSION}" + + # Move the rc-latest tag + git tag -f rc-latest + git push origin rc-latest --force + + - name: Trigger lez-multisig RC update + if: steps.check.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RC_VERSION: ${{ steps.version.outputs.rc_version }} + run: | + gh api repos/logos-co/lez-multisig/dispatches \ + -f event_type=spel-rc-published \ + -f client_payload[tag]="v${RC_VERSION}" || \ + echo "Warning: could not trigger lez-multisig (may need REPO_DISPATCH_TOKEN secret)" + + - name: Create GitHub pre-release + if: steps.check.outputs.has_changes == 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.rc_version }} + name: v${{ steps.version.outputs.rc_version }} (RC) + prerelease: true + body: | + ## Release Candidate v${{ steps.version.outputs.rc_version }} + + ⚠️ This is a **release candidate** — not for production use. + + ${{ steps.changelog.outputs.body }} + + --- + + **Installation** (Cargo.toml): + ```toml + spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v${{ steps.version.outputs.rc_version }}" } + ``` 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..3014fc40 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,10 +66,10 @@ mod my_program { state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, - ) -> NssaResult { + ) -> SpelResult { // Your logic here - Ok(NssaOutput::states_only(vec![ - AccountPostState::new_claimed(state.account.clone()), + Ok(SpelOutput::states_only(vec![ + AccountPostState::new_claimed(state.account.clone(), Claim::Authorized), 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; } ``` @@ -144,42 +145,104 @@ This provides: - `--dry-run` mode for testing - `inspect` subcommand to extract ProgramId from binaries +### Account Types + +Types that represent on-chain account data can be annotated with `#[account_type]`. This causes them to appear in the generated IDL so `spel inspect` can decode raw account bytes into readable JSON. + +```rust +use spel_framework::prelude::*; + +#[account_type] +#[derive(BorshSerialize, BorshDeserialize)] +pub struct VaultState { + pub owner: AccountId, + pub balance: u128, + pub locked: bool, +} + +#[account_type] +#[derive(BorshSerialize, BorshDeserialize)] +pub enum TokenHolding { + Fungible { definition_id: AccountId, balance: u128 }, + NftMaster { definition_id: AccountId, print_balance: u128 }, +} +``` + +Types referenced by an `#[account_type]` (such as helper enums or nested structs) are collected automatically — they do not need their own annotation: + +```rust +// No annotation needed — picked up automatically because VaultState references it +#[derive(BorshSerialize, BorshDeserialize)] +pub enum VaultStatus { Active, Frozen } +``` + +The IDL generator embeds all annotated types in the `accounts` array and all transitively referenced helper types in the `types` array of the generated JSON. No file paths or external references — the IDL is fully self-contained. + ### IDL Generation 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 + +# Generate IDL from a program source file (includes all #[account_type] definitions) +spel generate-idl methods/guest/src/bin/my_program.rs > my_program-idl.json + +# Decode on-chain account data using a type from the IDL +spel inspect --idl my_program-idl.json --type VaultState + +# Same, but supply raw borsh bytes directly instead of fetching from the network +spel inspect --idl my_program-idl.json --type VaultState --data # 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,18 +252,72 @@ 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 | +### Inspecting Account Data + +Once types are annotated with `#[account_type]` and the IDL is generated, you can decode any on-chain account into JSON: + +```bash +# Generate the IDL (embeds all annotated account types) +spel generate-idl methods/guest/src/bin/token.rs > token-idl.json + +# Fetch and decode a live account from the network +spel inspect 3f2a...bc01 --idl token-idl.json --type TokenHolding +``` + +``` +Account: 3f2a...bc01 +Data: 33 bytes +Hex: 01aabbccdd... + +{ + "NftMaster": { + "definition_id": "aabbccddee...", + "print_balance": "99" + } +} +``` + +For accounts with nested types (e.g. `TokenMetadata` referencing `MetadataStandard`), the IDL contains both and decoding works transparently: + +```bash +spel inspect 9d1c...f4 --idl token-idl.json --type TokenMetadata +``` + +```json +{ + "definition_id": "aabbccddee...", + "standard": "Simple", + "uri": "https://example.com/metadata.json", + "creators": "Alice", + "primary_sale_date": "1720000000" +} +``` + +You can also pass raw borsh bytes directly with `--data` to decode without a network connection — useful during development and testing: + +```bash +spel inspect 0000...0000 \ + --idl token-idl.json \ + --type TokenHolding \ + --data 00<32-byte-definition-id-hex>00000000000000000000000000000064 +``` + ## Crates | 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/SPEC.md b/SPEC.md new file mode 100644 index 00000000..4d4d7e0d --- /dev/null +++ b/SPEC.md @@ -0,0 +1,79 @@ +# SPEC: `spel --dry-run` Transaction Summary + +## Context + +When running `spel` commands with `--dry-run`, users should see the complete local transaction picture before any submission. This enables validation and scripting use cases. + +The current arg-parsing base (#134) has fixed CLI flag handling. The `--dry-run` flag exists but does not yet produce a transaction summary. + +## What + +### CLI Flag + +```bash +spel --dry-run [text|json] # text is default +spel --dry-run=text # equivalent +spel --dry-run=json # JSON output +``` + +- `--dry-run` alone → text summary to stdout, no submission +- `--dry-run json` → JSON object to stdout, no submission +- `--dry-run=json` → same as above (equals syntax) +- Without `--dry-run` → normal submission + +### Text Summary Format + +``` +=== Dry Run === +Program ID: +Accounts: + owner → Nsxxxxx… [signer, writable] + vault → 4Lp3gkH… [writable] + PDA vault → 4Lp3gkH… + seeds: [program_id, "owner"] +Arguments: + --target Owner/abc123... + --amount 100 +Instruction data: +Signers: + owner: nonce=42 +================ +Dry run complete — not submitted. +``` + +### JSON Format + +```json +{ + "program_id": "Nsxxxxx…", + "accounts": [ + {"name": "owner", "id": "Nsxxxxx…", "flags": ["signer", "writable"]}, + {"name": "vault", "id": "4Lp3gkH…", "flags": ["writable"]}, + {"name": "vault", "id": "4Lp3gkH…", "is_pda": true, "seeds": ["program_id", "owner"]} + ], + "arguments": {"target": "Owner/abc123…", "amount": 100}, + "instruction_data": "dead…", + "signers": {"owner": {"nonce": 42}} +} +``` + +## Constraints + +1. **Zero new dependencies** — use only existing crates +2. **Format strings** verified with `rustc` before commit +3. **No duplicate program binary reads** — read once, reuse +4. **PDA seed display** — derived from IDL, show Const/account/Arg seeds per PDA +5. **Non-fatal if wallet absent** — nonce may be unknown, show `(unknown)` + +## Files to Change + +- `spel-cli/src/tx.rs` — main implementation +- `spel-cli/src/lib.rs` — `--dry-run[=text|json]` flag parsing +- `spel-cli/src/cli.rs` — help text update +- `README.md` — usage examples + +## Out of Scope + +- Privacy warnings (not applicable — local RPC) +- Submission retry logic +- Wallet key management diff --git a/docs/ideas/cli-arg-separation.md b/docs/ideas/cli-arg-separation.md new file mode 100644 index 00000000..cb75d6d6 --- /dev/null +++ b/docs/ideas/cli-arg-separation.md @@ -0,0 +1,80 @@ +# CLI Argument Separation: `spel.toml` + `--` Separator + +## Problem Statement +**How might we** eliminate namespace collisions between spel CLI flags and IDL-driven instruction arguments, while keeping the daily UX clean for both newcomers and power users? + +## Recommended Direction + +Two complementary changes: + +### 1. `spel.toml` — project config for per-project defaults + +A config file at the project root holds values that don't change between invocations: + +```toml +[program] +idl = "treasury-idl.json" +binary = "methods/guest/target/riscv32im-risc0-zkvm-elf/docker/treasury.bin" +``` + +This removes `--idl` and `--program` from the command line entirely for the common case: + +```bash +# Before (today): +spel --idl treasury-idl.json -p methods/guest/target/.../treasury.bin create-vault --owner-key 0xAB... + +# After: +spel create-vault --owner-key 0xAB... +``` + +`spel.toml` is auto-discovered by walking up from CWD. `spel init` generates it alongside existing scaffolding. + +### 2. `--` separator — for CLI overrides + +When a user needs to override config values on the fly, the standard `--` separator marks the boundary: + +```bash +spel --idl other.json -- create-vault --owner-key 0xAB... +``` + +Everything before `--` is for spel. Everything after is the instruction subcommand + its arguments. This is the standard Unix convention (`cargo run`, `docker exec`, `ssh`). + +### Precedence + +CLI flag (`--idl`) > `spel.toml` > built-in default (`program.bin`) + +### Backward compatibility + +Current flat parsing (no `--`, no `spel.toml`) remains as a **deprecated fallback**. When the parser detects ambiguous flag usage without `--`, it emits a deprecation warning directing users to either `spel.toml` or the `--` separator. + +## Key Assumptions to Validate +- [ ] Users run spel from project root (where `spel.toml` lives) — validate by checking `spel init` project structure +- [ ] `idl` and `binary` are sufficient config fields for v1 — audit which flags are per-project vs. per-invocation +- [ ] Adding a `toml` dep to `spel-cli` is acceptable — check transitive deps and compile time impact +- [ ] Early adopters can migrate with deprecation warnings over ~2 releases + +## MVP Scope + +**In scope:** +- `spel.toml` with `[program]` section (`idl`, `binary` fields), auto-discovered by walking up from CWD +- `spel init` generates `spel.toml` +- `--` separator support in the parser (`lib.rs`) +- Deprecation warning when flat-parsing without `--` or config +- Updated help output showing both modes + +**Out of scope (v1):** +- Per-environment config (dev/testnet/mainnet profiles) +- Config fields beyond `idl` and `binary` +- `--bin-` in config + +## Not Doing (and Why) +- **Per-environment profiles** — YAGNI; one `spel.toml` per project is enough. Add `[env.testnet]` later if demand arises. +- **Removing flat parsing entirely** — early adopters have scripts/Makefiles. Deprecation period first. +- **Auto-detecting IDL without config** — implicit magic creates UX confusion. Explicit config is better. +- **Positional instruction args** — less self-documenting, breaks help generation. +- **Prefix namespacing (`--arg:name`)** — non-standard, more typing, solves the same problem worse. + +## Open Questions +- Should `spel init` update existing Makefile `ARGS=` patterns to reflect the new config-based workflow? +- Deprecation timeline: warn for how many releases before removing flat parsing? +- Should `spel.toml` also support `program-id` (hex) as an alternative to `binary` (path)? 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/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/serialize.rs b/nssa-framework-cli/src/serialize.rs deleted file mode 100644 index 8f400fea..00000000 --- a/nssa-framework-cli/src/serialize.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! risc0-compatible serialization for IDL instruction data. - -use nssa_framework_core::idl::IdlType; -use crate::parse::ParsedValue; - -/// Serialize an instruction to risc0 serde format (Vec). -/// -/// Produces: variant_index (u32), then each field serialized in order. -/// Matches `risc0_zkvm::serde::to_vec` for an enum struct variant. -pub fn serialize_to_risc0( - variant_index: u32, - parsed_args: &[(&IdlType, &ParsedValue)], -) -> Vec { - let mut out = vec![variant_index]; - for (ty, val) in parsed_args { - serialize_value_risc0(&mut out, ty, val); - } - out -} - -fn serialize_value_risc0(out: &mut Vec, ty: &IdlType, val: &ParsedValue) { - match (ty, val) { - (IdlType::Primitive(p), _) => serialize_primitive_risc0(out, p.as_str(), val), - (IdlType::Array { array }, _) => serialize_array_risc0(out, &array.0, array.1, val), - (IdlType::Vec { vec }, _) => serialize_vec_risc0(out, vec, val), - (IdlType::Option { option: _ }, ParsedValue::None) => { - out.push(0); - } - (IdlType::Option { option }, ParsedValue::Some(inner)) => { - out.push(1); - serialize_value_risc0(out, option, inner); - } - (IdlType::Option { option }, _) => { - out.push(1); - serialize_value_risc0(out, option, val); - } - _ => { - eprintln!("⚠️ Cannot serialize Defined/Raw type in risc0 format: {:?}", val); - } - } -} - -fn serialize_primitive_risc0(out: &mut Vec, prim: &str, val: &ParsedValue) { - match (prim, val) { - ("bool", ParsedValue::Bool(b)) => out.push(if *b { 1 } else { 0 }), - ("u8", ParsedValue::U8(v)) => out.push(*v as u32), - ("u32", ParsedValue::U32(v)) => out.push(*v), - ("u64", ParsedValue::U64(v)) => { - out.push(*v as u32); - out.push((*v >> 32) as u32); - } - ("u128", ParsedValue::U128(v)) => { - let bytes = v.to_le_bytes(); - for chunk in bytes.chunks(4) { - out.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); - } - } - ("program_id", ParsedValue::U32Array(vals)) => { - for v in vals { - out.push(*v); - } - } - ("string" | "String", ParsedValue::Str(s)) => { - let bytes = s.as_bytes(); - out.push(bytes.len() as u32); - serialize_bytes_padded(out, bytes); - } - _ => { - eprintln!("⚠️ Type mismatch in risc0 serialization: prim={}, val={:?}", prim, val); - } - } -} - -fn serialize_array_risc0(out: &mut Vec, elem_type: &IdlType, _size: usize, val: &ParsedValue) { - match (elem_type, val) { - (IdlType::Primitive(p), ParsedValue::ByteArray(bytes)) if p == "u8" => { - for b in bytes { - out.push(*b as u32); - } - } - (IdlType::Primitive(p), ParsedValue::U32Array(vals)) if p == "u32" => { - for v in vals { - out.push(*v); - } - } - _ => { - eprintln!("⚠️ Cannot serialize array type in risc0 format: {:?}", val); - } - } -} - -fn serialize_vec_risc0(out: &mut Vec, elem_type: &IdlType, val: &ParsedValue) { - match (elem_type, val) { - (IdlType::Array { array }, ParsedValue::ByteArrayVec(vecs)) => { - out.push(vecs.len() as u32); - match &*array.0 { - IdlType::Primitive(p) if p == "u8" => { - for v in vecs { - for b in v { - out.push(*b as u32); - } - } - } - _ => { - eprintln!("⚠️ Cannot serialize Vec element type in risc0 format"); - } - } - } - _ => { - eprintln!("⚠️ Cannot serialize Vec type in risc0 format: {:?}", val); - } - } -} - -fn serialize_bytes_padded(out: &mut Vec, bytes: &[u8]) { - let mut i = 0; - while i < bytes.len() { - let remaining = bytes.len() - i; - let mut word_bytes = [0u8; 4]; - let take = remaining.min(4); - word_bytes[..take].copy_from_slice(&bytes[i..i + take]); - out.push(u32::from_le_bytes(word_bytes)); - i += 4; - } -} 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-macros/Cargo.toml b/nssa-framework-macros/Cargo.toml deleted file mode 100644 index 4df5aa3b..00000000 --- a/nssa-framework-macros/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "nssa-framework-macros" -version = "0.1.0" -edition = "2021" -description = "Proc macros for the NSSA/LEZ program framework" - -[lib] -proc-macro = true - -[dependencies] -proc-macro2 = "1.0" -quote = "1.0" -syn = { version = "2.0", features = ["full", "extra-traits"] } diff --git a/nssa-framework-macros/src/lib.rs b/nssa-framework-macros/src/lib.rs deleted file mode 100644 index ebd959de..00000000 --- a/nssa-framework-macros/src/lib.rs +++ /dev/null @@ -1,872 +0,0 @@ -//! # NSSA Framework Proc Macros -//! -//! This crate provides the `#[nssa_program]` attribute macro that eliminates -//! boilerplate in NSSA/LEZ guest binaries, and the `generate_idl!` macro -//! for extracting IDL from program source files. -//! -//! ## Usage -//! -//! ```rust,ignore -//! use nssa_framework::prelude::*; -//! -//! #[nssa_program] -//! mod my_program { -//! #[instruction] -//! pub fn create( -//! #[account(init, pda = const("my_state"))] -//! state: AccountWithMetadata, -//! name: String, -//! ) -> NssaResult { -//! // business logic only -//! } -//! } -//! ``` -//! -//! ## IDL Generation -//! -//! ```rust,ignore -//! // generate_idl.rs — one-liner! -//! nssa_framework::generate_idl!("src/bin/treasury.rs"); -//! ``` - -use proc_macro::TokenStream; -use proc_macro2::TokenStream as TokenStream2; -use quote::{format_ident, quote}; -use syn::{ - parse_macro_input, Attribute, FnArg, Ident, ItemFn, ItemMod, Pat, PatType, Type, -}; - -/// Main entry point: `#[nssa_program]` on a module. -/// -/// This macro: -/// 1. Finds all `#[instruction]` functions in the module -/// 2. Generates a serde-serializable `Instruction` enum -/// 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) -#[proc_macro_attribute] -pub fn nssa_program(_attr: TokenStream, item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as ItemMod); - match expand_nssa_program(input) { - 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. -#[proc_macro_attribute] -pub fn instruction(_attr: TokenStream, item: TokenStream) -> TokenStream { - item -} - -/// Generate IDL from a program source file. -/// -/// Parses the given Rust source file, finds the `#[nssa_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"); -/// ``` -#[proc_macro] -pub fn generate_idl(input: TokenStream) -> TokenStream { - let lit = parse_macro_input!(input as syn::LitStr); - let file_path = lit.value(); - - match expand_generate_idl(&file_path, &lit) { - Ok(tokens) => tokens.into(), - Err(err) => err.to_compile_error().into(), - } -} - -// ─── Internal expansion logic ──────────────────────────────────────────── - -/// Parsed info about one instruction function. -struct InstructionInfo { - fn_name: Ident, - /// Account parameters (AccountWithMetadata type), in order - accounts: Vec, - /// Non-account parameters (the instruction args) - args: Vec, - /// The original function item (with #[instruction] stripped) - func: ItemFn, -} - -struct AccountParam { - name: Ident, - constraints: AccountConstraints, -} - -#[derive(Default)] -struct AccountConstraints { - mutable: bool, - init: bool, - owner: Option, - signer: bool, - pda_seeds: Vec, -} - -/// A PDA seed definition from the `#[account(pda = ...)]` attribute. -#[derive(Clone)] -enum PdaSeedDef { - /// `const("some_string")` — a constant string seed - Const(String), - /// `account("other_account_name")` — seed derived from another account's ID - Account(String), - /// `arg("some_arg")` — seed derived from an instruction argument - Arg(String), -} - -struct ArgParam { - name: Ident, - ty: Type, -} - -fn expand_nssa_program(input: ItemMod) -> 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"))?; - - // Collect instruction functions and other items - let mut instructions: Vec = Vec::new(); - let mut other_items: Vec = Vec::new(); - - for item in items { - match item { - syn::Item::Fn(func) => { - if has_instruction_attr(&func.attrs) { - instructions.push(parse_instruction(func.clone())?); - } else { - other_items.push(quote! { #func }); - } - } - other => { - other_items.push(quote! { #other }); - } - } - } - - if instructions.is_empty() { - return Err(syn::Error::new_spanned( - &input.ident, - "nssa_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 match arms for dispatch - let match_arms = generate_match_arms(mod_name, &instructions); - - // Generate the handler functions (with #[instruction] stripped, account attrs stripped) - let handler_fns = generate_handler_fns(&instructions); - - // Generate validation functions - let validation_fns = generate_validation(&instructions); - - // Generate main function - let main_fn = quote! { - fn main() { - // Read inputs from zkVM host - let (nssa_core::program::ProgramInput { pre_states, instruction }, instruction_words) - = nssa_core::program::read_nssa_inputs::(); - let pre_states_clone = pre_states.clone(); - - // Dispatch to instruction handler - let result: Result< - (Vec, Vec), - nssa_framework_core::error::NssaError - > = match instruction { - #(#match_arms)* - }; - - // Handle result - let (post_states, chained_calls) = match result { - Ok(output) => output, - Err(e) => { - panic!("Program error [{}]: {}", e.error_code(), e); - } - }; - - // Write outputs to zkVM host - nssa_core::program::write_nssa_outputs_with_chained_call( - instruction_words, - pre_states_clone, - post_states, - chained_calls, - ); - } - }; - - // Generate IDL function and const JSON - let idl_fn = generate_idl_fn(mod_name, &instructions); - let idl_json = generate_idl_json(mod_name, &instructions); - - // Assemble everything - let expanded = quote! { - // The instruction enum (used by both on-chain and client) - #enum_def - - // Complete IDL as a const JSON string (accessible from any target) - pub const PROGRAM_IDL_JSON: &str = #idl_json; - - // The program module with handler functions - mod #mod_name { - use super::*; - - #(#other_items)* - - #(#handler_fns)* - - #(#validation_fns)* - } - - // IDL generation (available at host-side for tooling) - #idl_fn - - // The guest binary entry point - #main_fn - }; - - Ok(expanded) -} - -fn has_instruction_attr(attrs: &[Attribute]) -> bool { - attrs.iter().any(|a| a.path().is_ident("instruction")) -} - -fn parse_instruction(func: ItemFn) -> syn::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, - }); - } else { - args.push(ArgParam { - name: param_name, - ty: ty.clone(), - }); - } - } - FnArg::Receiver(_) => { - return Err(syn::Error::new_spanned( - input, - "instruction functions cannot have self parameter", - )); - } - } - } - - Ok(InstructionInfo { - fn_name, - accounts, - args, - func, - }) -} - -fn extract_param_name(pat_type: &PatType) -> syn::Result { - match &*pat_type.pat { - Pat::Ident(pat_ident) => Ok(pat_ident.ident.clone()), - _ => Err(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 parse_account_constraints(attrs: &[Attribute]) -> syn::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()?; - constraints.owner = Some(expr); - Ok(()) - } else if meta.path.is_ident("pda") { - // Parse PDA seeds: pda = const("value"), pda = account("name"), pda = arg("name") - 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")) - } - })?; - } - } - - Ok(constraints) -} - -/// Parse PDA seed expressions. -/// -/// Supports: -/// - `const("string")` — constant seed -/// - `account("name")` — account-derived seed -/// - `arg("name")` — argument-derived seed -/// - `[const("a"), account("b")]` — multiple seeds (array syntax) -fn parse_pda_expr(expr: &syn::Expr) -> syn::Result> { - match expr { - // Single seed: const("value") or account("name") - syn::Expr::Call(call) => { - let seed = parse_single_pda_seed(call)?; - Ok(vec![seed]) - } - // Multiple seeds: [const("a"), account("b")] - 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) -> syn::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 - ), - )), - } -} - -// ─── Code generation helpers ───────────────────────────────────────────── - -fn generate_enum_variants(instructions: &[InstructionInfo]) -> Vec { - instructions - .iter() - .map(|ix| { - let variant_name = to_pascal_case(&ix.fn_name); - let fields: Vec = ix - .args - .iter() - .map(|arg| { - let name = &arg.name; - let ty = &arg.ty; - quote! { #name: #ty } - }) - .collect(); - - if fields.is_empty() { - quote! { #variant_name } - } else { - quote! { #variant_name { #(#fields),* } } - } - }) - .collect() -} - -fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Vec { - instructions - .iter() - .map(|ix| { - let variant_name = to_pascal_case(&ix.fn_name); - let fn_name = &ix.fn_name; - let num_accounts = ix.accounts.len(); - - let field_names: Vec<&Ident> = ix.args.iter().map(|a| &a.name).collect(); - let pattern = if field_names.is_empty() { - quote! { Instruction::#variant_name } - } else { - 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 call_args: Vec = ix - .accounts - .iter() - .map(|a| { - let name = &a.name; - quote! { #name } - }) - .chain(ix.args.iter().map(|a| { - let name = &a.name; - quote! { #name } - })) - .collect(); - - quote! { - #pattern => { - #account_destructure - #mod_name::#fn_name(#(#call_args),*) - .map(|output| (output.post_states, output.chained_calls)) - } - } - }) - .collect() -} - -fn generate_handler_fns(instructions: &[InstructionInfo]) -> Vec { - instructions - .iter() - .map(|ix| { - let mut func = ix.func.clone(); - func.attrs.retain(|a| !a.path().is_ident("instruction")); - for input in &mut func.sig.inputs { - if let FnArg::Typed(pat_type) = input { - pat_type.attrs.retain(|a| !a.path().is_ident("account")); - } - } - quote! { #func } - }) - .collect() -} - -fn generate_validation(_instructions: &[InstructionInfo]) -> Vec { - vec![] -} - -fn to_pascal_case(ident: &Ident) -> Ident { - let s = ident.to_string(); - let pascal: String = s - .split('_') - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().collect::() + chars.as_str(), - } - }) - .collect(); - format_ident!("{}", pascal) -} - -// ─── IDL type conversion ───────────────────────────────────────────────── - -fn rust_type_to_idl_string(ty: &Type) -> String { - match ty { - Type::Path(type_path) => { - let segment = type_path.path.segments.last().unwrap(); - let ident = segment.ident.to_string(); - match ident.as_str() { - "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" - | "i128" | "bool" | "String" => ident.to_lowercase(), - "Vec" => { - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { - format!("vec<{}>", rust_type_to_idl_string(inner)) - } else { - "vec".to_string() - } - } else { - "vec".to_string() - } - } - "ProgramId" => "program_id".to_string(), - other => other.to_string(), - } - } - Type::Array(arr) => { - let elem = rust_type_to_idl_string(&arr.elem); - if let syn::Expr::Lit(lit) = &arr.len { - if let syn::Lit::Int(n) = &lit.lit { - return format!("[{}; {}]", elem, n); - } - } - format!("[{}; ?]", elem) - } - _ => "unknown".to_string(), - } -} - -/// Convert a Rust IDL type string to the JSON representation. -/// This produces a JSON value string for embedding in const IDL JSON. -fn rust_type_to_idl_json(ty: &Type) -> String { - match ty { - Type::Path(type_path) => { - let segment = type_path.path.segments.last().unwrap(); - let ident = segment.ident.to_string(); - match ident.as_str() { - "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" - | "i128" | "bool" | "String" => { - format!("\"{}\"", ident.to_lowercase()) - } - "Vec" => { - if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { - if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { - format!("{{\"vec\":{}}}", rust_type_to_idl_json(inner)) - } else { - "\"vec\"".to_string() - } - } else { - "\"vec\"".to_string() - } - } - "ProgramId" => "\"program_id\"".to_string(), - other => format!("{{\"defined\":\"{}\"}}", other), - } - } - Type::Array(arr) => { - let elem = rust_type_to_idl_json(&arr.elem); - if let syn::Expr::Lit(lit) = &arr.len { - if let syn::Lit::Int(n) = &lit.lit { - return format!("{{\"array\":[{},{}]}}", elem, n); - } - } - format!("{{\"array\":[{},0]}}", elem) - } - _ => "\"unknown\"".to_string(), - } -} - -// ─── IDL generation (code-based, for __program_idl()) ──────────────────── - -fn generate_idl_fn(mod_name: &Ident, instructions: &[InstructionInfo]) -> TokenStream2 { - let program_name = mod_name.to_string(); - - let instruction_literals: Vec = instructions - .iter() - .map(|ix| { - let ix_name = ix.fn_name.to_string(); - - let account_literals: Vec = ix - .accounts - .iter() - .map(|acc| { - let acc_name = acc.name.to_string().trim_start_matches('_').to_string(); - let writable = acc.constraints.mutable; - let signer = acc.constraints.signer; - let init = acc.constraints.init; - - let pda_expr = if acc.constraints.pda_seeds.is_empty() { - quote! { None } - } else { - let seed_literals: Vec = acc - .constraints - .pda_seeds - .iter() - .map(|seed| match seed { - PdaSeedDef::Const(val) => quote! { - nssa_framework_core::idl::IdlSeed::Const { value: #val.to_string() } - }, - PdaSeedDef::Account(name) => quote! { - nssa_framework_core::idl::IdlSeed::Account { path: #name.to_string() } - }, - PdaSeedDef::Arg(name) => quote! { - nssa_framework_core::idl::IdlSeed::Arg { path: #name.to_string() } - }, - }) - .collect(); - - quote! { - Some(nssa_framework_core::idl::IdlPda { - seeds: vec![#(#seed_literals),*], - }) - } - }; - - quote! { - nssa_framework_core::idl::IdlAccountItem { - name: #acc_name.to_string(), - writable: #writable, - signer: #signer, - init: #init, - owner: None, - pda: #pda_expr, - } - } - }) - .collect(); - - let arg_literals: Vec = ix - .args - .iter() - .map(|arg| { - 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 { - name: #arg_name.to_string(), - type_: nssa_framework_core::idl::IdlType::Primitive(#type_str.to_string()), - } - } - }) - .collect(); - - quote! { - nssa_framework_core::idl::IdlInstruction { - name: #ix_name.to_string(), - accounts: vec![#(#account_literals),*], - args: vec![#(#arg_literals),*], - } - } - }) - .collect(); - - quote! { - #[allow(dead_code)] - pub fn __program_idl() -> nssa_framework_core::idl::NssaIdl { - nssa_framework_core::idl::NssaIdl { - version: "0.1.0".to_string(), - name: #program_name.to_string(), - instructions: vec![#(#instruction_literals),*], - accounts: vec![], - types: vec![], - errors: vec![], - } - } - } -} - -// ─── IDL generation (JSON string, for PROGRAM_IDL_JSON const) ──────────── - -fn generate_idl_json(mod_name: &Ident, instructions: &[InstructionInfo]) -> String { - let program_name = mod_name.to_string(); - - let instructions_json: Vec = instructions - .iter() - .map(|ix| { - let ix_name = &ix.fn_name.to_string(); - - let accounts_json: Vec = ix - .accounts - .iter() - .map(|acc| { - let name = acc.name.to_string(); - let writable = acc.constraints.mutable; - let signer = acc.constraints.signer; - let init = acc.constraints.init; - - let pda_json = if acc.constraints.pda_seeds.is_empty() { - String::new() - } else { - let seeds: Vec = acc - .constraints - .pda_seeds - .iter() - .map(|seed| match seed { - PdaSeedDef::Const(val) => { - format!("{{\"kind\":\"const\",\"value\":\"{}\"}}", val) - } - PdaSeedDef::Account(name) => { - format!("{{\"kind\":\"account\",\"path\":\"{}\"}}", name) - } - PdaSeedDef::Arg(name) => { - format!("{{\"kind\":\"arg\",\"path\":\"{}\"}}", name) - } - }) - .collect(); - format!(",\"pda\":{{\"seeds\":[{}]}}", seeds.join(",")) - }; - - format!( - "{{\"name\":\"{}\",\"writable\":{},\"signer\":{},\"init\":{}{}}}", - name, writable, signer, init, pda_json - ) - }) - .collect(); - - let args_json: Vec = ix - .args - .iter() - .map(|arg| { - let name = arg.name.to_string(); - let type_json = rust_type_to_idl_json(&arg.ty); - format!("{{\"name\":\"{}\",\"type\":{}}}", name, type_json) - }) - .collect(); - - format!( - "{{\"name\":\"{}\",\"accounts\":[{}],\"args\":[{}]}}", - ix_name, - accounts_json.join(","), - args_json.join(",") - ) - }) - .collect(); - - format!( - "{{\"version\":\"0.1.0\",\"name\":\"{}\",\"instructions\":[{}],\"accounts\":[],\"types\":[],\"errors\":[]}}", - program_name, - instructions_json.join(",") - ) -} - -// ─── generate_idl! macro implementation ────────────────────────────────── - -fn expand_generate_idl(file_path: &str, span_token: &syn::LitStr) -> syn::Result { - // Try the path as-is first, then relative to CARGO_MANIFEST_DIR - let resolved_path = if std::path::Path::new(file_path).exists() { - file_path.to_string() - } else if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { - let p = std::path::Path::new(&manifest_dir).join(file_path); - p.to_string_lossy().to_string() - } else { - file_path.to_string() - }; - - // Read the source file - let content = std::fs::read_to_string(&resolved_path).map_err(|e| { - syn::Error::new_spanned( - span_token, - format!("Failed to read '{}' (resolved: '{}'): {}", file_path, resolved_path, e), - ) - })?; - - // Parse as a Rust file - let file = syn::parse_file(&content).map_err(|e| { - syn::Error::new_spanned( - span_token, - format!("Failed to parse '{}': {}", file_path, e), - ) - })?; - - // Find the #[nssa_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")) { - program_mod = Some(m); - break; - } - } - } - - let program_mod = program_mod.ok_or_else(|| { - syn::Error::new_spanned( - span_token, - format!( - "No #[nssa_program] module found in '{}'", - file_path - ), - ) - })?; - - 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") - })?; - - // Parse instructions - 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(syn::Error::new_spanned( - span_token, - "No #[instruction] functions found in the program module", - )); - } - - // Generate the IDL JSON - let idl_json = generate_idl_json(mod_name, &instructions); - - // Embed the resolved path for cargo tracking - let resolved = resolved_path.clone(); - - // Generate a main() that pretty-prints the IDL - Ok(quote! { - fn main() { - // Help cargo track source changes - const _SOURCE: &str = include_str!(#resolved); - let json: serde_json::Value = serde_json::from_str(#idl_json) - .expect("Generated IDL JSON is invalid"); - println!("{}", serde_json::to_string_pretty(&json).unwrap()); - } - }) -} 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..afe4a019 --- /dev/null +++ b/scripts/smoke-test-privacy.sh @@ -0,0 +1,329 @@ +#!/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] +# +# Required Environment Variables: +# LEZ_TAG - LEZ revision/tag to test against (e.g., "v0.2.0-rc1" or a commit hash) +# LSSA_DIR - Path to logos-execution-zone directory with sequencer built +# +# Optional Environment Variables: +# WORK_DIR - Working directory (default: /tmp/spel-privacy-smoke) +# SEQUENCER_PORT - Sequencer port (default: 3040) +# SPEL_TAG - SPEL revision for init (defaults to current repo state) +# WALLET_PASSWORD - Wallet password (default: test) + +set -euo pipefail + +export RISC0_DEV_MODE=1 +export RISC0_SKIP_BUILD=1 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK_DIR="${1:-${WORK_DIR:-/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" + +# LEZ_TAG is required - no default to prevent testing against wrong version +if [ -z "${LEZ_TAG:-}" ]; then + echo "ERROR: LEZ_TAG environment variable is required" + echo "Usage: LEZ_TAG= LSSA_DIR= ./smoke-test-privacy.sh [WORK_DIR]" + exit 1 +fi + +# SPEL_TAG defaults to current local state (for local testing) or can be set explicitly +SPEL_TAG="${SPEL_TAG:-local}" +SPEL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +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 cargo >/dev/null 2>&1 || fail "cargo not found" + +# LSSA_DIR is required +if [ -z "${LSSA_DIR:-}" ]; then + echo "ERROR: LSSA_DIR environment variable is required" + echo "Usage: LEZ_TAG= LSSA_DIR= ./smoke-test-privacy.sh [WORK_DIR]" + exit 1 +fi + +LSSA_DIR="$(cd "$LSSA_DIR" && pwd)" + +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 + log "Found wallet binary: $candidate" + 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}" + +# ─── Verify LSSA version matches LEZ_TAG ────────────────────────────────── + +log "Verifying LSSA is at LEZ tag: ${LEZ_TAG}..." +cd "$LSSA_DIR" + +LSSA_CURRENT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") +LSSA_CURRENT_SHORT="${LSSA_CURRENT:0:7}" + +# Check if LEZ_TAG is a tag or commit hash +if git rev-parse "$LEZ_TAG" >/dev/null 2>&1; then + LEZ_RESOLVED=$(git rev-parse "$LEZ_TAG" 2>/dev/null) + LEZ_RESOLVED_SHORT="${LEZ_RESOLVED:0:7}" + + if [ "$LSSA_CURRENT" != "$LEZ_RESOLVED" ]; then + warn "LSSA is at commit ${LSSA_CURRENT_SHORT}, but LEZ_TAG specifies ${LEZ_RESOLVED_SHORT}" + warn "This may cause version mismatches. Consider checking out the correct version:" + warn " cd $LSSA_DIR && git checkout $LEZ_TAG" + else + log " ✓ LSSA version matches LEZ_TAG (${LSSA_CURRENT_SHORT})" + fi +else + warn "Could not resolve LEZ_TAG '${LEZ_TAG}' in local repo" + warn "LSSA is currently at: ${LSSA_CURRENT_SHORT}" +fi + +cd "$SCRIPT_DIR" + +# ─── Setup ───────────────────────────────────────────────────────────────── + +log "Setting up in ${WORK_DIR}..." +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR" "$LOG_DIR" +cd "$WORK_DIR" + +# Build local spel-cli from this repo +log "Building local spel-cli from ${SPEL_DIR}..." +cargo build --manifest-path "$SPEL_DIR/Cargo.toml" -p spel --release \ + > "$LOG_DIR/spel-build.log" 2>&1 || fail "Failed to build local spel-cli (see $LOG_DIR/spel-build.log)" +SPEL_BIN="$SPEL_DIR/target/release/spel" +[ -x "$SPEL_BIN" ] || fail "spel binary not found at $SPEL_BIN" +log " Using local spel: $SPEL_BIN" + +# ─── Step 1: Scaffold project ────────────────────────────────────────────── + +log "Step 1: Creating SPEL project (LEZ=${LEZ_TAG})..." +"$SPEL_BIN" init --lez-tag "$LEZ_TAG" --spel-rev "$SPEL_TAG" "$PROJECT_NAME" \ + > "$LOG_DIR/init.log" 2>&1 || fail "spel init failed (see $LOG_DIR/init.log)" +cd "$PROJECT_NAME" +log " ✓ Project scaffolded" + +# Regenerate lockfiles so the patch takes effect +(cd methods/guest && cargo generate-lockfile > "$LOG_DIR/guest-lockfile.log" 2>&1) \ + || warn "Guest lockfile regeneration failed" +cargo generate-lockfile > "$LOG_DIR/root-lockfile.log" 2>&1 \ + || warn "Root lockfile regeneration failed" + +# Print the actual LEZ version resolved +log " LEZ nssa_core resolved:" +grep -A2 'name = "nssa_core"' methods/guest/Cargo.lock 2>/dev/null | head -5 || true + +# ─── 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, signer)] + 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, Claim::Authorized) + } 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 at $SEQ_CONFIGS" + +cd "$LSSA_DIR" +RUST_LOG=info $SEQUENCER_BIN "$SEQ_CONFIGS" > "$LOG_DIR/sequencer.log" 2>&1 & +SEQ_PID=$! +sleep 2 +if ! kill -0 $SEQ_PID 2>/dev/null; then + echo "❌ Sequencer failed to start. Logs:" + cat "$LOG_DIR/sequencer.log" | tail -30 + exit 1 +fi +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'; echo $?) -eq 0 ]; then + log " ✓ Sequencer up"; break + fi + kill -0 "$SEQ_PID" 2>/dev/null || fail "Sequencer died" + echo -n "." + sleep 1 +done + +# Wait for first block to be produced before proceeding +log " Waiting for first block..." +for i in $(seq 1 60); do + curl -sf -X POST "$SEQUENCER_URL" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"getLastBlockId","params":[],"id":1}' 2>/dev/null + + SUCCESS=$? + if [ $SUCCESS -eq 0 ]; then + log " ✓ Sequencer producing blocks"; + break + fi + sleep 2 + echo -n "." +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=$(echo "$WALLET_PASSWORD" | $WALLET_BIN account new public 2>&1 | grep -o "Public/[^ ]*" | head -1) +[ -n "$FRESH_ACCOUNT" ] || fail "Could not create public account from wallet" +log " Fresh account: ${FRESH_ACCOUNT:0:20}..." + +SEQUENCER_URL="$SEQUENCER_URL" "$SPEL_BIN" --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_BIN" --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/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..a2c2ae39 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,23 @@ +# SonarCloud Configuration for logos-co/spel +# https://sonarcloud.io/docs/sonarcloud-analysis/ + +sonar.projectKey=logos-co_spel +sonar.organization=logos-co + +# Source directories +sonar.sources=spel-framework,spel-framework-core,spel-framework-macros,spel-cli,spel-client-gen + +# Test directories +sonar.tests=tests + +# Language +sonar.language=rust + +# Rust-specific configuration +sonar.rust.cargo.manifestPath=Cargo.toml + +# Coverage exclusions +sonar.coverage.exclusions=**/tests/**,**/test_*/**,**/examples/** + +# General exclusions +sonar.exclusions=**/target/**,**/.git/**,**/node_modules/**,**/docs/**,**/LICENSE-**,**/CHANGELOG.md,**/README.md,**/SPEC.md \ No newline at end of file diff --git a/spel-cli/Cargo.toml b/spel-cli/Cargo.toml new file mode 100644 index 00000000..533e542e --- /dev/null +++ b/spel-cli/Cargo.toml @@ -0,0 +1,28 @@ +[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", tag = "v0.2.0-rc1" } +nssa = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc1" } +common = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc1" } +sequencer_service_rpc = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc1", features = ["client"] } +wallet = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc1" } +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" +toml = "0.8" + +[dev-dependencies] +tempfile = "3" diff --git a/spel-cli/src/account_inspect.rs b/spel-cli/src/account_inspect.rs new file mode 100644 index 00000000..3ddb6773 --- /dev/null +++ b/spel-cli/src/account_inspect.rs @@ -0,0 +1,279 @@ +//! Account data inspection: fetch from sequencer, borsh-decode using IDL types, +//! and pretty-print as JSON. + +use serde_json::{json, Value}; +use spel_framework_core::idl::{IdlEnumVariant, IdlField, IdlType, IdlTypeDef, SpelIdl}; +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_) + .or_else(|| idl.types.iter().find(|t| t.name == name)) +} + +// ── 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" | "account_id" => { + // ProgramId / AccountId 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 63% rename from nssa-framework-cli/src/cli.rs rename to spel-cli/src/cli.rs index 90bf5bee..2eb03129 100644 --- a/nssa-framework-cli/src/cli.rs +++ b/spel-cli/src/cli.rs @@ -1,33 +1,55 @@ //! CLI helpers: help text, argument parsing, string utilities. +use spel_framework_core::idl::{IdlInstruction, IdlType, SpelIdl}; use std::collections::HashMap; -use nssa_framework_core::idl::{IdlType, IdlInstruction, NssaIdl}; /// 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:"); - println!(" {} [OPTIONS] [ARGS]", binary_name); + println!( + " {} [ARGS] (with spel.toml)", + binary_name + ); + println!( + " {} [OPTIONS] -- [ARGS] (without spel.toml)", + binary_name + ); println!(); println!("OPTIONS:"); - println!(" -i, --idl IDL JSON file"); - println!(" -p, --program Program binary"); + println!(" -i, --idl IDL JSON file (or set in spel.toml)"); + println!(" -p, --program "); + println!(" Program name from spel.toml, 64-char hex program ID,"); + println!(" or path to program binary (or set in spel.toml)"); println!(" --dry-run Print parsed/serialized data without submitting"); - println!(" --bin- Additional program binary (auto-fills ---program-id)"); + println!( + " --bin- Additional program binary (auto-fills ---program-id)" + ); 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 { let cmd = snake_to_kebab(&ix.name); - let args_desc: Vec = ix.args.iter() - .map(|a| format!("--{} <{}>", snake_to_kebab(&a.name), idl_type_hint(&a.type_))) + let args_desc: Vec = ix + .args + .iter() + .map(|a| { + format!( + "--{} <{}>", + snake_to_kebab(&a.name), + idl_type_hint(&a.type_) + ) + }) .collect(); - let acct_desc: Vec = ix.accounts.iter() + 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(" ")); @@ -39,32 +61,66 @@ pub fn print_help(idl: &NssaIdl, binary_name: &str) { println!(" [u32; 8] / program_id Comma-separated u32s: \"0,0,0,0,0,0,0,0\""); println!(" Vec<[u8; 32]> Comma-separated hex strings: \"aabb...00,ccdd...00\""); println!(); + println!("CONFIG:"); + println!(" Create a spel.toml in your project root to avoid passing --idl and --program:"); + println!(" [program]"); + println!(" idl = \"my-project-idl.json\""); + println!(" binary = \"path/to/program.bin\""); + println!(); println!("Auto-generated from IDL. Accounts marked as PDA are computed automatically."); } /// Print detailed help for a single instruction. pub fn print_instruction_help(ix: &IdlInstruction) { - println!("📋 {} — {} account(s), {} arg(s)", ix.name, ix.accounts.len(), ix.args.len()); + println!( + "📋 {} — {} account(s), {} arg(s)", + ix.name, + ix.accounts.len(), + ix.args.len() + ); println!(); println!("ACCOUNTS:"); for acc in &ix.accounts { let mut flags = vec![]; - if acc.writable { flags.push("mut"); } - if acc.signer { flags.push("signer"); } - if acc.init { flags.push("init"); } - let flags_str = if flags.is_empty() { String::new() } else { format!(" [{}]", flags.join(", ")) }; - let pda_note = if acc.pda.is_some() { " (PDA — auto-computed)" } else { "" }; + if acc.writable { + flags.push("mut"); + } + if acc.signer { + flags.push("signer"); + } + if acc.init { + flags.push("init"); + } + let flags_str = if flags.is_empty() { + String::new() + } else { + format!(" [{}]", flags.join(", ")) + }; + let pda_note = if acc.pda.is_some() { + " (PDA — auto-computed)" + } else { + "" + }; println!(" {}{}{}", acc.name, flags_str, pda_note); } println!(); println!("ARGS:"); for arg in &ix.args { - println!(" --{:<25} {} ({}) — format: {}", - snake_to_kebab(&arg.name), arg.name, idl_type_display(&arg.type_), idl_type_hint(&arg.type_)); + println!( + " --{:<25} {} ({}) — format: {}", + snake_to_kebab(&arg.name), + arg.name, + idl_type_display(&arg.type_), + idl_type_hint(&arg.type_) + ); } 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/config.rs b/spel-cli/src/config.rs new file mode 100644 index 00000000..e61f5cc5 --- /dev/null +++ b/spel-cli/src/config.rs @@ -0,0 +1,341 @@ +//! `spel.toml` config file discovery and parsing. + +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +const CONFIG_FILENAME: &str = "spel.toml"; + +#[derive(Debug, Deserialize)] +pub struct SpelConfig { + /// Single program shorthand: `[program]` + pub program: Option, + /// Named programs: `[programs.]` + pub programs: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProgramConfig { + pub idl: Option, + pub binary: Option, +} + +impl SpelConfig { + /// Walk up from `start_dir` looking for `spel.toml`. + /// Returns `None` if no config file is found. + pub fn discover(start_dir: &Path) -> Option<(PathBuf, SpelConfig)> { + let mut dir = start_dir.to_path_buf(); + loop { + let candidate = dir.join(CONFIG_FILENAME); + if candidate.is_file() { + match Self::load(&candidate) { + Ok(config) => return Some((candidate, config)), + Err(e) => { + eprintln!("⚠️ Error reading {}: {}", candidate.display(), e); + return None; + } + } + } + if !dir.pop() { + return None; + } + } + } + + /// Load and parse a `spel.toml` file at the given path. + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?; + let config: SpelConfig = toml::from_str(&content) + .map_err(|e| format!("invalid TOML in '{}': {}", path.display(), e))?; + config.validate(path)?; + Ok(config) + } + + /// Check that `[program]` and `[programs]` are not both present. + fn validate(&self, path: &Path) -> Result<(), String> { + let has_program = self.program.is_some(); + let has_programs = self.programs.as_ref().is_some_and(|p| !p.is_empty()); + if has_program && has_programs { + return Err(format!( + "invalid config in '{}': [program] and [programs] are mutually exclusive. \ + Use [program] for single-program projects or [programs.] for multi-program.", + path.display() + )); + } + Ok(()) + } + + /// Resolve which program config to use. + /// + /// - `name = Some("x")` → look up `[programs.x]` + /// - `name = None` + `[program]` exists → use it + /// - `name = None` + exactly one `[programs.x]` → use it + /// - `name = None` + multiple `[programs]` → error + pub fn resolve_program(&self, name: Option<&str>) -> Result<&ProgramConfig, String> { + if let Some(name) = name { + // Explicit name: must be in [programs.] + if let Some(programs) = &self.programs { + if let Some(cfg) = programs.get(name) { + return Ok(cfg); + } + let available: Vec<&str> = programs.keys().map(|s| s.as_str()).collect(); + return Err(format!( + "program '{}' not found in spel.toml. Available: {}", + name, + available.join(", ") + )); + } + return Err(format!( + "program '{}' not found: spel.toml has no [programs] section", + name + )); + } + + // No name given: auto-resolve + if let Some(ref cfg) = self.program { + return Ok(cfg); + } + if let Some(programs) = &self.programs { + if programs.len() == 1 { + return Ok(programs.values().next().unwrap()); + } + if programs.is_empty() { + return Err("spel.toml has no programs defined".to_string()); + } + let available: Vec<&str> = programs.keys().map(|s| s.as_str()).collect(); + return Err(format!( + "multiple programs in spel.toml — specify one with --program . Available: {}", + available.join(", ") + )); + } + Err("spel.toml has no [program] or [programs] section".to_string()) + } + + /// Check if a name matches a program entry in the config. + pub fn has_program(&self, name: &str) -> bool { + self.programs.as_ref().is_some_and(|p| p.contains_key(name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parse_single_program() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[program] +idl = "my-project-idl.json" +binary = "target/my_project.bin" +"#, + ) + .unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + let prog = config.resolve_program(None).unwrap(); + assert_eq!(prog.idl.as_deref(), Some("my-project-idl.json")); + assert_eq!(prog.binary.as_deref(), Some("target/my_project.bin")); + } + + #[test] + fn parse_multi_program() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[programs.game] +idl = "game-idl.json" +binary = "target/game.bin" + +[programs.nft] +idl = "nft-idl.json" +binary = "target/nft.bin" +"#, + ) + .unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + + let game = config.resolve_program(Some("game")).unwrap(); + assert_eq!(game.idl.as_deref(), Some("game-idl.json")); + + let nft = config.resolve_program(Some("nft")).unwrap(); + assert_eq!(nft.idl.as_deref(), Some("nft-idl.json")); + } + + #[test] + fn multi_program_auto_select_single() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[programs.only_one] +idl = "only.json" +binary = "only.bin" +"#, + ) + .unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + let prog = config.resolve_program(None).unwrap(); + assert_eq!(prog.idl.as_deref(), Some("only.json")); + } + + #[test] + fn multi_program_no_name_errors() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[programs.a] +idl = "a.json" + +[programs.b] +idl = "b.json" +"#, + ) + .unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + let err = config.resolve_program(None).unwrap_err(); + assert!(err.contains("--program ")); + assert!(err.contains("a")); + assert!(err.contains("b")); + } + + #[test] + fn unknown_program_name_errors() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[programs.game] +idl = "game.json" +"#, + ) + .unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + let err = config.resolve_program(Some("nope")).unwrap_err(); + assert!(err.contains("nope")); + assert!(err.contains("game")); + } + + #[test] + fn mutual_exclusion_errors() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[program] +idl = "single.json" + +[programs.multi] +idl = "multi.json" +"#, + ) + .unwrap(); + + let err = SpelConfig::load(&config_path).unwrap_err(); + assert!(err.contains("mutually exclusive")); + } + + #[test] + fn has_program_check() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[programs.game] +idl = "game.json" +"#, + ) + .unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + assert!(config.has_program("game")); + assert!(!config.has_program("nope")); + } + + #[test] + fn parse_empty_config() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write(&config_path, "").unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + assert!(config.resolve_program(None).is_err()); + } + + #[test] + fn parse_partial_config() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[program] +idl = "foo.json" +"#, + ) + .unwrap(); + + let config = SpelConfig::load(&config_path).unwrap(); + let prog = config.resolve_program(None).unwrap(); + assert_eq!(prog.idl.as_deref(), Some("foo.json")); + assert_eq!(prog.binary.as_deref(), None); + } + + #[test] + fn parse_malformed_toml_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write(&config_path, "this is not valid toml [[[").unwrap(); + + let result = SpelConfig::load(&config_path); + assert!(result.is_err()); + } + + #[test] + fn discover_walks_up() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("spel.toml"); + fs::write( + &config_path, + r#" +[program] +idl = "found.json" +"#, + ) + .unwrap(); + + let subdir = dir.path().join("sub/deep"); + fs::create_dir_all(&subdir).unwrap(); + + let result = SpelConfig::discover(&subdir); + assert!(result.is_some()); + let (path, config) = result.unwrap(); + assert_eq!(path, config_path); + let prog = config.resolve_program(None).unwrap(); + assert_eq!(prog.idl.as_deref(), Some("found.json")); + } + + #[test] + fn discover_returns_none_when_not_found() { + let dir = tempfile::tempdir().unwrap(); + let result = SpelConfig::discover(dir.path()); + assert!(result.is_none()); + } +} diff --git a/spel-cli/src/generate_idl.rs b/spel-cli/src/generate_idl.rs new file mode 100644 index 00000000..c0733d5d --- /dev/null +++ b/spel-cli/src/generate_idl.rs @@ -0,0 +1,295 @@ +//! 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..a8548a75 --- /dev/null +++ b/spel-cli/src/hex.rs @@ -0,0 +1,113 @@ +//! 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 58% rename from nssa-framework-cli/src/init.rs rename to spel-cli/src/init.rs index 985e7330..83d4c8a4 100644 --- a/nssa-framework-cli/src/init.rs +++ b/spel-cli/src/init.rs @@ -1,18 +1,34 @@ -//! Project scaffolding: `nssa-cli init ` +//! Project scaffolding: `spel init ` use std::fs; use std::path::Path; -pub fn init_project(name: &str) { +pub fn init_project( + name: &str, + lez_tag: Option<&str>, + spel_tag: Option<&str>, + lez_rev: Option<&str>, + spel_rev: Option<&str>, +) { let root = Path::new(name); if root.exists() { eprintln!("❌ Directory '{}' already exists", name); 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); + }); - let snake_name = name.replace('-', "_"); + println!("🚀 Creating SPEL project '{}'...", project_name); + + let snake_name = project_name.replace('-', "_"); // Create directories let dirs = [ @@ -31,7 +47,11 @@ pub fn init_project(name: &str) { } // Root Cargo.toml (workspace) - write_file(root, "Cargo.toml", &format!(r#"[workspace] + write_file( + root, + "Cargo.toml", + &format!( + r#"[workspace] members = [ "{snake_name}_core", "methods", @@ -41,18 +61,42 @@ exclude = [ "methods/guest", ] resolver = "2" -"#)); +"# + ), + ); // .gitignore - write_file(root, ".gitignore", &format!(r#"target/ + write_file( + root, + ".gitignore", + &format!( + r#"target/ methods/guest/target/ *.bin .{snake_name}-state .{snake_name}-state.tmp -"#)); +"# + ), + ); + + // spel.toml + write_file( + root, + "spel.toml", + &format!( + r#"[program] +idl = "{project_name}-idl.json" +binary = "methods/guest/target/riscv32im-risc0-zkvm-elf/docker/{snake_name}.bin" +"# + ), + ); // 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 +105,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,11 +121,11 @@ 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" - @echo " make cli ARGS= Run the IDL-driven CLI (pass args via ARGS=)" + @echo " make cli ARGS= Run the IDL-driven CLI (reads spel.toml for config)" @echo " make deploy Deploy program to sequencer" @echo " make setup Create accounts needed for the program" @echo " make inspect Show ProgramId for built binary" @@ -91,7 +135,7 @@ help: ## Show this help @echo "Example:" @echo " make build idl deploy" @echo " make cli ARGS=\"--help\"" - @echo " make cli ARGS=\"-p $(PROGRAM_BIN) --arg1 value1\"" + @echo " make cli ARGS=\" --arg1 value1\"" build: ## Build the guest binary cargo risczero build --manifest-path methods/guest/Cargo.toml @@ -104,7 +148,7 @@ idl: ## Generate IDL JSON from program source @echo "✅ IDL written to $(IDL_FILE)" cli: ## Run the IDL-driven CLI (ARGS="...") - cargo run --bin {snake_name}_cli -- -i $(IDL_FILE) $(ARGS) + cargo run --bin {snake_name}_cli -- $(ARGS) deploy: ## Deploy program to sequencer @test -f "$(PROGRAM_BIN)" || (echo "ERROR: Binary not found. Run 'make build' first."; exit 1) @@ -112,7 +156,7 @@ deploy: ## Deploy program to sequencer @echo "✅ Program deployed" inspect: ## Show ProgramId for built binary - cargo run --bin {snake_name}_cli -- -i $(IDL_FILE) inspect $(PROGRAM_BIN) + cargo run --bin {snake_name}_cli -- inspect $(PROGRAM_BIN) setup: ## Create accounts needed for the program @echo "Creating signer account..." @@ -123,7 +167,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 "" @@ -136,12 +180,18 @@ status: ## Show saved state and binary info clean: ## Remove saved state rm -f $(STATE_FILE) $(STATE_FILE).tmp @echo "✅ State cleaned" -"#)); +"# + ), + ); // 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 +205,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 @@ -164,13 +214,11 @@ make deploy # 4. See available commands (auto-generated from your program) make cli ARGS="--help" -# 5. Run an instruction -make cli ARGS="-p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/{snake_name}.bin \\ - --arg1 value1 --arg2 value2" +# 5. Run an instruction (spel.toml provides IDL and binary paths) +make cli ARGS=" --arg1 value1 --arg2 value2" # Dry run (no submission): -make cli ARGS="--dry-run -p methods/guest/target/riscv32im-risc0-zkvm-elf/docker/{snake_name}.bin \\ - --arg1 value1" +make cli ARGS="--dry-run -- --arg1 value1" ``` ## Make Targets @@ -189,7 +237,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/ @@ -199,13 +247,14 @@ make cli ARGS="--dry-run -p methods/guest/target/riscv32im-risc0-zkvm-elf/docker │ └── src/bin/ │ ├── generate_idl.rs # One-liner IDL generator │ └── {snake_name}_cli.rs # Three-line CLI wrapper +├── spel.toml # SPEL CLI config (IDL and binary paths) ├── 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 @@ -213,10 +262,16 @@ The framework automatically: 3. **Provides a full CLI** for building, inspecting, and submitting transactions You write the program logic. The framework handles the rest. -"#)); +"# + ), + ); // program_core - write_file(root, &format!("{}_core/Cargo.toml", snake_name), &format!(r#"[package] + write_file( + root, + &format!("{}_core/Cargo.toml", snake_name), + &format!( + r#"[package] name = "{snake_name}_core" version = "0.1.0" edition = "2021" @@ -224,9 +279,15 @@ 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}; +"# + ), + ); + + write_file( + root, + &format!("{}_core/src/lib.rs", snake_name), + r#"use serde::{Deserialize, Serialize}; /// Example state struct — customize for your program. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -234,34 +295,63 @@ pub struct ProgramState { pub initialized: bool, pub owner: [u8; 32], } -"#); +"#, + ); // methods/Cargo.toml - write_file(root, "methods/Cargo.toml", &format!(r#"[package] + write_file( + root, + "methods/Cargo.toml", + &format!( + r#"[package] name = "{snake_name}-methods" 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" }} -"#)); +"# + ), + ); // methods/build.rs - write_file(root, "methods/build.rs", r#"fn main() { + write_file( + root, + "methods/build.rs", + r#"fn main() { risc0_build::embed_methods(); } -"#); +"#, + ); // methods/src/lib.rs - write_file(root, "methods/src/lib.rs", r#"include!(concat!(env!("OUT_DIR"), "/methods.rs")); -"#); - + write_file( + root, + "methods/src/lib.rs", + r#"include!(concat!(env!("OUT_DIR"), "/methods.rs")); +"#, + ); + + let lez_ref = match (lez_tag, lez_rev) { + (Some(t), _) => format!("tag = \"{}\"", t), + (_, Some(r)) => format!("rev = \"{}\"", r), + _ => "tag = \"v0.2.0-rc1\"".to_string(), + }; + let spel_ref = match (spel_tag, spel_rev) { + (Some(t), _) => format!("tag = \"{}\"", t), + (_, Some(r)) => format!("rev = \"{}\"", r), + _ => "tag = \"v0.2.0-rc.1\"".to_string(), + }; // methods/guest/Cargo.toml - write_file(root, "methods/guest/Cargo.toml", &format!(r#"[package] + write_file( + root, + "methods/guest/Cargo.toml", + &format!( + r#"[package] name = "{snake_name}-guest" version = "0.1.0" edition = "2021" @@ -273,25 +363,29 @@ 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", {spel_ref} }} +nssa_core = {{ git = "https://github.com/logos-blockchain/logos-execution-zone.git", {lez_ref} }} +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] + 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,12 +397,9 @@ mod {snake_name} {{ state: AccountWithMetadata, #[account(signer)] owner: AccountWithMetadata, - ) -> NssaResult {{ + ) -> SpelResult {{ // TODO: implement initialization logic - Ok(NssaOutput::states_only(vec![ - AccountPostState::new_claimed(state.account.clone()), - AccountPostState::new(owner.account.clone()), - ])) + Ok(SpelOutput::execute(vec![state, owner], vec![])) }} /// Example instruction — replace with your own. @@ -319,18 +410,21 @@ mod {snake_name} {{ #[account(signer)] owner: AccountWithMetadata, amount: u64, - ) -> NssaResult {{ + ) -> SpelResult {{ // TODO: implement your logic - Ok(NssaOutput::states_only(vec![ - AccountPostState::new(state.account.clone()), - AccountPostState::new(owner.account.clone()), - ])) + Ok(SpelOutput::execute(vec![state, owner], vec![])) }} }} -"#)); +"# + ), + ); // examples/Cargo.toml - write_file(root, "examples/Cargo.toml", &format!(r#"[package] + write_file( + root, + "examples/Cargo.toml", + &format!( + r#"[package] name = "{snake_name}-examples" version = "0.1.0" edition = "2021" @@ -344,36 +438,67 @@ 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", {spel_ref} }} +nssa_core = {{ git = "https://github.com/logos-blockchain/logos-execution-zone.git", {lez_ref} }} +spel = {{ git = "https://github.com/logos-co/spel.git", {spel_ref} }} {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] + 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 + ), + } + + println!("✅ Project '{}' created!", project_name); println!(); println!("Next steps:"); println!(" cd {}", name); - println!(" # Edit methods/guest/src/bin/{}.rs with your program logic", snake_name); + println!( + " # Edit methods/guest/src/bin/{}.rs with your program logic", + snake_name + ); println!(" # Edit {}_core/src/lib.rs with your types", snake_name); println!(" make idl # Generate the IDL"); println!(" make cli ARGS=\"--help\" # See available commands"); 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..edd8b59a 100644 --- a/nssa-framework-cli/src/inspect.rs +++ b/spel-cli/src/inspect.rs @@ -1,13 +1,13 @@ //! Binary inspection — extract ProgramId from ELF binaries. -use nssa::program::Program; use crate::hex::hex_encode; +use nssa::program::Program; 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..6e6f2196 --- /dev/null +++ b/spel-cli/src/lib.rs @@ -0,0 +1,708 @@ +//! 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 account_inspect; +pub mod cli; +pub mod config; +pub mod generate_idl; +pub mod hex; +pub mod init; +pub mod inspect; +pub mod parse; +pub mod pda; +pub mod serialize; +pub mod tx; + +use cli::{parse_instruction_args, print_help, snake_to_kebab}; +use config::SpelConfig; +use init::init_project; +use inspect::inspect_binaries; +use parse::ParsedValue; +use pda::compute_pda_from_seeds; +use spel_framework_core::idl::{IdlSeed, SpelIdl}; +use std::collections::HashMap; +use std::{env, fs, process}; +use tx::execute_instruction; + +/// 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_ref: Option = None; // raw --program value + let mut dry_run_format: Option = None; + 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 used_separator = false; + let mut i = 1; + + while i < args.len() { + match args[i].as_str() { + "--" => { + // Everything after `--` is passed through as instruction args + used_separator = true; + remaining_args.extend_from_slice(&args[i + 1..]); + break; + } + "--idl" | "-i" => { + i += 1; + if i < args.len() { + idl_path = args[i].clone(); + } + } + "--program" | "-p" => { + i += 1; + if i < args.len() { + program_ref = Some(args[i].clone()); + } + } + "--program-id" => { + eprintln!("⚠️ --program-id is deprecated. Use --program instead."); + i += 1; + if i < args.len() { + program_ref = 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_format = Some("text".to_string()); + } + s if s.starts_with("--dry-run=") => { + let fmt = s.strip_prefix("--dry-run=").unwrap(); + match fmt { + "text" | "json" => { + dry_run_format = Some(fmt.to_string()); + } + _ => { + eprintln!( + "❌ Unknown --dry-run format '{}'. Use 'text' or 'json'.", + fmt + ); + process::exit(1); + } + } + } + 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; + } + + // Load spel.toml config + let config = env::current_dir() + .ok() + .and_then(|cwd| SpelConfig::discover(&cwd)); + let has_config = config.is_some(); + + // Resolve --program value: config name → 64-char hex → file path + let mut program_path: Option = None; + let mut program_id_hex: Option = None; + + if let Some(ref value) = program_ref { + let resolved_from_config = config.as_ref().and_then(|(_, cfg)| { + if cfg.has_program(value) { + cfg.resolve_program(Some(value)).ok() + } else { + None + } + }); + + if let Some(prog) = resolved_from_config { + // Config name → set both IDL and binary from config entry + if idl_path.is_empty() { + if let Some(ref idl) = prog.idl { + idl_path = idl.clone(); + } + } + program_path = prog.binary.clone(); + } else if is_hex_program_id(value) { + program_id_hex = Some(value.clone()); + } else { + program_path = Some(value.clone()); + } + } + + // Fill gaps from config default program (when --program not given or didn't resolve IDL) + if let Some((_, ref cfg)) = config { + if program_ref.is_none() { + if let Ok(prog) = cfg.resolve_program(None) { + if idl_path.is_empty() { + if let Some(ref idl) = prog.idl { + idl_path = idl.clone(); + } + } + if program_path.is_none() { + program_path = prog.binary.clone(); + } + } + } + } + + // Handle commands that don't need an IDL + if let Some(cmd) = remaining_args.get(1).map(|s| s.as_str()) { + match cmd { + "init" => { + // Check for help flag + if remaining_args.get(2) == Some(&"-h".to_string()) + || remaining_args.get(2) == Some(&"--help".to_string()) + { + println!("Usage: spel init [OPTIONS]"); + println!(); + println!("Create a new SPEL project"); + println!(); + println!("Options:"); + println!(" --lez-tag LEZ version tag (default: v0.2.0-rc1)"); + println!(" --spel-rev SPEL revision (default: refs/pull/122/head)"); + println!(" --lez-rev LEZ revision (alternative to --lez-tag)"); + println!(" --spel-tag SPEL tag (alternative to --spel-rev)"); + println!(); + println!("Examples:"); + println!(" spel init my-project"); + println!( + " spel init my-project --lez-tag v0.2.0-rc1 --spel-rev refs/pull/122/head" + ); + return; + } + let mut lez_tag: Option = None; + let mut spel_tag: Option = None; + let mut lez_rev: Option = None; + let mut spel_rev: Option = None; + let mut name_arg_idx = 2; + + while name_arg_idx < remaining_args.len() { + let arg = &remaining_args[name_arg_idx]; + if arg == "--lez-tag" { + name_arg_idx += 1; + if name_arg_idx < remaining_args.len() { + lez_tag = Some(remaining_args[name_arg_idx].clone()); + } + } else if arg == "--spel-tag" { + name_arg_idx += 1; + if name_arg_idx < remaining_args.len() { + spel_tag = Some(remaining_args[name_arg_idx].clone()); + } + } else if arg == "--lez-rev" { + name_arg_idx += 1; + if name_arg_idx < remaining_args.len() { + lez_rev = Some(remaining_args[name_arg_idx].clone()); + } + } else if arg == "--spel-rev" { + name_arg_idx += 1; + if name_arg_idx < remaining_args.len() { + spel_rev = Some(remaining_args[name_arg_idx].clone()); + } + } else { + break; + } + name_arg_idx += 1; + } + + let name = remaining_args.get(name_arg_idx).unwrap_or_else(|| { + eprintln!("Usage: {} init [--lez-tag ] [--spel-tag ] [--lez-rev ] [--spel-rev ]", args[0]); + process::exit(1); + }); + init_project( + name, + lez_tag.as_deref(), + spel_tag.as_deref(), + lez_rev.as_deref(), + spel_rev.as_deref(), + ); + 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 generate_idl::discover_sources; + use spel_framework_core::idl_gen::generate_idl_from_file; + + 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).is_some_and(|s| !s.starts_with("--")) => + { + // Raw PDA mode: no IDL needed + // Triggered when --program resolves to a program ID + pda command + // Usage: --program 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: {} [OPTIONS] -- [ARGS]", args[0]); + eprintln!(); + eprintln!( + "Tip: create a spel.toml with [program] or [programs.] to avoid passing flags." + ); + 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 [SEED...] Compute arbitrary PDA (no IDL needed)"); + eprintln!("For all other commands, provide an IDL file via --idl or spel.toml."); + 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.as_deref(), + 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) => { + if !used_separator && !has_config { + eprintln!("⚠️ Deprecation: mixing CLI and instruction args without '--' separator."); + eprintln!(" Consider adding a spel.toml to your project, or use:"); + eprintln!(" spel --idl -- --arg1 value1"); + eprintln!(); + } + let cli_args = parse_instruction_args(&remaining_args[2..], ix); + execute_instruction( + &idl, + ix, + &cli_args, + program_path.as_deref(), + program_id_hex.as_deref(), + dry_run_format.as_deref(), + &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: Option<&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 and its owning instruction + let found = idl.instructions.iter().find_map(|ix| { + ix.accounts + .iter() + .find(|acc| acc.name == account_name || snake_to_kebab(&acc.name) == account_name) + .and_then(|acc| acc.pda.as_ref().map(|pda| (ix, pda))) + }); + + let (owning_ix, pda_def) = match found { + Some(pair) => pair, + 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); + } + }; + + // Build a map from arg name to IDL type using the owning instruction's args + let arg_types: HashMap<&str, &spel_framework_core::idl::IdlType> = owning_ix + .args + .iter() + .map(|a| (a.name.as_str(), &a.type_)) + .collect(); + + // Parse --key value pairs from remaining args, using IDL types when available + 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]; + let arg_name = key.replace('-', "_"); + let parsed = if let Some(ty) = arg_types.get(arg_name.as_str()) { + parse::parse_value(raw, ty).unwrap_or_else(|e| { + eprintln!( + "⚠️ Failed to parse --{} as {}: {}", + key, + format!("{:?}", ty), + e + ); + ParsedValue::Str(raw.clone()) + }) + } else { + ParsedValue::Str(raw.clone()) + }; + seed_args.insert(arg_name, parsed); + 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 crate::hex::decode_bytes_32; + use nssa::program::Program; + + 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 let Some(path) = program_path { + if std::path::Path::new(path).exists() { + let program_bytes = std::fs::read(path).unwrap_or_else(|e| { + eprintln!("❌ Cannot read program binary '{}': {}", 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 binary not found: {}", path); + std::process::exit(1); + } + } else { + eprintln!("❌ Program ID required to compute PDA."); + eprintln!(" Pass --program (from spel.toml)"); + eprintln!(" Or --program <64-char-hex> (program ID)"); + 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::AccountId; + use nssa_core::program::{PdaSeed, ProgramId}; + + // 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); +} + +/// Check if a string is a 64-character hex program ID. +fn is_hex_program_id(s: &str) -> bool { + s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) +} diff --git a/nssa-framework-cli/src/parse.rs b/spel-cli/src/parse.rs similarity index 58% rename from nssa-framework-cli/src/parse.rs rename to spel-cli/src/parse.rs index 8cc80922..ceb4a8f4 100644 --- a/nssa-framework-cli/src/parse.rs +++ b/spel-cli/src/parse.rs @@ -1,7 +1,7 @@ //! IDL type-aware value parsing from CLI strings. -use nssa_framework_core::idl::IdlType; use crate::hex::{hex_decode, hex_encode}; +use spel_framework_core::idl::IdlType; /// A parsed CLI value with type information preserved. #[derive(Debug, Clone)] @@ -43,7 +43,10 @@ impl std::fmt::Display for ParsedValue { write!(f, "[{}]", strs.join(", ")) } ParsedValue::ByteArrayVec(vecs) => { - let strs: Vec = vecs.iter().map(|v| format!("0x{}", hex_encode(v))).collect(); + let strs: Vec = vecs + .iter() + .map(|v| format!("0x{}", hex_encode(v))) + .collect(); write!(f, "[{}]", strs.join(", ")) } ParsedValue::None => write!(f, "None"), @@ -72,10 +75,22 @@ pub fn parse_value(raw: &str, ty: &IdlType) -> Result { fn parse_primitive(raw: &str, prim: &str) -> Result { match prim { - "u8" => raw.parse::().map(ParsedValue::U8).map_err(|e| format!("Invalid u8 '{}': {}", raw, e)), - "u32" => raw.parse::().map(ParsedValue::U32).map_err(|e| format!("Invalid u32 '{}': {}", raw, e)), - "u64" => raw.parse::().map(ParsedValue::U64).map_err(|e| format!("Invalid u64 '{}': {}", raw, e)), - "u128" => raw.parse::().map(ParsedValue::U128).map_err(|e| format!("Invalid u128 '{}': {}", raw, e)), + "u8" => raw + .parse::() + .map(ParsedValue::U8) + .map_err(|e| format!("Invalid u8 '{}': {}", raw, e)), + "u32" => raw + .parse::() + .map(ParsedValue::U32) + .map_err(|e| format!("Invalid u32 '{}': {}", raw, e)), + "u64" => raw + .parse::() + .map(ParsedValue::U64) + .map_err(|e| format!("Invalid u64 '{}': {}", raw, e)), + "u128" => raw + .parse::() + .map(ParsedValue::U128) + .map_err(|e| format!("Invalid u128 '{}': {}", raw, e)), "program_id" => parse_program_id(raw), "bool" => match raw { "true" | "1" | "yes" => Ok(ParsedValue::Bool(true)), @@ -111,7 +126,10 @@ fn parse_program_id(raw: &str) -> Result { } Ok(ParsedValue::U32Array(vals)) } else { - Err(format!("Invalid ProgramId '{}': expected 8 comma-separated u32s or 64 hex chars", raw)) + Err(format!( + "Invalid ProgramId '{}': expected 8 comma-separated u32s or 64 hex chars", + raw + )) } } @@ -128,13 +146,23 @@ fn parse_array(raw: &str, elem_type: &IdlType, size: usize) -> Result size { - return Err(format!("String '{}' is {} bytes, max {} for [u8; {}]", raw, str_bytes.len(), size, size)); + return Err(format!( + "String '{}' is {} bytes, max {} for [u8; {}]", + raw, + str_bytes.len(), + size, + size + )); } let mut bytes = vec![0u8; size]; bytes[..str_bytes.len()].copy_from_slice(str_bytes); @@ -148,7 +176,10 @@ fn parse_array(raw: &str, elem_type: &IdlType, size: usize) -> Result().map_err(|e| format!("Invalid u32 '{}': {}", p, e))?); + vals.push( + p.parse::() + .map_err(|e| format!("Invalid u32 '{}': {}", p, e))?, + ); } Ok(ParsedValue::U32Array(vals)) } @@ -172,10 +203,20 @@ fn parse_vec(raw: &str, elem_type: &IdlType) -> Result { .map_err(|e| format!("Element [{}]: {}", i, e))?; result.push(bytes.to_vec()); } else { - let hex = part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")).unwrap_or(part); - let bytes = hex_decode(hex).map_err(|e| format!("Element [{}]: {}", i, e))?; + let hex = part + .strip_prefix("0x") + .or_else(|| part.strip_prefix("0X")) + .unwrap_or(part); + let bytes = + hex_decode(hex).map_err(|e| format!("Element [{}]: {}", i, e))?; if bytes.len() != size { - return Err(format!("Element [{}]: expected {} bytes, got {} from '{}'", i, size, bytes.len(), part)); + return Err(format!( + "Element [{}]: expected {} bytes, got {} from '{}'", + i, + size, + bytes.len(), + part + )); } result.push(bytes); } @@ -184,6 +225,73 @@ 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())), } } + +#[cfg(test)] +mod tests { + use super::*; + use spel_framework_core::idl::IdlType; + + #[test] + fn pda_seed_from_bytes32_arg() { + // Simulate what happens when IDL has a [u8; 32] arg used as a PDA seed: + // 1. IDL declares arg type as Array { array: (Primitive("u8"), 32) } + // 2. User passes hex string on CLI + // 3. parse_value should produce ParsedValue::ByteArray(...) + // 4. PDA resolver should extract the 32 bytes as seed material + + let idl_type = IdlType::Array { + array: (Box::new(IdlType::Primitive("u8".to_string())), 32), + }; + + let hex_input = "4343434343434343434343434343434343434343434343434343434343434343"; + let parsed = parse_value(hex_input, &idl_type).expect("should parse [u8; 32] from hex"); + + // Must be ByteArray, not Raw — Raw causes PDA computation to fail + match &parsed { + ParsedValue::ByteArray(bytes) => { + assert_eq!(bytes.len(), 32); + assert_eq!(bytes[0], 0x43); + } + other => panic!("expected ByteArray, got {:?}", other), + } + } + + #[test] + fn primitive_bytes32_string_does_not_parse_as_byte_array() { + // This is the bug: the macro emits Primitive("[u8; 32]") which + // falls through to Raw instead of being parsed as a byte array. + // This test documents the broken behavior that the macro fix addresses. + let buggy_type = IdlType::Primitive("[u8; 32]".to_string()); + + let hex_input = "4343434343434343434343434343434343434343434343434343434343434343"; + let parsed = parse_value(hex_input, &buggy_type).expect("should not error"); + + // With Primitive("[u8; 32]"), parse_primitive doesn't recognize it → Raw + assert!( + matches!(&parsed, ParsedValue::Raw(_)), + "Primitive('[u8; 32]') should fall through to Raw, got {:?}", + parsed + ); + } +} diff --git a/spel-cli/src/pda.rs b/spel-cli/src/pda.rs new file mode 100644 index 00000000..4ab001c1 --- /dev/null +++ b/spel-cli/src/pda.rs @@ -0,0 +1,235 @@ +//! PDA (Program Derived Address) computation from IDL seed definitions. + +use crate::parse::ParsedValue; +use nssa::AccountId; +use nssa_core::program::{PdaSeed, ProgramId}; +use spel_framework_core::idl::IdlSeed; +use std::collections::HashMap; + +/// 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/spel-cli/src/serialize.rs b/spel-cli/src/serialize.rs new file mode 100644 index 00000000..68cce848 --- /dev/null +++ b/spel-cli/src/serialize.rs @@ -0,0 +1,335 @@ +//! risc0-compatible serialization for IDL instruction data. + +use crate::parse::ParsedValue; +use spel_framework_core::idl::IdlType; + +/// Serialize an instruction to risc0 serde format (Vec). +/// +/// Produces: variant_index (u32), then each field serialized in order. +/// Matches `risc0_zkvm::serde::to_vec` for an enum struct variant. +pub fn serialize_to_risc0( + variant_index: u32, + parsed_args: &[(&IdlType, &ParsedValue)], +) -> Vec { + let mut out = vec![variant_index]; + for (ty, val) in parsed_args { + serialize_value_risc0(&mut out, ty, val); + } + out +} + +fn serialize_value_risc0(out: &mut Vec, ty: &IdlType, val: &ParsedValue) { + match (ty, val) { + (IdlType::Primitive(p), _) => serialize_primitive_risc0(out, p.as_str(), val), + (IdlType::Array { array }, _) => serialize_array_risc0(out, &array.0, array.1, val), + (IdlType::Vec { vec }, _) => serialize_vec_risc0(out, vec, val), + (IdlType::Option { option: _ }, ParsedValue::None) => { + out.push(0); + } + (IdlType::Option { option }, ParsedValue::Some(inner)) => { + out.push(1); + serialize_value_risc0(out, option, inner); + } + (IdlType::Option { option }, _) => { + out.push(1); + serialize_value_risc0(out, option, val); + } + _ => { + eprintln!( + "⚠️ Cannot serialize Defined/Raw type in risc0 format: {:?}", + val + ); + } + } +} + +fn serialize_primitive_risc0(out: &mut Vec, prim: &str, val: &ParsedValue) { + match (prim, val) { + ("bool", ParsedValue::Bool(b)) => out.push(if *b { 1 } else { 0 }), + ("u8", ParsedValue::U8(v)) => out.push(*v as u32), + ("u32", ParsedValue::U32(v)) => out.push(*v), + ("u64", ParsedValue::U64(v)) => { + out.push(*v as u32); + out.push((*v >> 32) as u32); + } + ("u128", ParsedValue::U128(v)) => { + let bytes = v.to_le_bytes(); + for chunk in bytes.chunks(4) { + out.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + } + ("program_id", ParsedValue::U32Array(vals)) => { + for v in vals { + out.push(*v); + } + } + ("string" | "String", ParsedValue::Str(s)) => { + let bytes = s.as_bytes(); + out.push(bytes.len() as u32); + serialize_bytes_padded(out, bytes); + } + _ => { + eprintln!( + "⚠️ Type mismatch in risc0 serialization: prim={}, val={:?}", + prim, val + ); + } + } +} + +fn serialize_array_risc0(out: &mut Vec, elem_type: &IdlType, _size: usize, val: &ParsedValue) { + match (elem_type, val) { + (IdlType::Primitive(p), ParsedValue::ByteArray(bytes)) if p == "u8" => { + for b in bytes { + out.push(*b as u32); + } + } + (IdlType::Primitive(p), ParsedValue::U32Array(vals)) if p == "u32" => { + for v in vals { + out.push(*v); + } + } + _ => { + eprintln!("⚠️ Cannot serialize array type in risc0 format: {:?}", val); + } + } +} + +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 { + IdlType::Primitive(p) if p == "u8" => { + for v in vecs { + for b in v { + out.push(*b as u32); + } + } + } + _ => { + eprintln!("⚠️ Cannot serialize Vec element type in risc0 format"); + } + } + } + _ => { + eprintln!("⚠️ Cannot serialize Vec type in risc0 format: {:?}", val); + } + } +} + +fn serialize_bytes_padded(out: &mut Vec, bytes: &[u8]) { + let mut i = 0; + while i < bytes.len() { + let remaining = bytes.len() - i; + let mut word_bytes = [0u8; 4]; + let take = remaining.min(4); + word_bytes[..take].copy_from_slice(&bytes[i..i + take]); + out.push(u32::from_le_bytes(word_bytes)); + i += 4; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::parse_value; + use risc0_zkvm::serde::Deserializer; + use serde::Deserialize; + use spel_framework_core::idl::IdlType; + + #[test] + fn serialize_bytes32_one_word_per_byte() { + // risc0 serde format: each u8 is its own u32 word (zero-extended). + // A [u8; 32] produces 32 u32 words, NOT 8 packed words. + let idl_type = IdlType::Array { + array: (Box::new(IdlType::Primitive("u8".to_string())), 32), + }; + + let parsed = parse_value( + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + &idl_type, + ) + .unwrap(); + + let words = serialize_to_risc0(0, &[(&idl_type, &parsed)]); + + // words[0] = variant index, words[1..33] = 32 individual u8-as-u32 words + let payload = &words[1..]; + assert_eq!(payload.len(), 32, "expected 32 u32 words for [u8; 32]"); + assert_eq!(payload[0], 0x01); + assert_eq!(payload[1], 0x02); + assert_eq!(payload[31], 0x20); + } + + #[test] + fn serialize_vec_u8_one_word_per_byte() { + // Vec in risc0 serde: length prefix + one u32 word per byte. + let elem_type = IdlType::Primitive("u8".to_string()); + let idl_type = IdlType::Vec { + vec: Box::new(elem_type), + }; + + let bytes = ParsedValue::ByteArray(vec![0x3b, 0x50, 0x9c, 0x40]); + + let words = serialize_to_risc0(0, &[(&idl_type, &bytes)]); + + // words[0] = variant index, words[1] = length (4), words[2..6] = bytes as u32 + let payload = &words[1..]; + assert_eq!(payload[0], 4, "length prefix"); + assert_eq!(payload.len(), 5, "1 length + 4 bytes"); + assert_eq!(payload[1], 0x3b); + assert_eq!(payload[2], 0x50); + } + + #[test] + fn serialize_vec_byte_array_one_word_per_byte() { + // Vec<[u8; 4]>: vec length prefix, then each element's bytes as individual words. + let inner = IdlType::Array { + array: (Box::new(IdlType::Primitive("u8".to_string())), 4), + }; + let idl_type = IdlType::Vec { + vec: Box::new(inner), + }; + + let bytes = ParsedValue::ByteArrayVec(vec![ + vec![0x3b, 0x50, 0x9c, 0x40], + vec![0x61, 0x13, 0x01, 0xf7], + ]); + + let words = serialize_to_risc0(0, &[(&idl_type, &bytes)]); + + // words[0] = variant, words[1] = vec len (2), words[2..6] = elem0, words[6..10] = elem1 + let payload = &words[1..]; + assert_eq!(payload[0], 2, "vec length"); + assert_eq!(payload.len(), 9, "1 length + 2*4 bytes"); + assert_eq!(payload[1], 0x3b); + assert_eq!(payload[5], 0x61); + } + + /// Verify risc0's own serializer as the reference for [u8; 32] format. + #[test] + fn risc0_reference_bytes32_format() { + let seed: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + + #[derive(serde::Serialize)] + enum TestInstruction { + CommitRun { + seed: [u8; 32], + class: u8, + strength: u32, + }, + } + + let reference = risc0_zkvm::serde::to_vec(&TestInstruction::CommitRun { + seed, + class: 2, + strength: 42, + }) + .unwrap(); + + // Each u8 is its own u32 word (not packed) + // word[0] = variant index (0) + // word[1..33] = 32 u8 values, each as u32 + // word[33] = class (2) + // word[34] = strength (42) + assert_eq!( + reference.len(), + 35, + "expected 35 words: 1 variant + 32 seed + 1 class + 1 strength" + ); + assert_eq!(reference[0], 0, "variant index"); + assert_eq!(reference[1], 0x01, "seed[0]"); + assert_eq!(reference[2], 0x02, "seed[1]"); + assert_eq!(reference[33], 2, "class"); + assert_eq!(reference[34], 42, "strength"); + } + + /// Verifies spel CLI's serialization is compatible with the guest-side + /// Deserializer from risc0_zkvm (used by nssa_core::program::read_nssa_inputs). + /// This is the contract between the CLI (transaction sender) and the + /// on-chain program (transaction executor) at LEZ v0.2.0-rc1. + #[test] + fn serialize_deserialize_roundtrip_with_bytes32() { + #[derive(Deserialize, Debug, PartialEq)] + enum TestInstruction { + CommitRun { + seed: [u8; 32], + class: u8, + strength: u32, + }, + } + + // 1. Define IDL arg types matching the enum variant fields + let seed_type = IdlType::Array { + array: (Box::new(IdlType::Primitive("u8".into())), 32), + }; + let class_type = IdlType::Primitive("u8".into()); + let strength_type = IdlType::Primitive("u32".into()); + + // 2. Parse CLI values exactly as spel would + let seed_hex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + let parsed_seed = parse_value(seed_hex, &seed_type).unwrap(); + let parsed_class = parse_value("2", &class_type).unwrap(); + let parsed_strength = parse_value("42", &strength_type).unwrap(); + + // 3. Serialize to u32 words (variant_index=0 for CommitRun) + let words = serialize_to_risc0( + 0, + &[ + (&seed_type, &parsed_seed), + (&class_type, &parsed_class), + (&strength_type, &parsed_strength), + ], + ); + + // 4. Deserialize using risc0's Deserializer — the SAME code the guest runs + let instruction: TestInstruction = + TestInstruction::deserialize(&mut Deserializer::new(words.as_ref())) + .expect("guest-side deserialization must succeed"); + + // 5. Assert values survived the roundtrip + let expected_seed: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + assert_eq!( + instruction, + TestInstruction::CommitRun { + seed: expected_seed, + class: 2, + strength: 42, + } + ); + } +} diff --git a/spel-cli/src/tx.rs b/spel-cli/src/tx.rs new file mode 100644 index 00000000..f00e9fb5 --- /dev/null +++ b/spel-cli/src/tx.rs @@ -0,0 +1,683 @@ +//! Transaction building and submission. + +use crate::cli::{snake_to_kebab, to_pascal_case}; +use crate::hex::{decode_bytes_32, hex_encode, parse_account_id}; +use crate::parse::{parse_value, ParsedValue}; +use crate::pda::compute_pda_from_seeds; +use crate::serialize::serialize_to_risc0; +use common::transaction::NSSATransaction; +use hex; +use nssa::program::Program; +use nssa::public_transaction::{Message, WitnessSet}; +use nssa::{AccountId, PublicTransaction}; +use nssa_core::account::Nonce; +use nssa_core::program::ProgramId; +use sequencer_service_rpc::RpcClient as _; +use spel_framework_core::idl::{IdlInstruction, IdlSeed, SpelIdl}; +use std::collections::HashMap; +use std::fs; +use std::process; +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: Option<&str>, + program_id_hex: Option<&str>, + dry_run_format: Option<&str>, + 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); + + // ─── Resolve program_id (once) ──────────────────────────────── + 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 if let Some(path) = program_path { + let program_bytecode = fs::read(path).unwrap_or_else(|e| { + eprintln!("❌ Failed to read program binary '{}': {}", path, e); + eprintln!(" Hint: pass --program <64-char-hex> 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)) + } else { + eprintln!( + "❌ No program specified. Use --program or configure in spel.toml." + ); + process::exit(1); + }; + + let program_id_display = hex_encode( + &program_id + .iter() + .flat_map(|w| w.to_le_bytes()) + .collect::>(), + ); + + // 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) => { + 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) => { + account_map.insert(acc.name.clone(), id); + } + Err(e) => { + eprintln!("❌ Failed to compute PDA for '{}': {}", acc.name, e); + process::exit(1); + } + } + } + } + + // ─── Dry-run summary ──────────────────────────────────────── + if let Some(fmt) = dry_run_format { + // Build instruction data hex string + let ix_data_hex: String = instruction_data + .iter() + .flat_map(|w| w.to_le_bytes()) + .map(|b| format!("{:02x}", b)) + .collect(); + + // Try to fetch nonces (non-fatal if wallet unavailable) + let signer_names: Vec<&str> = ix + .accounts + .iter() + .filter(|a| a.signer) + .map(|a| a.name.as_str()) + .collect(); + let signer_nonces: HashMap> = { + let mut nonces_map = HashMap::new(); + if !signer_names.is_empty() { + if let Ok(wallet_core) = WalletCore::from_env() { + let signer_ids: Vec = signer_names + .iter() + .filter_map(|n| account_map.get(*n).copied()) + .collect(); + match wallet_core.get_accounts_nonces(signer_ids.clone()).await { + Ok(nonces) => { + for (name, &Nonce(n)) in signer_names.iter().zip(nonces.iter()) { + nonces_map.insert(name.to_string(), Some(n as u64)); + } + } + Err(_) => { + for name in &signer_names { + nonces_map.insert(name.to_string(), None); + } + } + } + } else { + for name in &signer_names { + nonces_map.insert(name.to_string(), None); + } + } + } + nonces_map + }; + + if fmt == "json" { + // JSON output + let mut accounts_json: Vec = Vec::new(); + for acc in &ix.accounts { + let id = account_map + .get(&acc.name) + .map(|a| format!("{}", a)) + .unwrap_or_else(|| "(unresolved)".to_string()); + let mut flags: Vec<&str> = Vec::new(); + if acc.signer { + flags.push("signer"); + } + if acc.writable { + flags.push("writable"); + } + let flags_str = flags + .iter() + .map(|f| format!("\"{}\"", f)) + .collect::>() + .join(", "); + + if let Some(pda) = &acc.pda { + let seeds: Vec = std::iter::once("\"program_id\"".to_string()) + .chain(pda.seeds.iter().map(|s| match s { + IdlSeed::Const { value } => format!("\"{}\"", value), + IdlSeed::Account { path } => format!("\"Account({})\"", path), + IdlSeed::Arg { path } => format!("\"Arg({})\"", path), + })) + .collect(); + accounts_json.push(format!( + " {{\"name\": \"{}\", \"id\": \"{}\", \"flags\": [{}], \"is_pda\": true, \"seeds\": [{}]}}", + acc.name, id, flags_str, seeds.join(", ") + )); + } else { + accounts_json.push(format!( + " {{\"name\": \"{}\", \"id\": \"{}\", \"flags\": [{}]}}", + acc.name, id, flags_str + )); + } + } + + let mut args_json_entries: Vec = Vec::new(); + for (name, _, val) in &parsed_args { + let val_str = match val { + ParsedValue::U8(n) => format!("{}", n), + ParsedValue::U32(n) => format!("{}", n), + ParsedValue::U64(n) => format!("{}", n), + ParsedValue::U128(n) => format!("{}", n), + _ => format!("\"{}\"", val), + }; + args_json_entries.push(format!(" \"{}\": {}", name, val_str)); + } + + let mut signers_json_entries: Vec = Vec::new(); + for name in &signer_names { + let nonce_str = match signer_nonces.get(*name) { + Some(Some(n)) => format!("{}", n), + _ => "null".to_string(), + }; + signers_json_entries + .push(format!(" \"{}\": {{\"nonce\": {}}}", name, nonce_str)); + } + + println!("{{"); + println!(" \"program_id\": \"{}\",", program_id_display); + println!(" \"accounts\": ["); + println!("{}", accounts_json.join(",\n")); + println!(" ],"); + println!(" \"arguments\": {{"); + println!("{}", args_json_entries.join(",\n")); + println!(" }},"); + println!(" \"instruction_data\": \"{}\",", ix_data_hex); + println!(" \"signers\": {{"); + println!("{}", signers_json_entries.join(",\n")); + println!(" }}"); + println!("}}"); + } else { + // Text output + println!("=== Dry Run ==="); + println!("Program ID: {}", program_id_display); + println!("Accounts:"); + for acc in &ix.accounts { + let id = account_map + .get(&acc.name) + .map(|a| format!("{}", a)) + .unwrap_or_else(|| "(unresolved)".to_string()); + let mut flags: Vec<&str> = Vec::new(); + if acc.signer { + flags.push("signer"); + } + if acc.writable { + flags.push("writable"); + } + + if let Some(pda) = &acc.pda { + let flags_str = if flags.is_empty() { + String::new() + } else { + format!(" [{}]", flags.join(", ")) + }; + println!(" PDA {} \u{2192} {}{}", acc.name, id, flags_str); + let seeds: Vec = std::iter::once("program_id".to_string()) + .chain(pda.seeds.iter().map(|s| match s { + IdlSeed::Const { value } => format!("\"{}\"", value), + IdlSeed::Account { path } => format!("Account({})", path), + IdlSeed::Arg { path } => format!("Arg({})", path), + })) + .collect(); + println!(" seeds: [{}]", seeds.join(", ")); + } else { + let flags_str = if flags.is_empty() { + String::new() + } else { + format!(" [{}]", flags.join(", ")) + }; + println!(" {} \u{2192} {}{}", acc.name, id, flags_str); + } + } + println!("Arguments:"); + for (name, _, val) in &parsed_args { + println!(" --{} {}", snake_to_kebab(name), val); + } + println!("Instruction data: {}", ix_data_hex); + if !signer_names.is_empty() { + println!("Signers:"); + for name in &signer_names { + let nonce_str = match signer_nonces.get(*name) { + Some(Some(n)) => format!("nonce={}", n), + _ => "nonce=(unknown)".to_string(), + }; + println!(" {}: {}", name, nonce_str); + } + } + println!("================"); + println!("Dry run complete \u{2014} not submitted."); + } + return; + } + + // ─── Normal display ───────────────────────────────────────── + println!("Accounts:"); + for acc in &ix.accounts { + if acc.pda.is_some() { + let id = account_map + .get(&acc.name) + .map(|a| format!("{}", a)) + .unwrap_or_default(); + println!(" \u{1f4e6} {} \u{2192} {} (PDA)", acc.name, id); + } else if acc.rest { + if let Some((_, entries)) = rest_accounts.iter().find(|(n, _)| *n == acc.name) { + if entries.is_empty() { + println!( + " \u{1f4e6} {} \u{2192} (none \u{2014} variadic rest)", + acc.name + ); + } else { + for (e, _) in entries { + println!(" \u{1f4e6} {} \u{2192} 0x{}", acc.name, hex_encode(e)); + } + } + } + } else { + let account_bytes = parsed_accounts + .iter() + .find(|(n, _, _)| *n == acc.name) + .unwrap(); + println!( + " \u{1f4e6} {} \u{2192} 0x{}", + acc.name, + hex_encode(&account_bytes.1) + ); + } + } + println!(); + println!("Arguments (parsed):"); + for (name, _, val) in &parsed_args { + println!(" {} = {}", name, val); + } + println!(); + println!("\u{1f527} Transaction:"); + println!(" Program ID: {}", program_id_display); + 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!(); + + // ─── Transaction submission ────────────────────────────────── + println!("\u{1f4e4} Submitting transaction..."); + println!(" Program ID: {:?}", program_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); + }); + + // 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 nssa::privacy_preserving_transaction::circuit::ProgramWithDependencies; + use wallet::PrivacyPreservingAccount; + + 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); + } + } + } +} 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..dc3dd451 --- /dev/null +++ b/spel-client-gen/src/codegen.rs @@ -0,0 +1,436 @@ +//! Typed Rust client generation from SPEL IDL. + +use crate::util::*; +use spel_framework_core::idl::*; +use std::collections::HashSet; +use std::fmt::Write; + +/// 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..1acea47e --- /dev/null +++ b/spel-client-gen/src/ffi_codegen.rs @@ -0,0 +1,670 @@ +//! 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 crate::util::*; +use spel_framework_core::idl::*; +use std::fmt::Write; + +/// 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..085ae73b --- /dev/null +++ b/spel-client-gen/src/lib.rs @@ -0,0 +1,54 @@ +//! # 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..fc9a470c --- /dev/null +++ b/spel-client-gen/src/main.rs @@ -0,0 +1,80 @@ +//! 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..bd4cde0c --- /dev/null +++ b/spel-client-gen/src/tests.rs @@ -0,0 +1,863 @@ +//! 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 crate::ffi_codegen::generate_pda_helpers; + use spel_framework_core::idl::*; + + 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 crate::ffi_codegen::generate_pda_helpers; + use spel_framework_core::idl::*; + + 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 crate::ffi_codegen::generate_pda_helpers; + use spel_framework_core::idl::*; + + // 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 crate::ffi_codegen::generate_pda_helpers; + use spel_framework_core::idl::*; + + // 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 crate::ffi_codegen::generate_pda_helpers; + use spel_framework_core::idl::*; + + // 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..dc12e7ba --- /dev/null +++ b/spel-client-gen/src/util.rs @@ -0,0 +1,123 @@ +//! 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..1cf9c645 --- /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", tag = "v0.2.0-rc1", 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 59% rename from nssa-framework-core/src/error.rs rename to spel-framework-core/src/error.rs index d5b970b6..53aa5a24 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,13 +33,10 @@ 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 { - expected: usize, - actual: usize, - }, + AccountCountMismatch { expected: usize, actual: usize }, /// Account is not owned by the expected program #[error("Account {account_index} has wrong owner: expected {expected_owner}")] @@ -50,22 +47,15 @@ pub enum NssaError { /// Account should be uninitialized but contains data #[error("Account {account_index} is already initialized")] - AccountAlreadyInitialized { - account_index: usize, - }, + AccountAlreadyInitialized { account_index: usize }, /// Account should be initialized but is empty/default #[error("Account {account_index} is not initialized")] - AccountNotInitialized { - account_index: usize, - }, + AccountNotInitialized { account_index: usize }, /// Insufficient balance for transfer or burn #[error("Insufficient balance: have {available}, need {requested}")] - InsufficientBalance { - available: u128, - requested: u128, - }, + InsufficientBalance { available: u128, requested: u128 }, /// Failed to deserialize account data #[error("Failed to deserialize account data at index {account_index}: {message}")] @@ -76,40 +66,33 @@ pub enum NssaError { /// Failed to serialize account data #[error("Failed to serialize data: {message}")] - SerializationError { - message: String, - }, + SerializationError { message: String }, /// Arithmetic overflow #[error("Arithmetic overflow: {operation}")] - Overflow { - operation: String, - }, + Overflow { operation: String }, /// Authorization failure #[error("Unauthorized: {message}")] - Unauthorized { - message: String, - }, + Unauthorized { message: String }, /// PDA derivation mismatch - #[error("PDA mismatch for account {account_index}")] + #[error("PDA mismatch for account '{account_name}': expected {expected}, got {actual}")] PdaMismatch { - account_index: usize, + account_name: String, + expected: String, + actual: String, }, /// Custom program-specific error with code and message #[error("Program error {code}: {message}")] - Custom { - code: u32, - message: String, - }, + Custom { code: u32, message: String }, } -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 +101,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 53% rename from nssa-framework-core/src/idl.rs rename to spel-framework-core/src/idl.rs index 42663db0..29c14fac 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,6 +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. @@ -90,8 +145,16 @@ pub struct IdlAccountType { } /// Type definition (struct or enum). +/// +/// When stored in [`SpelIdl::types`] the `name` field identifies the type so +/// the decoder can resolve `Defined { name }` references. When embedded inside +/// [`IdlAccountType`] the name is redundant (already on the wrapper) and is +/// left empty / skipped during serialisation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdlTypeDef { + /// Type name. Required when stored in `SpelIdl::types`; empty otherwise. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, pub kind: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fields: Vec, @@ -124,7 +187,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::{Digest, Sha256}; + 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 +208,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..dd1dad8a --- /dev/null +++ b/spel-framework-core/src/idl_gen.rs @@ -0,0 +1,1264 @@ +//! 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 std::collections::HashSet; + +use crate::idl::{ + IdlAccountItem, IdlAccountType, IdlArg, IdlEnumVariant, IdlField, IdlInstruction, IdlPda, + IdlSeed, IdlType, IdlTypeDef, 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(); + + let (accounts, types) = collect_account_types(&file.items); + + Ok(SpelIdl { + version: "0.1.0".to_string(), + name: mod_name, + instructions: idl_instructions, + accounts, + types, + 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()), + } +} + +// ─── Account type scanning ──────────────────────────────────────────────────── + +fn has_account_type_attr(attrs: &[Attribute]) -> bool { + attrs.iter().any(|a| a.path().is_ident("account_type")) +} + +/// Parse a named struct annotated with `#[account_type]` into an [`IdlAccountType`]. +/// Returns `None` for tuple / unit structs (no named fields to describe). +fn parse_struct_account_type(item: &syn::ItemStruct) -> Option { + let fields = if let syn::Fields::Named(named) = &item.fields { + named + .named + .iter() + .filter_map(|f| { + f.ident.as_ref().map(|ident| IdlField { + name: ident.to_string(), + type_: syn_type_to_idl_type(&f.ty), + }) + }) + .collect() + } else { + return None; + }; + Some(IdlAccountType { + name: item.ident.to_string(), + type_: IdlTypeDef { + name: String::new(), + kind: "struct".to_string(), + fields, + variants: vec![], + }, + }) +} + +/// Parse an enum annotated with `#[account_type]` into an [`IdlAccountType`]. +/// Only named-field variants are supported; tuple variants are emitted with no fields. +fn parse_enum_account_type(item: &syn::ItemEnum) -> IdlAccountType { + let variants = item + .variants + .iter() + .map(|v| { + let fields = if let syn::Fields::Named(named) = &v.fields { + named + .named + .iter() + .filter_map(|f| { + f.ident.as_ref().map(|ident| IdlField { + name: ident.to_string(), + type_: syn_type_to_idl_type(&f.ty), + }) + }) + .collect() + } else { + vec![] + }; + IdlEnumVariant { + name: v.ident.to_string(), + fields, + } + }) + .collect(); + IdlAccountType { + name: item.ident.to_string(), + type_: IdlTypeDef { + name: String::new(), + kind: "enum".to_string(), + fields: vec![], + variants, + }, + } +} + +/// Collect all `Defined { name }` type references that appear anywhere within a +/// type definition (fields of structs, fields of enum variants). +fn collect_defined_refs(type_def: &IdlTypeDef) -> Vec { + let mut refs = Vec::new(); + for field in &type_def.fields { + collect_defined_refs_from_type(&field.type_, &mut refs); + } + for variant in &type_def.variants { + for field in &variant.fields { + collect_defined_refs_from_type(&field.type_, &mut refs); + } + } + refs +} + +fn collect_defined_refs_from_type(ty: &IdlType, out: &mut Vec) { + match ty { + IdlType::Defined { defined } => out.push(defined.clone()), + IdlType::Vec { vec } => collect_defined_refs_from_type(vec, out), + IdlType::Option { option } => collect_defined_refs_from_type(option, out), + IdlType::Array { array: (inner, _) } => collect_defined_refs_from_type(inner, out), + IdlType::Primitive(_) => {} + } +} + +/// Look up a type by name in the top-level items of a file and parse it. +/// Returns `None` if not found or the item cannot be represented (e.g. tuple struct). +fn find_and_parse_type(items: &[syn::Item], name: &str) -> Option { + for item in items { + match item { + syn::Item::Struct(s) if s.ident == name => { + return parse_struct_account_type(s).map(|at| IdlTypeDef { + name: name.to_string(), + ..at.type_ + }); + } + syn::Item::Enum(e) if e.ident == name => { + let mut def = parse_enum_account_type(e).type_; + def.name = name.to_string(); + return Some(def); + } + _ => {} + } + } + None +} + +/// Scan `items` for `#[account_type]`-annotated types and return: +/// - `accounts`: directly annotated types (primary account data layouts) +/// - `types`: helper types referenced by account types but not themselves annotated +/// +/// Helper types are resolved transitively: if `Vault` references `VaultStatus` +/// and `VaultStatus` references `StatusFlags`, all three end up in the IDL. +fn collect_account_types(items: &[syn::Item]) -> (Vec, Vec) { + // Pass 1: collect directly annotated types. + let mut accounts: Vec = Vec::new(); + let mut annotated_names: HashSet = HashSet::new(); + + for item in items { + match item { + syn::Item::Struct(s) if has_account_type_attr(&s.attrs) => { + if let Some(at) = parse_struct_account_type(s) { + annotated_names.insert(at.name.clone()); + accounts.push(at); + } + } + syn::Item::Enum(e) if has_account_type_attr(&e.attrs) => { + let at = parse_enum_account_type(e); + annotated_names.insert(at.name.clone()); + accounts.push(at); + } + _ => {} + } + } + + // Pass 2: BFS over Defined-type references to collect helper types. + let mut helper_types: Vec = Vec::new(); + let mut visited: HashSet = annotated_names.clone(); + + let mut queue: Vec = accounts + .iter() + .flat_map(|a| collect_defined_refs(&a.type_)) + .filter(|n| !visited.contains(n)) + .collect::>() // dedup without extra alloc + .into_iter() + .collect(); + + while !queue.is_empty() { + let batch: Vec = queue.drain(..).collect(); + for name in batch { + if visited.contains(&name) { + continue; + } + visited.insert(name.clone()); + if let Some(def) = find_and_parse_type(items, &name) { + // Enqueue any new references from this helper type. + for ref_name in collect_defined_refs(&def) { + if !visited.contains(&ref_name) { + queue.push(ref_name); + } + } + helper_types.push(def); + } + // If the type isn't found in the file (e.g. it's from an external crate), + // leave it as an unresolved Defined reference in the IDL. The decoder will + // report a clear error if it encounters that reference at runtime. + } + } + + (accounts, helper_types) +} + +#[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")); + } + + // ── #[account_type] — basic discovery ───────────────────────────────────── + + #[test] + fn account_type_struct_included_in_accounts() { + let src = r#" + #[account_type] + pub struct VaultState { + pub owner: AccountId, + pub balance: u64, + } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.accounts.len(), 1); + assert_eq!(idl.accounts[0].name, "VaultState"); + assert_eq!(idl.accounts[0].type_.kind, "struct"); + assert_eq!(idl.accounts[0].type_.fields.len(), 2); + assert_eq!(idl.accounts[0].type_.fields[0].name, "owner"); + assert_eq!(idl.accounts[0].type_.fields[1].name, "balance"); + } + + #[test] + fn account_type_enum_included_in_accounts() { + let src = r#" + #[account_type] + pub enum TokenHolding { + Fungible { definition_id: AccountId, balance: u128 }, + NftMaster { definition_id: AccountId, print_balance: u128 }, + } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.accounts.len(), 1); + let def = &idl.accounts[0]; + assert_eq!(def.name, "TokenHolding"); + assert_eq!(def.type_.kind, "enum"); + assert_eq!(def.type_.variants.len(), 2); + assert_eq!(def.type_.variants[0].name, "Fungible"); + assert_eq!(def.type_.variants[0].fields.len(), 2); + assert_eq!(def.type_.variants[1].name, "NftMaster"); + } + + #[test] + fn unannotated_type_not_in_accounts() { + let src = r#" + pub struct NotAnAccountType { pub x: u64 } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert!(idl.accounts.is_empty()); + } + + #[test] + fn multiple_account_types_all_collected() { + let src = r#" + #[account_type] + pub struct DefinitionAccount { pub name: String } + + #[account_type] + pub enum HoldingAccount { Fungible { balance: u128 } } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.accounts.len(), 2); + let names: Vec<&str> = idl.accounts.iter().map(|a| a.name.as_str()).collect(); + assert!(names.contains(&"DefinitionAccount")); + assert!(names.contains(&"HoldingAccount")); + } + + // ── #[account_type] — referenced helper types ────────────────────────────── + + #[test] + fn referenced_helper_type_goes_into_types() { + let src = r#" + pub enum Status { Active, Inactive } + + #[account_type] + pub struct VaultState { + pub status: Status, + pub balance: u64, + } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.accounts.len(), 1); + assert_eq!(idl.types.len(), 1); + assert_eq!(idl.types[0].name, "Status"); + assert_eq!(idl.types[0].kind, "enum"); + assert_eq!(idl.types[0].variants.len(), 2); + } + + #[test] + fn annotated_type_not_duplicated_in_types() { + // If a type is itself annotated with #[account_type], it should not + // also appear in idl.types even if another account type references it. + let src = r#" + #[account_type] + pub enum Status { Active, Inactive } + + #[account_type] + pub struct VaultState { pub status: Status } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!( + idl.accounts.len(), + 2, + "both annotated types should be in accounts" + ); + assert!( + idl.types.is_empty(), + "annotated type should not also be in types" + ); + } + + #[test] + fn transitive_helper_type_resolved() { + // VaultState → Status → StatusFlags — all helper types should end up in types. + let src = r#" + pub enum StatusFlags { Flag1, Flag2 } + pub enum Status { Active(StatusFlags), Inactive } + + #[account_type] + pub struct VaultState { pub status: Status } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.accounts.len(), 1); + let type_names: Vec<&str> = idl.types.iter().map(|t| t.name.as_str()).collect(); + assert!(type_names.contains(&"Status"), "Status should be in types"); + // StatusFlags is referenced inside Status enum (tuple variant — not named fields), + // so it won't be extracted as a field. Verify at least Status is present. + assert_eq!(idl.types.iter().filter(|t| t.name == "Status").count(), 1); + } + + #[test] + fn external_defined_type_left_as_defined_ref() { + // AccountId is mapped to the primitive "account_id" by syn_type_to_idl_type, + // so it should NOT appear in idl.types as an unresolvable Defined reference. + let src = r#" + #[account_type] + pub struct HoldingAccount { + pub definition_id: AccountId, + pub balance: u128, + } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + assert_eq!(idl.accounts.len(), 1); + // AccountId → primitive "account_id", so no helper types needed + assert!(idl.types.is_empty()); + assert!( + matches!(&idl.accounts[0].type_.fields[0].type_, IdlType::Primitive(s) if s == "account_id") + ); + } + + #[test] + fn account_type_field_types_correctly_mapped() { + let src = r#" + #[account_type] + pub struct Everything { + pub a: u8, + pub b: u64, + pub c: u128, + pub d: bool, + pub e: String, + pub f: AccountId, + pub g: Option, + pub h: Vec, + } + + #[lez_program] + pub mod prog { + #[instruction] + pub fn init(acc: AccountWithMetadata) {} + } + "#; + let idl = ok(src); + let fields = &idl.accounts[0].type_.fields; + assert!(matches!(&fields[0].type_, IdlType::Primitive(s) if s == "u8")); + assert!(matches!(&fields[1].type_, IdlType::Primitive(s) if s == "u64")); + assert!(matches!(&fields[2].type_, IdlType::Primitive(s) if s == "u128")); + assert!(matches!(&fields[3].type_, IdlType::Primitive(s) if s == "bool")); + assert!(matches!(&fields[4].type_, IdlType::Primitive(s) if s == "string")); + assert!(matches!(&fields[5].type_, IdlType::Primitive(s) if s == "account_id")); + assert!(matches!(&fields[6].type_, IdlType::Option { .. })); + assert!(matches!(&fields[7].type_, IdlType::Vec { .. })); + } +} diff --git a/spel-framework-core/src/lib.rs b/spel-framework-core/src/lib.rs new file mode 100644 index 00000000..91a8fd21 --- /dev/null +++ b/spel-framework-core/src/lib.rs @@ -0,0 +1,22 @@ +//! # SPEL Framework Core +//! +//! Core types and traits for the SPEL program framework. + +pub mod error; +pub mod idl; +pub mod pda; +pub mod spel_output; +pub mod types; +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, compute_pda_multi, seed_from_str, ToSeed}; + pub use crate::spel_output::AutoClaim; + pub use crate::types::{AccountConstraint, IntoPostState, SpelOutput}; + pub use nssa_core::account::{Account, AccountWithMetadata}; + pub use nssa_core::program::{AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId}; +} diff --git a/spel-framework-core/src/pda.rs b/spel-framework-core/src/pda.rs new file mode 100644 index 00000000..7190df48 --- /dev/null +++ b/spel-framework-core/src/pda.rs @@ -0,0 +1,305 @@ +//! Generic PDA (Program Derived Address) computation utilities. + +use nssa_core::account::AccountId; +use nssa_core::program::{PdaSeed, ProgramId}; +use sha2::{Digest, Sha256}; + +/// Trait for converting a value into a 32-byte PDA seed. +/// +/// Provides type-specific conversions that are more predictable than +/// generic Borsh serialization. Each type uses its natural byte +/// representation, zero-padded to 32 bytes. +pub trait ToSeed { + /// Convert this value into a zero-padded 32-byte seed. + fn to_seed(&self) -> [u8; 32]; +} + +impl ToSeed for [u8; 32] { + fn to_seed(&self) -> [u8; 32] { + *self + } +} + +impl ToSeed for u64 { + fn to_seed(&self) -> [u8; 32] { + let mut seed = [0u8; 32]; + seed[..8].copy_from_slice(&self.to_le_bytes()); + seed + } +} + +impl ToSeed for u32 { + fn to_seed(&self) -> [u8; 32] { + let mut seed = [0u8; 32]; + seed[..4].copy_from_slice(&self.to_le_bytes()); + seed + } +} + +impl ToSeed for String { + fn to_seed(&self) -> [u8; 32] { + seed_from_str(self) + } +} + +impl ToSeed for &str { + fn to_seed(&self) -> [u8; 32] { + seed_from_str(self) + } +} + +/// 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)) +} + +/// Compute a PDA from a program ID and multiple [`ToSeed`] values. +/// +/// This is a convenience wrapper around [`compute_pda`] that accepts any +/// mix of types implementing `ToSeed` (e.g. `u64`, `u32`, `String`, `[u8; 32]`). +/// +/// # Panics +/// +/// Panics if `seeds` is empty. +pub fn compute_pda_multi(program_id: &ProgramId, seeds: &[&dyn ToSeed]) -> AccountId { + let converted: Vec<[u8; 32]> = seeds.iter().map(|s| s.to_seed()).collect(); + let refs: Vec<&[u8; 32]> = converted.iter().collect(); + compute_pda(program_id, &refs) +} + +#[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, &[]); + } + + // ── ToSeed trait tests ────────────────────────────────────────── + + #[test] + fn test_to_seed_u8_32_identity() { + let val = [42u8; 32]; + assert_eq!(val.to_seed(), val); + } + + #[test] + fn test_to_seed_u64() { + let val: u64 = 0x0102030405060708; + let seed = val.to_seed(); + assert_eq!(&seed[..8], &val.to_le_bytes()); + assert_eq!(&seed[8..], &[0u8; 24]); + } + + #[test] + fn test_to_seed_u32() { + let val: u32 = 0x01020304; + let seed = val.to_seed(); + assert_eq!(&seed[..4], &val.to_le_bytes()); + assert_eq!(&seed[4..], &[0u8; 28]); + } + + #[test] + fn test_to_seed_string() { + let val = String::from("hello"); + let seed = val.to_seed(); + assert_eq!(&seed[..5], b"hello"); + assert_eq!(&seed[5..], &[0u8; 27]); + } + + #[test] + fn test_to_seed_str() { + let seed = "hello".to_seed(); + assert_eq!(&seed[..5], b"hello"); + assert_eq!(&seed[5..], &[0u8; 27]); + } + + #[test] + fn test_to_seed_string_matches_seed_from_str() { + let s = "vault_prefix"; + assert_eq!(s.to_seed(), seed_from_str(s)); + assert_eq!(String::from(s).to_seed(), seed_from_str(s)); + } + + // ── compute_pda_multi tests ───────────────────────────────────── + + #[test] + fn test_compute_pda_multi_matches_compute_pda() { + let program_id: ProgramId = [1u32; 8]; + let seed1 = seed_from_str("config"); + let seed2 = [99u8; 32]; + + let from_compute = compute_pda(&program_id, &[&seed1, &seed2]); + let from_multi = compute_pda_multi(&program_id, &[&seed1, &seed2]); + assert_eq!(from_compute, from_multi); + } + + #[test] + fn test_compute_pda_multi_mixed_types() { + let program_id: ProgramId = [1u32; 8]; + let id: u64 = 42; + let label = String::from("vault"); + + let pda = compute_pda_multi(&program_id, &[&label, &id]); + + // Verify it matches manual computation + let seed1 = label.to_seed(); + let seed2 = id.to_seed(); + let expected = compute_pda(&program_id, &[&seed1, &seed2]); + assert_eq!(pda, expected); + } + + #[test] + fn test_compute_pda_multi_single_u64() { + let program_id: ProgramId = [1u32; 8]; + let val: u64 = 1000; + let pda = compute_pda_multi(&program_id, &[&val]); + + let seed = val.to_seed(); + let expected = compute_pda(&program_id, &[&seed]); + assert_eq!(pda, expected); + } + + #[test] + fn test_compute_pda_multi_three_seeds() { + let program_id: ProgramId = [1u32; 8]; + let prefix = "order"; + let user_id: u64 = 7; + let seq: u32 = 100; + + let pda = compute_pda_multi(&program_id, &[&prefix, &user_id, &seq]); + + let s1 = prefix.to_seed(); + let s2 = user_id.to_seed(); + let s3 = seq.to_seed(); + let expected = compute_pda(&program_id, &[&s1, &s2, &s3]); + assert_eq!(pda, expected); + } +} diff --git a/spel-framework-core/src/spel_output.rs b/spel-framework-core/src/spel_output.rs new file mode 100644 index 00000000..2c9cdffb --- /dev/null +++ b/spel-framework-core/src/spel_output.rs @@ -0,0 +1,252 @@ +//! Auto-claim logic for `SpelOutput::execute()`. +//! +//! [`AutoClaim`] wraps `nssa_core::program::Claim` with a `None` variant for +//! accounts that don't need claiming. [`SpelOutput::execute`] turns +//! `(Account, AutoClaim)` pairs into the correct `AccountPostState` values. + +use nssa_core::account::Account; +use nssa_core::program::{AccountPostState, ChainedCall, Claim, PdaSeed}; + +use crate::types::{IntoPostState, SpelOutput}; + +/// Describes the claim disposition for an account in a post-state. +/// +/// Wraps `nssa_core::program::Claim` with an additional `None` variant for +/// accounts that are mutable or read-only (no ownership claim). +/// +/// # Auto-claim rules (from `#[account(...)]` constraints) +/// +/// | Constraint | AutoClaim variant | +/// |-------------------------------------|----------------------------| +/// | `#[account(init, pda = ...)]` | `AutoClaim::Claimed(Claim::Pda(seed))` | +/// | `#[account(init, signer)]` | `AutoClaim::Claimed(Claim::Authorized)` | +/// | `#[account(init)]` (no pda/signer) | `AutoClaim::Claimed(Claim::Authorized)` | +/// | `#[account(mut)]` | `AutoClaim::None` | +/// | `#[account]` (read-only) | `AutoClaim::None` | +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AutoClaim { + /// The account should be claimed with the given [`Claim`] type. + Claimed(Claim), + /// The account is mutable or read-only — no claim is requested. + None, +} + +impl AutoClaim { + /// Returns `true` if this will result in `AccountPostState::new_claimed`. + #[must_use] + pub fn is_claimed(&self) -> bool { + matches!(self, AutoClaim::Claimed(_)) + } + + /// Convert an account and this auto-claim into an `AccountPostState`. + #[must_use] + pub fn to_post_state(&self, account: Account) -> AccountPostState { + match self { + AutoClaim::Claimed(claim) => AccountPostState::new_claimed(account, *claim), + AutoClaim::None => AccountPostState::new(account), + } + } + + /// Create an `AutoClaim` for a PDA-initialized account from raw seed bytes. + /// + /// The seed bytes are zero-padded to 32 bytes and wrapped in a `PdaSeed`. + pub fn pda_from_seeds(seeds: &[&[u8]]) -> Self { + // Combine seeds into a single PdaSeed using the same logic as compute_pda + let combined = if seeds.len() == 1 { + // Single seed: use raw 32 bytes (no padding), consistent with compute_pda + assert!( + seeds[0].len() == 32, + "pda_from_seeds: single seed must be 32 bytes" + ); + let mut buf = [0u8; 32]; + buf.copy_from_slice(seeds[0]); + buf + } else { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + for seed in seeds { + hasher.update(seed); + } + hasher.finalize().into() + }; + AutoClaim::Claimed(Claim::Pda(PdaSeed::new(combined))) + } +} + +// ── IntoPostState implementations ─────────────────────────────────────── + +impl IntoPostState for (Account, AutoClaim) { + fn into_post_state(self) -> AccountPostState { + self.1.to_post_state(self.0) + } +} + +impl IntoPostState for (Account, &AutoClaim) { + fn into_post_state(self) -> AccountPostState { + self.1.to_post_state(self.0) + } +} + +impl IntoPostState for AccountPostState { + fn into_post_state(self) -> AccountPostState { + self + } +} + +impl IntoPostState for Account { + fn into_post_state(self) -> AccountPostState { + AccountPostState::new(self) + } +} + +// ── SpelOutput::execute ───────────────────────────────────────────────── + +impl SpelOutput { + /// Build a `SpelOutput` from accounts paired with auto-claim metadata. + /// + /// Each item is converted to an `AccountPostState` via [`IntoPostState`]. + /// Accepts `Vec<(Account, AutoClaim)>`, `Vec`, or any + /// iterator of `impl IntoPostState`. + /// + /// # Examples + /// + /// ```rust,ignore + /// // Inside a handler generated by #[lez_program]: + /// Ok(SpelOutput::execute( + /// vec![ + /// (state.account.clone(), AutoClaim::Claimed(Claim::Authorized)), + /// (authority.account.clone(), AutoClaim::None), + /// ], + /// vec![], // chained calls + /// )) + /// ``` + pub fn execute( + accounts: impl IntoIterator, + calls: Vec, + ) -> Self { + let post_states = accounts + .into_iter() + .map(IntoPostState::into_post_state) + .collect(); + Self { + post_states, + chained_calls: calls, + } + } + + /// Build a `SpelOutput` by zipping a list of accounts with a matching + /// list of auto-claims. + /// + /// This is the primary method used by macro-generated `__claims_*()` helpers. + /// + /// # Panics + /// + /// Panics if `accounts` and `claims` have different lengths. + pub fn execute_with_claims( + accounts: &[Account], + claims: &[AutoClaim], + calls: Vec, + ) -> Self { + assert_eq!( + accounts.len(), + claims.len(), + "execute_with_claims: accounts.len() ({}) != claims.len() ({})", + accounts.len(), + claims.len(), + ); + let post_states = accounts + .iter() + .zip(claims.iter()) + .map(|(acc, claim)| claim.to_post_state(acc.clone())) + .collect(); + Self { + post_states, + chained_calls: calls, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_claim_is_claimed() { + assert!(AutoClaim::Claimed(Claim::Authorized).is_claimed()); + assert!(AutoClaim::Claimed(Claim::Pda(PdaSeed::new([0; 32]))).is_claimed()); + assert!(!AutoClaim::None.is_claimed()); + } + + #[test] + fn execute_auto_claims() { + let account_a = Account::default(); + let account_b = Account::default(); + + let output = SpelOutput::execute( + vec![ + (account_a.clone(), AutoClaim::Claimed(Claim::Authorized)), + (account_b.clone(), AutoClaim::None), + ], + vec![], + ); + + assert_eq!(output.post_states.len(), 2); + assert!(output.post_states[0].required_claim().is_some()); + assert!(output.post_states[1].required_claim().is_none()); + assert!(output.chained_calls.is_empty()); + } + + #[test] + fn execute_with_claims_zips_correctly() { + let accounts = vec![Account::default(), Account::default(), Account::default()]; + let claims = vec![ + AutoClaim::Claimed(Claim::Pda(PdaSeed::new([0; 32]))), + AutoClaim::Claimed(Claim::Authorized), + AutoClaim::None, + ]; + + let output = SpelOutput::execute_with_claims(&accounts, &claims, vec![]); + + assert_eq!(output.post_states.len(), 3); + assert!(output.post_states[0].required_claim().is_some()); + assert!(output.post_states[1].required_claim().is_some()); + assert!(output.post_states[2].required_claim().is_none()); + } + + #[test] + fn execute_empty() { + let output = SpelOutput::execute(Vec::<(Account, AutoClaim)>::new(), vec![]); + assert!(output.post_states.is_empty()); + assert!(output.chained_calls.is_empty()); + } + + #[test] + #[should_panic(expected = "accounts.len()")] + fn execute_with_claims_panics_on_mismatch() { + SpelOutput::execute_with_claims( + &[Account::default()], + &[AutoClaim::None, AutoClaim::None], + vec![], + ); + } + + #[test] + fn execute_accepts_raw_post_states() { + let ps = AccountPostState::new(Account::default()); + let output = SpelOutput::execute(vec![ps], vec![]); + assert_eq!(output.post_states.len(), 1); + assert!(output.post_states[0].required_claim().is_none()); + } + + #[test] + fn pda_from_seeds_single() { + let claim = AutoClaim::pda_from_seeds(&[&[0u8; 32]]); + assert!(claim.is_claimed()); + } + + #[test] + fn pda_from_seeds_multi() { + let claim = AutoClaim::pda_from_seeds(&[&[1u8; 32], &[2u8; 32]]); + assert!(claim.is_claimed()); + } +} diff --git a/nssa-framework-core/src/types.rs b/spel-framework-core/src/types.rs similarity index 77% rename from nssa-framework-core/src/types.rs rename to spel-framework-core/src/types.rs index d4cdb76a..b2384597 100644 --- a/nssa-framework-core/src/types.rs +++ b/spel-framework-core/src/types.rs @@ -1,19 +1,28 @@ -//! 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}; +/// Trait for types that can be converted into an [`AccountPostState`]. +/// +/// Implemented for `(Account, AutoClaim)`, `(Account, &AutoClaim)`, and +/// `AccountPostState` itself, so [`SpelOutput::execute`] accepts any of these. +pub trait IntoPostState { + fn into_post_state(self) -> AccountPostState; +} + /// 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. + #[deprecated(note = "Use SpelOutput::execute() for auto-claim support")] pub fn states_only(post_states: Vec) -> Self { Self { post_states, @@ -22,6 +31,7 @@ impl NssaOutput { } /// Create output with post-states and chained calls. + #[deprecated(note = "Use SpelOutput::execute() for auto-claim support")] pub fn with_chained_calls( post_states: Vec, chained_calls: Vec, diff --git a/nssa-framework-core/src/validation.rs b/spel-framework-core/src/validation.rs similarity index 83% rename from nssa-framework-core/src/validation.rs rename to spel-framework-core/src/validation.rs index 490658df..4e1a16bf 100644 --- a/nssa-framework-core/src/validation.rs +++ b/spel-framework-core/src/validation.rs @@ -3,16 +3,13 @@ //! 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> { +pub fn validate_account_count(actual: usize, expected: usize) -> Result<(), SpelError> { if actual != expected { - return Err(NssaError::AccountCountMismatch { expected, actual }); + return Err(SpelError::AccountCountMismatch { expected, actual }); } Ok(()) } @@ -21,7 +18,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,19 +32,19 @@ 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())?; - + // In a real implementation, we would also check: // - ownership constraints // - initialization state - // - signer verification + // - signer verification // - PDA derivation // // These require access to the actual AccountWithMetadata data, // which the proc-macro would pass in. - + Ok(()) } @@ -63,9 +60,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), }); @@ -73,7 +70,7 @@ pub fn verify_owner( Ok(()) } -// Note: hex is used for error display only. In production, +// Note: hex is used for error display only. In production, // consider base58 or the chain's preferred encoding. mod hex { pub fn encode(bytes: &[u8]) -> String { diff --git a/spel-framework-core/tests/custom_instruction.rs b/spel-framework-core/tests/custom_instruction.rs new file mode 100644 index 00000000..bc68b274 --- /dev/null +++ b/spel-framework-core/tests/custom_instruction.rs @@ -0,0 +1,58 @@ +//! 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")); + } + #[allow(deprecated)] + 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..842b72f5 --- /dev/null +++ b/spel-framework-core/tests/signer_validation.rs @@ -0,0 +1,129 @@ +//! 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..ad54687b --- /dev/null +++ b/spel-framework-core/tests/variable_accounts.rs @@ -0,0 +1,55 @@ +//! 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/spel-framework-macros/Cargo.toml b/spel-framework-macros/Cargo.toml new file mode 100644 index 00000000..ee55a8ce --- /dev/null +++ b/spel-framework-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "spel-framework-macros" +version = "0.2.0" +edition = "2021" +description = "Proc macros for the SPEL program framework" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full", "extra-traits", "visit-mut"] } +sha2 = "0.10" diff --git a/spel-framework-macros/src/lib.rs b/spel-framework-macros/src/lib.rs new file mode 100644 index 00000000..bbdbb4ae --- /dev/null +++ b/spel-framework-macros/src/lib.rs @@ -0,0 +1,1769 @@ +//! # SPEL Framework Proc Macros +//! +//! 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 spel_framework::prelude::*; +//! +//! #[lez_program] +//! mod my_program { +//! #[instruction] +//! pub fn create( +//! #[account(init, pda = const("my_state"))] +//! state: AccountWithMetadata, +//! name: String, +//! ) -> SpelResult { +//! // business logic only +//! } +//! } +//! ``` +//! +//! ## IDL Generation +//! +//! ```rust,ignore +//! // generate_idl.rs — one-liner! +//! spel_framework::generate_idl!("src/bin/treasury.rs"); +//! ``` + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use sha2::{Digest, Sha256}; +use syn::{ + parse::Parser, + parse_macro_input, + visit_mut::{self, VisitMut}, + Attribute, FnArg, Ident, ItemFn, ItemMod, Pat, PatType, Type, +}; + +/// Main entry point: `#[lez_program]` on a module. +/// +/// This macro: +/// 1. Finds all `#[instruction]` functions in the module +/// 2. Generates a serde-serializable `Instruction` enum +/// 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 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_lez_program(input, config) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// 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 +} + +/// Marker attribute for account data types. +/// +/// Place this on any struct or enum whose Borsh-encoded bytes are stored +/// in on-chain accounts. `spel generate-idl` will include the type in the +/// IDL so that `spel inspect` can decode account data of this shape. +/// +/// ```rust,ignore +/// #[account_type] +/// #[derive(BorshSerialize, BorshDeserialize)] +/// pub struct VaultState { +/// pub owner: AccountId, +/// pub balance: u64, +/// } +/// ``` +/// +/// This attribute is a no-op at compile time; it is consumed solely by the +/// IDL generator. +#[proc_macro_attribute] +pub fn account_type(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} + +/// Generate IDL from a program source file. +/// +/// 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 +/// spel_framework_macros::generate_idl!("../../methods/guest/src/bin/treasury.rs"); +/// ``` +#[proc_macro] +pub fn generate_idl(input: TokenStream) -> TokenStream { + let lit = parse_macro_input!(input as syn::LitStr); + let file_path = lit.value(); + + match expand_generate_idl(&file_path, &lit) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +// ─── Internal expansion logic ──────────────────────────────────────────── + +/// Parsed info about one instruction function. +struct InstructionInfo { + fn_name: Ident, + /// Account parameters (AccountWithMetadata type), in order + accounts: Vec, + /// Non-account parameters (the instruction args) + args: Vec, + /// The original function item (with #[instruction] stripped) + func: ItemFn, +} + +struct AccountParam { + name: Ident, + constraints: AccountConstraints, + /// True if this is a Vec (variable-length trailing accounts) + is_rest: bool, +} + +#[derive(Default)] +struct AccountConstraints { + mutable: bool, + init: bool, + owner: Option, + signer: bool, + pda_seeds: Vec, +} + +/// A PDA seed definition from the `#[account(pda = ...)]` attribute. +#[derive(Clone)] +enum PdaSeedDef { + /// `const("some_string")` — a constant string seed. + /// `literal("some_string")` is accepted as an alias for backwards compatibility. + Const(String), + /// `account("other_account_name")` — seed derived from another account's ID + Account(String), + /// `arg("some_arg")` — seed derived from an instruction argument + Arg(String), +} + +struct ArgParam { + name: Ident, + ty: Type, +} + +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, "lez_program module must have a body"))?; + + // Collect instruction functions and other items + let mut instructions: Vec = Vec::new(); + let mut other_items: Vec = Vec::new(); + + for item in items { + match item { + syn::Item::Fn(func) => { + if has_instruction_attr(&func.attrs) { + instructions.push(parse_instruction(func.clone())?); + } else { + other_items.push(quote! { #func }); + } + } + other => { + other_items.push(quote! { #other }); + } + } + } + + if instructions.is_empty() { + return Err(syn::Error::new_spanned( + &input.ident, + "lez_program must contain at least one #[instruction] function", + )); + } + + // 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; + } + }; + + // Generate match arms for dispatch + let match_arms = generate_match_arms(mod_name, &instructions); + + // Generate the handler functions (with #[instruction] stripped, account attrs stripped) + let handler_fns = generate_handler_fns(&instructions); + + // Generate validation functions + let validation_fns = generate_validation(&instructions); + + // Generate per-instruction __claims_*() functions for auto-claim + let claim_fns = generate_claim_fns(&instructions); + + // Generate main function. + // `pub fn main` (not just `fn main`) is required so the zkVM linker can find the entry point + // when this crate is compiled as a guest binary dependency. + let main_fn = quote! { + pub fn main() { + // Read inputs from zkVM host + let (nssa_core::program::ProgramInput { self_program_id, caller_program_id, pre_states, instruction }, instruction_words) + = nssa_core::program::read_nssa_inputs::(); + let pre_states_clone = pre_states.clone(); + + // Dispatch to instruction handler + let result: Result< + (Vec, Vec), + spel_framework::error::SpelError + > = match instruction { + #(#match_arms)* + }; + + // Handle result + let (post_states, chained_calls) = match result { + Ok(output) => output, + Err(e) => { + panic!("Program error [{}]: {}", e.error_code(), e); + } + }; + + // Filter out non-program-owned, non-default-state accounts from the output. + // + // LEZ validate_execution rule 7: if post.program_owner == DEFAULT_PROGRAM_ID + // and pre.account != Account::default(), validation fails. This would happen + // for signer accounts (e.g., proposer/executor) whose nonce has been incremented + // by a prior transaction — they are not owned by the program and must not be + // returned in the program's post-states. + // + // We drop any (pre, post) pair where: + // - pre.program_owner == DEFAULT_PROGRAM_ID (not owned by this program), AND + // - pre.account != Account::default() (has non-trivial state), AND + // - post has no claim (init accounts are fine since their pre == default) + let (filtered_pre, filtered_post): ( + Vec, + Vec, + ) = pre_states_clone + .into_iter() + .zip(post_states.into_iter()) + .filter(|(pre, post)| { + let is_default_owner = + pre.account.program_owner == nssa_core::program::DEFAULT_PROGRAM_ID; + let pre_is_default = + pre.account == nssa_core::account::Account::default(); + let has_claim = post.required_claim().is_some(); + !is_default_owner || pre_is_default || has_claim + }) + .unzip(); + + // Write outputs to zkVM host + nssa_core::program::ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + filtered_pre, + filtered_post, + ) + .with_chained_calls(chained_calls) + .write(); + } + }; + + // Generate IDL function and const JSON + 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! { + // The instruction enum (used by both on-chain and client) + #enum_def + + // Complete IDL as a const JSON string (accessible from any target) + pub const PROGRAM_IDL_JSON: &str = #idl_json; + + // The program module is pub so host-side tests and tooling can call handler functions, + // validation helpers (__validate_*), and claims helpers (__claims_*) directly. + pub mod #mod_name { + use super::*; + + #(#other_items)* + + #(#handler_fns)* + + #(#validation_fns)* + + #(#claim_fns)* + } + + // IDL generation (available at host-side for tooling) + #idl_fn + + // The guest binary entry point (cfg-gated so cargo test works on host) + #[cfg(not(test))] + #main_fn + }; + + Ok(expanded) +} + +fn has_instruction_attr(attrs: &[Attribute]) -> bool { + attrs.iter().any(|a| a.path().is_ident("instruction")) +} + +fn parse_instruction(func: ItemFn) -> syn::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(syn::Error::new_spanned( + input, + "instruction functions cannot have self parameter", + )); + } + } + } + + Ok(InstructionInfo { + fn_name, + accounts, + args, + func, + }) +} + +fn extract_param_name(pat_type: &PatType) -> syn::Result { + match &*pat_type.pat { + Pat::Ident(pat_ident) => Ok(pat_ident.ident.clone()), + _ => Err(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 +} + +/// 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(); + + 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()?; + constraints.owner = Some(expr); + Ok(()) + } else if meta.path.is_ident("pda") { + // Parse PDA seeds: pda = const("value"), pda = account("name"), pda = arg("name") + 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")) + } + })?; + } + } + + Ok(constraints) +} + +/// Parse PDA seed expressions. +/// +/// Supports: +/// - `const("string")` — constant seed (`literal("string")` is accepted as an alias) +/// - `account("name")` — account-derived seed +/// - `arg("name")` — argument-derived seed +/// - `[const("a"), account("b")]` — multiple seeds (array syntax) +fn parse_pda_expr(expr: &syn::Expr) -> syn::Result> { + match expr { + // Single seed: const("value") or account("name") + syn::Expr::Call(call) => { + let seed = parse_single_pda_seed(call)?; + Ok(vec![seed]) + } + // Multiple seeds: [const("a"), account("b")] + 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) -> syn::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 + ), + )), + } +} + +// ─── Code generation helpers ───────────────────────────────────────────── + +fn generate_enum_variants(instructions: &[InstructionInfo]) -> Vec { + instructions + .iter() + .map(|ix| { + let variant_name = to_pascal_case(&ix.fn_name); + let fields: Vec = ix + .args + .iter() + .map(|arg| { + let name = &arg.name; + let ty = &arg.ty; + quote! { #name: #ty } + }) + .collect(); + + if fields.is_empty() { + quote! { #variant_name } + } else { + quote! { #variant_name { #(#fields),* } } + } + }) + .collect() +} + +fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Vec { + instructions + .iter() + .map(|ix| { + let variant_name = to_pascal_case(&ix.fn_name); + let fn_name = &ix.fn_name; + let num_accounts = ix.accounts.len(); + + let field_names: Vec<&Ident> = ix.args.iter().map(|a| &a.name).collect(); + let pattern = if field_names.is_empty() { + quote! { Instruction::#variant_name } + } else { + quote! { Instruction::#variant_name { #(#field_names),* } } + }; + + 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/pda checks) + let has_validation = ix.accounts.iter().any(|a| { + a.constraints.signer || a.constraints.init || !a.constraints.pda_seeds.is_empty() + }); + let validate_fn_name = format_ident!("__validate_{}", ix.fn_name); + + let call_args: Vec = ix + .accounts + .iter() + .map(|a| { + let name = &a.name; + quote! { #name } + }) + .chain(ix.args.iter().map(|a| { + let name = &a.name; + quote! { #name } + })) + .collect(); + + // Collect arg seed values to pass to validation + let arg_seed_values: Vec = { + let mut names = Vec::new(); + for acc in &ix.accounts { + for seed in &acc.constraints.pda_seeds { + if let PdaSeedDef::Arg(name) = seed { + if !names.contains(name) { + names.push(name.clone()); + } + } + } + } + names.iter().map(|name| { + let arg_ident = format_ident!("{}", name); + quote! { &#arg_ident } + }).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, + &self_program_id, + &instruction_words, + #(#arg_seed_values),* + ).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()),*], + &self_program_id, + &instruction_words, + #(#arg_seed_values),* + ).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)) + } + } + }) + .collect() +} + +// ─── SpelOutput::execute() auto-claim transformer ────────────────────── + +/// Walks a handler function body and rewrites `SpelOutput::execute(...)` calls: +/// +/// - **Fixed accounts** (`vec![a, b]`): +/// → `SpelOutput::execute_with_claims(&[a.account.clone(), ...], &__claims_fn(...), calls)` +/// +/// - **Dynamic accounts** (any expression, for instructions with `Vec`): +/// → `{ let __accs = accounts_expr; let __extracted = ...; SpelOutput::execute_with_claims(&__extracted, &__claims_fn(__accs.len() - NUM_FIXED, ...), calls) }` +/// The block binds accounts_expr once to avoid double evaluation. +struct ExecuteTransformer<'a> { + accounts: &'a [AccountParam], + fn_name: &'a Ident, +} + +impl<'a> ExecuteTransformer<'a> { + fn has_rest(&self) -> bool { + self.accounts.iter().any(|a| a.is_rest) + } + + fn num_fixed(&self) -> usize { + self.accounts.iter().filter(|a| !a.is_rest).count() + } + + /// Collect arg seed values as function call arguments for __claims_* functions. + /// For each unique PdaSeedDef::Arg across all accounts, generates: &arg_name + fn arg_seed_args(&self) -> Vec { + let mut names: Vec = Vec::new(); + for acc in self.accounts { + for seed in &acc.constraints.pda_seeds { + if let PdaSeedDef::Arg(name) = seed { + if !names.contains(name) { + names.push(name.clone()); + } + } + } + } + names + .iter() + .map(|name| { + let ident = format_ident!("{}", name); + quote! { &#ident } + }) + .collect() + } + + /// Collect account-seed arguments for the vec![ident, ...] pattern. + /// For each unique PdaSeedDef::Account, generates: &*ident.account_id.value() + fn account_seed_args_from_idents(&self, account_idents: &[Ident]) -> Vec { + let mut seen: Vec = Vec::new(); + let mut result = Vec::new(); + for acc in self.accounts { + for seed in &acc.constraints.pda_seeds { + if let PdaSeedDef::Account(path) = seed { + let name = path.split('.').next().unwrap_or(path.as_str()).to_string(); + if !seen.contains(&name) { + seen.push(name.clone()); + if let Some(ident) = account_idents.iter().find(|i| i.to_string() == name) { + result.push(quote! { &*#ident.account_id.value() }); + } + } + } + } + } + result + } + + /// Collect account-seed arguments for the rest-accounts branch. + /// `binding` is the local variable name holding Vec. + /// For each unique PdaSeedDef::Account, generates: &*binding[idx].account_id.value() + fn account_seed_args_for_rest(&self, binding: &TokenStream2) -> Vec { + let mut seen: Vec = Vec::new(); + let mut result = Vec::new(); + for acc in self.accounts { + for seed in &acc.constraints.pda_seeds { + if let PdaSeedDef::Account(path) = seed { + let name = path.split('.').next().unwrap_or(path.as_str()).to_string(); + if !seen.contains(&name) { + seen.push(name.clone()); + let idx = self + .accounts + .iter() + .position(|a| a.name.to_string() == name) + .unwrap_or(0); + result.push(quote! { &*#binding[#idx].account_id.value() }); + } + } + } + } + result + } +} + +impl<'a> VisitMut for ExecuteTransformer<'a> { + fn visit_expr_mut(&mut self, expr: &mut syn::Expr) { + // Recurse into sub-expressions first + visit_mut::visit_expr_mut(self, expr); + + // Clone what we need before mutably borrowing expr below + let (accounts_arg, chained_arg) = { + let call = if let syn::Expr::Call(c) = &*expr { + c + } else { + return; + }; + if !is_spel_output_execute(&call.func) || call.args.len() != 2 { + return; + } + (call.args[0].clone(), call.args[1].clone()) + }; + + let claims_fn = format_ident!("__claims_{}", self.fn_name); + let arg_seed_args: Vec = self.arg_seed_args(); + + // Try vec![ident, ...] pattern first (fixed-size accounts, most common case) + if let Some(account_idents) = extract_vec_macro_idents(&accounts_arg) { + // Verify all account names are known before transforming + let mut account_clones: Vec = Vec::new(); + for ident in &account_idents { + if self.accounts.iter().find(|a| a.name == *ident).is_none() { + return; // unknown account — don't transform + } + account_clones.push(quote! { #ident.account.clone() }); + } + let account_seed_args = self.account_seed_args_from_idents(&account_idents); + let all_seed_args: Vec = + arg_seed_args.into_iter().chain(account_seed_args).collect(); + if let syn::Expr::Call(call) = expr { + call.func = syn::parse_quote! { SpelOutput::execute_with_claims }; + call.args.clear(); + call.args + .push(syn::parse_quote! { &[#(#account_clones),*] }); + call.args + .push(syn::parse_quote! { &#claims_fn(#(#all_seed_args),*) }); + call.args.push(syn::parse_quote! { #chained_arg }); + } + return; + } + + // For instructions with Vec (rest accounts): use a block to bind + // accounts_expr exactly once, fixing double evaluation and allowing account-seed lookup. + if self.has_rest() { + let num_fixed = self.num_fixed(); + let accs = quote! { __accs }; + let account_seed_args = self.account_seed_args_for_rest(&accs); + let all_seed_args: Vec = + arg_seed_args.into_iter().chain(account_seed_args).collect(); + *expr = syn::parse_quote! { + { + let __accs: ::std::vec::Vec<_> = #accounts_arg; + let __extracted: ::std::vec::Vec<_> = + __accs.iter().map(|__a| __a.account.clone()).collect(); + SpelOutput::execute_with_claims( + &__extracted, + &#claims_fn(__accs.len() - #num_fixed #(, #all_seed_args)*), + #chained_arg + ) + } + }; + return; + } + + // Fixed-account instruction with an arbitrary accounts expression (e.g. a Vec + // variable built by the handler). The vec![name, ...] pattern above handles the common + // case; this catches anything else. Note: account(...) PDA seeds cannot be resolved here + // because AccountWithMetadata is not available — use vec![...] for those instructions. + let all_seed_args: Vec = arg_seed_args; + if let syn::Expr::Call(call) = expr { + call.func = syn::parse_quote! { SpelOutput::execute_with_claims }; + call.args.clear(); + call.args.push(syn::parse_quote! { &#accounts_arg }); + call.args + .push(syn::parse_quote! { &#claims_fn(#(#all_seed_args),*) }); + call.args.push(syn::parse_quote! { #chained_arg }); + } + } +} + +fn is_spel_output_execute(func: &syn::Expr) -> bool { + if let syn::Expr::Path(ep) = func { + let segments: Vec<_> = ep.path.segments.iter().collect(); + if segments.len() == 2 { + return segments[0].ident == "SpelOutput" && segments[1].ident == "execute"; + } + } + false +} + +fn extract_vec_macro_idents(expr: &syn::Expr) -> Option> { + if let syn::Expr::Macro(em) = expr { + if em.mac.path.is_ident("vec") { + let parser = + syn::punctuated::Punctuated::::parse_terminated; + if let Ok(idents) = parser.parse2(em.mac.tokens.clone()) { + return Some(idents.into_iter().collect()); + } + } + } + None +} + +fn generate_handler_fns(instructions: &[InstructionInfo]) -> Vec { + instructions + .iter() + .map(|ix| { + let mut func = ix.func.clone(); + func.attrs.retain(|a| !a.path().is_ident("instruction")); + for input in &mut func.sig.inputs { + if let FnArg::Typed(pat_type) = input { + pat_type.attrs.retain(|a| !a.path().is_ident("account")); + } + } + // Transform SpelOutput::execute(vec![...], calls) → execute_with_claims + let mut transformer = ExecuteTransformer { + accounts: &ix.accounts, + fn_name: &ix.fn_name, + }; + transformer.visit_item_fn_mut(&mut func); + quote! { #func } + }) + .collect() +} + +/// Generate the `AutoClaim` token stream for a single account based on its constraints. +/// +/// For `PdaSeedDef::Account`, the generated expression references a `__account_seed_{name}: &[u8;32]` +/// parameter that the caller (claims function) receives at runtime, matching the actual account ID +/// used by the validation function. This is the correct counterpart to `generate_validation`. +fn generate_single_claim_expr(acc: &AccountParam) -> TokenStream2 { + if acc.constraints.init && !acc.constraints.pda_seeds.is_empty() { + let seed_bytes: Vec = acc + .constraints + .pda_seeds + .iter() + .map(|seed| { + match seed { + PdaSeedDef::Const(v) => { + let val = v.clone(); + quote! { &spel_framework::pda::seed_from_str(#val) } + } + PdaSeedDef::Account(path) => { + // Use a runtime parameter holding the actual account ID bytes, + // matching how generate_validation resolves account seeds. + let account_name = path.split('.').next().unwrap_or(path.as_str()); + let ident = format_ident!("__account_seed_{}", account_name); + quote! { #ident } // already &[u8; 32] + } + PdaSeedDef::Arg(name) => { + let ident = format_ident!("__pda_arg_{}", name); + quote! { &spel_framework::pda::ToSeed::to_seed(#ident) } + } + } + }) + .collect(); + if seed_bytes.len() == 1 { + let seed = &seed_bytes[0]; + quote! { + spel_framework::spel_output::AutoClaim::Claimed( + nssa_core::program::Claim::Pda( + nssa_core::program::PdaSeed::new(*#seed) + ) + ) + } + } else { + quote! { + spel_framework::spel_output::AutoClaim::pda_from_seeds( + &[#(#seed_bytes),*] + ) + } + } + } else if acc.constraints.init { + quote! { + spel_framework::spel_output::AutoClaim::Claimed( + nssa_core::program::Claim::Authorized + ) + } + } else { + quote! { spel_framework::spel_output::AutoClaim::None } + } +} + +/// Generate per-instruction `__claims_{fn_name}()` functions that return +/// `Vec` based on account constraints. These are used by +/// `SpelOutput::execute_with_claims()` so users don't have to manually +/// choose `new()` vs `new_claimed()`. +/// +/// Auto-claim rules: +/// - `#[account(init, pda = ...)]` → `Claim::Pda(seeds)` +/// - `#[account(init, signer)]` → `Claim::Authorized` +/// - `#[account(init)]` → `Claim::Authorized` +/// - `#[account(mut)]` → `Claim::None` +/// - `#[account]` → `Claim::None` +/// +/// For instructions with `Vec` (rest accounts), the +/// generated function takes a `rest_count: usize` parameter and repeats +/// the rest account's claim that many times. +/// +/// For `account(...)` PDA seeds, the generated function takes an additional +/// `__account_seed_{name}: &[u8; 32]` parameter per referenced account, so the +/// caller can pass the actual runtime account ID (matching what validation does). + +/// Collect the unique PDA arg seed parameters for a given instruction as typed +/// `__pda_arg_: &` token streams, used in generated function signatures. +fn pda_arg_params(ix: &InstructionInfo) -> Vec { + let mut names: Vec = Vec::new(); + for acc in &ix.accounts { + for seed in &acc.constraints.pda_seeds { + if let PdaSeedDef::Arg(name) = seed { + if !names.contains(name) { + names.push(name.clone()); + } + } + } + } + names + .iter() + .map(|name| { + let ident = format_ident!("__pda_arg_{}", name); + let actual_type = ix + .args + .iter() + .find(|a| a.name.to_string() == *name) + .map(|a| &a.ty); + if let Some(ty) = actual_type { + quote! { #ident: &#ty } + } else { + quote! { #ident: &[u8; 32] } + } + }) + .collect() +} + +/// Collect the unique PDA account seed parameters for a given instruction as typed +/// `__account_seed_: &[u8; 32]` token streams. +fn pda_account_seed_params(ix: &InstructionInfo) -> Vec { + let mut names: Vec = Vec::new(); + for acc in &ix.accounts { + for seed in &acc.constraints.pda_seeds { + if let PdaSeedDef::Account(path) = seed { + let name = path.split('.').next().unwrap_or(path.as_str()).to_string(); + if !names.contains(&name) { + names.push(name); + } + } + } + } + names + .iter() + .map(|name| { + let ident = format_ident!("__account_seed_{}", name); + quote! { #ident: &[u8; 32] } + }) + .collect() +} + +fn generate_claim_fns(instructions: &[InstructionInfo]) -> Vec { + instructions + .iter() + .map(|ix| { + let fn_name = format_ident!("__claims_{}", ix.fn_name); + let has_rest = ix.accounts.iter().any(|a| a.is_rest); + let arg_params = pda_arg_params(ix); + let account_seed_params = pda_account_seed_params(ix); + let all_params: Vec = arg_params.into_iter() + .chain(account_seed_params) + .collect(); + + if has_rest { + let fixed_claims: Vec = ix + .accounts + .iter() + .filter(|a| !a.is_rest) + .map(|acc| generate_single_claim_expr(acc)) + .collect(); + + let rest_acc = ix.accounts.iter().find(|a| a.is_rest).unwrap(); + let rest_claim = generate_single_claim_expr(rest_acc); + + quote! { + #[allow(dead_code)] + pub fn #fn_name(rest_count: usize, #(#all_params),*) -> Vec { + let mut claims = vec![#(#fixed_claims),*]; + claims.extend( + std::iter::repeat(#rest_claim).take(rest_count) + ); + claims + } + } + } else { + let claim_exprs: Vec = ix + .accounts + .iter() + .map(|acc| generate_single_claim_expr(acc)) + .collect(); + + quote! { + #[allow(dead_code)] + pub fn #fn_name(#(#all_params),*) -> Vec { + vec![#(#claim_exprs),*] + } + } + } + }) + .collect() +} + +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 idx = i; + quote! { + if accounts[#idx].account != nssa_core::account::Account::default() { + return Err(spel_framework::error::SpelError::AccountAlreadyInitialized { + account_index: #idx, + }); + } + } + }) + .collect(); + + // Extra parameters for arg PDA seeds + let arg_seed_params = pda_arg_params(ix); + + // Generate PDA checks for accounts with pda_seeds + let pda_checks: Vec = ix + .accounts + .iter() + .enumerate() + .filter(|(_, acc)| !acc.constraints.pda_seeds.is_empty()) + .map(|(i, acc)| { + let acc_name = acc.name.to_string(); + let idx = i; + + let seed_exprs: Vec = acc + .constraints + .pda_seeds + .iter() + .enumerate() + .map(|(j, seed)| { + let var = format_ident!("__seed_{}", j); + match seed { + PdaSeedDef::Const(val) => { + quote! { let #var = spel_framework::pda::seed_from_str(#val); } + } + PdaSeedDef::Account(path) => { + // Strip ".id" or other suffixes — we always use account_id + let account_name = path.split('.').next().unwrap_or(path); + let account_idx = ix.accounts.iter() + .position(|a| a.name == account_name) + .unwrap_or_else(|| panic!( + "PDA seed references unknown account '{}'", account_name + )); + quote! { let #var = *accounts[#account_idx].account_id.value(); } + } + PdaSeedDef::Arg(field_name) => { + let param_name = format_ident!("__pda_arg_{}", field_name); + quote! { let #var = spel_framework::pda::ToSeed::to_seed(#param_name); } + } + } + }) + .collect(); + + let seed_refs: Vec = (0..acc.constraints.pda_seeds.len()) + .map(|j| { + let var = format_ident!("__seed_{}", j); + quote! { &#var } + }) + .collect(); + + quote! { + { + #(#seed_exprs)* + let __expected_id = spel_framework::pda::compute_pda( + self_program_id, &[#(#seed_refs),*] + ); + if accounts[#idx].account_id != __expected_id { + return Err(spel_framework::error::SpelError::PdaMismatch { + account_name: #acc_name.to_string(), + expected: format!("{:?}", __expected_id), + actual: format!("{:?}", accounts[#idx].account_id), + }); + } + } + } + }) + .collect(); + + if signer_checks.is_empty() && init_checks.is_empty() && pda_checks.is_empty() { + return quote! {}; + } + + quote! { + #[allow(dead_code)] + pub fn #fn_name( + accounts: &[nssa_core::account::AccountWithMetadata], + self_program_id: &nssa_core::program::ProgramId, + // Retained for future use (e.g. instruction-level replay protection or + // content-based dispatch). Not used in validation logic today. + _instruction_words: &nssa_core::program::InstructionData, + #(#arg_seed_params),* + ) -> Result<(), spel_framework::error::SpelError> { + #(#signer_checks)* + #(#init_checks)* + #(#pda_checks)* + Ok(()) + } + } + }) + .collect() +} + +fn to_pascal_case(ident: &Ident) -> Ident { + let s = ident.to_string(); + let pascal: String = s + .split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect(); + format_ident!("{}", pascal) +} + +// ─── IDL type conversion ───────────────────────────────────────────────── + +/// Convert a Rust `syn::Type` to a `TokenStream` that constructs the correct `IdlType` variant. +/// Used by `generate_idl_fn` to emit structured types (Array, Vec, Option) instead of +/// flattening everything to `IdlType::Primitive(string)`. +fn rust_type_to_idl_type_tokens(ty: &Type) -> proc_macro2::TokenStream { + match ty { + Type::Path(type_path) => { + let segment = type_path.path.segments.last().unwrap(); + let ident = segment.ident.to_string(); + match ident.as_str() { + "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" + | "bool" | "String" => { + let name = ident.to_lowercase(); + quote! { spel_framework::idl::IdlType::Primitive(#name.to_string()) } + } + "Vec" => { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + let inner_tokens = rust_type_to_idl_type_tokens(inner); + quote! { + spel_framework::idl::IdlType::Vec { + vec: Box::new(#inner_tokens) + } + } + } else { + quote! { spel_framework::idl::IdlType::Primitive("vec".to_string()) } + } + } else { + quote! { spel_framework::idl::IdlType::Primitive("vec".to_string()) } + } + } + "Option" => { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + let inner_tokens = rust_type_to_idl_type_tokens(inner); + quote! { + spel_framework::idl::IdlType::Option { + option: Box::new(#inner_tokens) + } + } + } else { + quote! { spel_framework::idl::IdlType::Primitive("option".to_string()) } + } + } else { + quote! { spel_framework::idl::IdlType::Primitive("option".to_string()) } + } + } + "ProgramId" => { + quote! { spel_framework::idl::IdlType::Primitive("program_id".to_string()) } + } + "AccountId" => { + quote! { spel_framework::idl::IdlType::Primitive("account_id".to_string()) } + } + other => { + let name = other.to_string(); + quote! { spel_framework::idl::IdlType::Defined { defined: #name.to_string() } } + } + } + } + Type::Array(arr) => { + let elem_tokens = rust_type_to_idl_type_tokens(&arr.elem); + if let syn::Expr::Lit(lit) = &arr.len { + if let syn::Lit::Int(n) = &lit.lit { + let size: usize = n.base10_parse().unwrap_or(0); + quote! { + spel_framework::idl::IdlType::Array { + array: (Box::new(#elem_tokens), #size) + } + } + } else { + quote! { spel_framework::idl::IdlType::Primitive("unknown".to_string()) } + } + } else { + quote! { spel_framework::idl::IdlType::Primitive("unknown".to_string()) } + } + } + _ => { + quote! { spel_framework::idl::IdlType::Primitive("unknown".to_string()) } + } + } +} + +/// Convert a Rust IDL type string to the JSON representation. +/// This produces a JSON value string for embedding in const IDL JSON. +fn rust_type_to_idl_json(ty: &Type) -> String { + match ty { + Type::Path(type_path) => { + let segment = type_path.path.segments.last().unwrap(); + let ident = segment.ident.to_string(); + match ident.as_str() { + "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" + | "bool" | "String" => { + format!("\"{}\"", ident.to_lowercase()) + } + "Vec" => { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + format!("{{\"vec\":{}}}", rust_type_to_idl_json(inner)) + } else { + "\"vec\"".to_string() + } + } else { + "\"vec\"".to_string() + } + } + "ProgramId" => "\"program_id\"".to_string(), + "AccountId" => "\"account_id\"".to_string(), + other => format!("{{\"defined\":\"{}\"}}", other), + } + } + Type::Array(arr) => { + let elem = rust_type_to_idl_json(&arr.elem); + if let syn::Expr::Lit(lit) = &arr.len { + if let syn::Lit::Int(n) = &lit.lit { + return format!("{{\"array\":[{},{}]}}", elem, n); + } + } + format!("{{\"array\":[{},0]}}", elem) + } + _ => "\"unknown\"".to_string(), + } +} + +// ─── IDL generation (code-based, for __program_idl()) ──────────────────── + +/// 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 + .iter() + .map(|ix| { + let ix_name = ix.fn_name.to_string(); + + let account_literals: Vec = ix + .accounts + .iter() + .map(|acc| { + let acc_name = acc.name.to_string().trim_start_matches('_').to_string(); + let writable = acc.constraints.mutable; + let signer = acc.constraints.signer; + let init = acc.constraints.init; + + let pda_expr = if acc.constraints.pda_seeds.is_empty() { + quote! { None } + } else { + let seed_literals: Vec = acc + .constraints + .pda_seeds + .iter() + .map(|seed| match seed { + PdaSeedDef::Const(val) => quote! { + spel_framework::idl::IdlSeed::Const { value: #val.to_string() } + }, + PdaSeedDef::Account(name) => quote! { + spel_framework::idl::IdlSeed::Account { path: #name.to_string() } + }, + PdaSeedDef::Arg(name) => quote! { + spel_framework::idl::IdlSeed::Arg { path: #name.to_string() } + }, + }) + .collect(); + + quote! { + Some(spel_framework::idl::IdlPda { + seeds: vec![#(#seed_literals),*], + }) + } + }; + + let is_rest = acc.is_rest; + quote! { + 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()], + } + } + }) + .collect(); + + let arg_literals: Vec = ix + .args + .iter() + .map(|arg| { + let arg_name = arg.name.to_string().trim_start_matches('_').to_string(); + let type_tokens = rust_type_to_idl_type_tokens(&arg.ty); + quote! { + spel_framework::idl::IdlArg { + name: #arg_name.to_string(), + type_: #type_tokens, + } + } + }) + .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! { + 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() -> 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(), + }), + } + } + } +} + +// ─── IDL generation (JSON string, for PROGRAM_IDL_JSON const) ──────────── + +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 + .iter() + .map(|ix| { + let ix_name = &ix.fn_name.to_string(); + + let accounts_json: Vec = ix + .accounts + .iter() + .map(|acc| { + let name = acc.name.to_string(); + let writable = acc.constraints.mutable; + let signer = acc.constraints.signer; + let init = acc.constraints.init; + + let pda_json = if acc.constraints.pda_seeds.is_empty() { + String::new() + } else { + let seeds: Vec = acc + .constraints + .pda_seeds + .iter() + .map(|seed| match seed { + PdaSeedDef::Const(val) => { + format!("{{\"kind\":\"const\",\"value\":\"{}\"}}", val) + } + PdaSeedDef::Account(name) => { + format!("{{\"kind\":\"account\",\"path\":\"{}\"}}", name) + } + PdaSeedDef::Arg(name) => { + format!("{{\"kind\":\"arg\",\"path\":\"{}\"}}", name) + } + }) + .collect(); + 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, rest_json + ) + }) + .collect(); + + let args_json: Vec = ix + .args + .iter() + .map(|arg| { + let name = arg.name.to_string(); + let type_json = rust_type_to_idl_json(&arg.ty); + format!("{{\"name\":\"{}\",\"type\":{}}}", name, type_json) + }) + .collect(); + + format!( + "{{\"name\":\"{}\",\"accounts\":[{}],\"args\":[{}]}}", + ix_name, + accounts_json.join(","), + args_json.join(",") + ) + }) + .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\":[]{}}}" , + program_name, + instructions_json.join(","), + instruction_type_suffix + ) +} + +// ─── generate_idl! macro implementation ────────────────────────────────── + +fn expand_generate_idl(file_path: &str, span_token: &syn::LitStr) -> syn::Result { + // Try the path as-is first, then relative to CARGO_MANIFEST_DIR + let resolved_path = if std::path::Path::new(file_path).exists() { + file_path.to_string() + } else if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { + let p = std::path::Path::new(&manifest_dir).join(file_path); + p.to_string_lossy().to_string() + } else { + file_path.to_string() + }; + + // Read the source file + let content = std::fs::read_to_string(&resolved_path).map_err(|e| { + syn::Error::new_spanned( + span_token, + format!( + "Failed to read '{}' (resolved: '{}'): {}", + file_path, resolved_path, e + ), + ) + })?; + + // Parse as a Rust file + let file = syn::parse_file(&content).map_err(|e| { + syn::Error::new_spanned( + span_token, + format!("Failed to parse '{}': {}", file_path, e), + ) + })?; + + // 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("lez_program")) { + program_mod = Some(m); + break; + } + } + } + + let program_mod = program_mod.ok_or_else(|| { + syn::Error::new_spanned( + span_token, + format!("No #[lez_program] module found in '{}'", file_path), + ) + })?; + + let mod_name = &program_mod.ident; + + let (_, items) = program_mod + .content + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(span_token, "lez_program module has no body"))?; + + // Parse instructions + 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(syn::Error::new_spanned( + span_token, + "No #[instruction] functions found in the program module", + )); + } + + // 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, external_instruction_str.as_deref()); + + // Embed the resolved path for cargo tracking + let resolved = resolved_path.clone(); + + // Generate a main() that pretty-prints the IDL + Ok(quote! { + pub fn main() { + // Help cargo track source changes + const _SOURCE: &str = include_str!(#resolved); + let json: serde_json::Value = serde_json::from_str(#idl_json) + .expect("Generated IDL JSON is invalid"); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + } + }) +} diff --git a/spel-framework/Cargo.toml b/spel-framework/Cargo.toml new file mode 100644 index 00000000..1a87f907 --- /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", tag = "v0.2.0-rc1", 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..0990f146 --- /dev/null +++ b/spel-framework/src/lib.rs @@ -0,0 +1,21 @@ +//! # SPEL Framework +//! +//! Developer framework for building programs on SPEL, +//! similar to Anchor for Solana. + +// Re-export the proc macros +pub use spel_framework_macros::{account_type, generate_idl, instruction, lez_program}; + +// Re-export core types +pub use spel_framework_core::*; + +pub mod prelude { + pub use crate::account_type; + pub use crate::instruction; + pub use crate::lez_program; + pub use borsh::{BorshDeserialize, BorshSerialize}; + pub use spel_framework_core::error::{SpelError, SpelResult}; + pub use spel_framework_core::prelude::*; + pub use spel_framework_core::spel_output::AutoClaim; + pub use spel_framework_core::types::SpelOutput; +} diff --git a/spel-framework/tests/e2e.rs b/spel-framework/tests/e2e.rs new file mode 100644 index 00000000..45aa0009 --- /dev/null +++ b/spel-framework/tests/e2e.rs @@ -0,0 +1,245 @@ +//! 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(), 8); + + // 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"); + + // create_ledger instruction + let ledger = &idl.instructions[3]; + assert_eq!(ledger.name, "create_ledger"); + assert_eq!(ledger.accounts.len(), 2); + assert!(ledger.accounts[0].init, "ledger should be init"); + assert!(ledger.accounts[0].pda.is_some(), "ledger should have PDA"); + let ledger_pda = ledger.accounts[0].pda.as_ref().unwrap(); + assert_eq!(ledger_pda.seeds.len(), 3); // literal + u64 arg + u32 arg + assert!(ledger.accounts[1].signer, "authority should be signer"); + assert_eq!(ledger.args.len(), 2); + assert_eq!(ledger.args[0].name, "user_id"); + assert_eq!(ledger.args[1].name, "seq"); + + // register_entity instruction + let entity = &idl.instructions[4]; + assert_eq!(entity.name, "register_entity"); + assert_eq!(entity.accounts.len(), 2); + assert!(entity.accounts[0].init, "entity should be init"); + assert!(entity.accounts[0].pda.is_some(), "entity should have PDA"); + let entity_pda = entity.accounts[0].pda.as_ref().unwrap(); + assert_eq!(entity_pda.seeds.len(), 2); // String arg + String arg + assert!(entity.accounts[1].signer, "registrar should be signer"); + assert_eq!(entity.args.len(), 2); + assert_eq!(entity.args[0].name, "domain"); + assert_eq!(entity.args[1].name, "name"); + + // transfer instruction + let transfer = &idl.instructions[5]; + 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..82ab1a18 --- /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", tag = "v0.2.0-rc1", 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..624fcc50 --- /dev/null +++ b/tests/e2e/fixture_program/src/lib.rs @@ -0,0 +1,644 @@ +//! 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::execute(vec![state, authority], 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::execute(vec![vault, owner], 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::execute(vec![config, admin], vec![])) + } + + /// Create a ledger entry (PDA from literal + u64 arg + u32 arg). + #[instruction] + pub fn create_ledger( + #[account(init, pda = [literal("ledger"), arg("user_id"), arg("seq")])] + ledger: AccountWithMetadata, + #[account(signer)] + authority: AccountWithMetadata, + user_id: u64, + seq: u32, + ) -> SpelResult { + Ok(SpelOutput::execute(vec![ledger, authority], vec![])) + } + + /// Register a named entity (PDA from arg + arg with String type). + #[instruction] + pub fn register_entity( + #[account(init, pda = [arg("domain"), arg("name")])] + entity: AccountWithMetadata, + #[account(signer)] + registrar: AccountWithMetadata, + domain: String, + name: String, + ) -> SpelResult { + Ok(SpelOutput::execute(vec![entity, registrar], 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::execute(vec![from, to, signer], vec![])) + } + + /// Create a record whose PDA is derived from the owner's account ID. + /// Exercises the `account("owner")` PDA seed variant in both claim generation + /// and validation. + #[instruction] + pub fn create_record( + #[account(init, pda = account("owner"))] + record: AccountWithMetadata, + #[account(signer)] + owner: AccountWithMetadata, + ) -> SpelResult { + Ok(SpelOutput::execute(vec![record, owner], vec![])) + } + + /// Batch update: one fixed authority + variable-length list of target accounts. + #[instruction] + pub fn batch_update( + #[account(signer)] + authority: AccountWithMetadata, + #[account(mut)] + targets: Vec, + value: u64, + ) -> SpelResult { + let mut accounts = vec![authority]; + accounts.extend(targets); + Ok(SpelOutput::execute(accounts, 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(), 8); + 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(), 8); + } + + #[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[5]; + 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()); + } + + // ── PDA validation tests ───────────────────────────────────────── + + fn make_account_with_id(id: [u8; 32], authorized: bool) -> AccountWithMetadata { + AccountWithMetadata { + account_id: nssa_core::account::AccountId::new(id), + account: nssa_core::account::Account::default(), + is_authorized: authorized, + } + } + + fn test_program_id() -> nssa_core::program::ProgramId { + [1u32; 8] + } + + fn empty_ix_data() -> Vec { + vec![] + } + + // ── create_vault (single arg seed) ─────────────────────────────── + + #[test] + fn validate_create_vault_rejects_wrong_pda() { + let program_id = test_program_id(); + let owner_key = [42u8; 32]; + + // Compute the correct PDA so we can supply a *different* one + let correct_id = spel_framework::pda::compute_pda(&program_id, &[&owner_key]); + let wrong_id = [0xFFu8; 32]; // definitely not the correct PDA + assert_ne!( + nssa_core::account::AccountId::new(wrong_id), + correct_id, + "test precondition: wrong_id must differ from correct PDA" + ); + + let accounts = vec![ + make_account_with_id(wrong_id, false), // vault — wrong address + make_account_with_id([2u8; 32], true), // owner — signer + ]; + + let result = treasury::__validate_create_vault( + &accounts, + &program_id, + &empty_ix_data(), + &owner_key, + ); + let err = result.expect_err("should reject wrong PDA"); + assert!( + matches!(err, spel_framework::error::SpelError::PdaMismatch { .. }), + "expected PdaMismatch, got: {err:?}" + ); + } + + #[test] + fn validate_create_vault_accepts_correct_pda() { + let program_id = test_program_id(); + let owner_key = [42u8; 32]; + let correct_id = spel_framework::pda::compute_pda(&program_id, &[&owner_key]); + + let accounts = vec![ + make_account_with_id(*correct_id.value(), false), // vault — correct PDA + make_account_with_id([2u8; 32], true), // owner — signer + ]; + + let result = treasury::__validate_create_vault( + &accounts, + &program_id, + &empty_ix_data(), + &owner_key, + ); + assert!(result.is_ok(), "correct PDA should pass: {result:?}"); + } + + // ── create_config (multi-seed: literal + arg) ──────────────────── + + #[test] + fn validate_create_config_rejects_wrong_pda() { + let program_id = test_program_id(); + let user_id = [99u8; 32]; + let config_seed = spel_framework::pda::seed_from_str("config"); + + let correct_id = + spel_framework::pda::compute_pda(&program_id, &[&config_seed, &user_id]); + let wrong_id = [0xAAu8; 32]; + assert_ne!( + nssa_core::account::AccountId::new(wrong_id), + correct_id, + "test precondition: wrong_id must differ from correct PDA" + ); + + let accounts = vec![ + make_account_with_id(wrong_id, false), // config — wrong address + make_account_with_id([2u8; 32], true), // admin — signer + ]; + + let result = treasury::__validate_create_config( + &accounts, + &program_id, + &empty_ix_data(), + &user_id, + ); + let err = result.expect_err("should reject wrong PDA"); + assert!( + matches!(err, spel_framework::error::SpelError::PdaMismatch { .. }), + "expected PdaMismatch, got: {err:?}" + ); + } + + #[test] + fn validate_create_config_accepts_correct_pda() { + let program_id = test_program_id(); + let user_id = [99u8; 32]; + let config_seed = spel_framework::pda::seed_from_str("config"); + + let correct_id = + spel_framework::pda::compute_pda(&program_id, &[&config_seed, &user_id]); + + let accounts = vec![ + make_account_with_id(*correct_id.value(), false), // config — correct PDA + make_account_with_id([2u8; 32], true), // admin — signer + ]; + + let result = treasury::__validate_create_config( + &accounts, + &program_id, + &empty_ix_data(), + &user_id, + ); + assert!(result.is_ok(), "correct PDA should pass: {result:?}"); + } + + // ── create_ledger (literal + u64 arg + u32 arg) ───────────────── + + #[test] + fn validate_create_ledger_rejects_wrong_pda() { + use spel_framework::pda::ToSeed; + + let program_id = test_program_id(); + let user_id: u64 = 42; + let seq: u32 = 7; + + let correct_id = spel_framework::pda::compute_pda_multi( + &program_id, + &[&"ledger", &user_id, &seq], + ); + let wrong_id = [0xBBu8; 32]; + assert_ne!( + nssa_core::account::AccountId::new(wrong_id), + correct_id, + ); + + let accounts = vec![ + make_account_with_id(wrong_id, false), + make_account_with_id([2u8; 32], true), + ]; + + let result = treasury::__validate_create_ledger( + &accounts, + &program_id, + &empty_ix_data(), + &user_id, + &seq, + ); + let err = result.expect_err("should reject wrong PDA"); + assert!( + matches!(err, spel_framework::error::SpelError::PdaMismatch { .. }), + "expected PdaMismatch, got: {err:?}" + ); + } + + #[test] + fn validate_create_ledger_accepts_correct_pda() { + use spel_framework::pda::ToSeed; + + let program_id = test_program_id(); + let user_id: u64 = 42; + let seq: u32 = 7; + + let correct_id = spel_framework::pda::compute_pda_multi( + &program_id, + &[&"ledger", &user_id, &seq], + ); + + let accounts = vec![ + make_account_with_id(*correct_id.value(), false), + make_account_with_id([2u8; 32], true), + ]; + + let result = treasury::__validate_create_ledger( + &accounts, + &program_id, + &empty_ix_data(), + &user_id, + &seq, + ); + assert!(result.is_ok(), "correct PDA should pass: {result:?}"); + } + + // ── register_entity (String arg + String arg) ─────────────────── + + #[test] + fn validate_register_entity_rejects_wrong_pda() { + use spel_framework::pda::ToSeed; + + let program_id = test_program_id(); + let domain = String::from("gaming"); + let name = String::from("player1"); + + let correct_id = spel_framework::pda::compute_pda_multi( + &program_id, + &[&domain, &name], + ); + let wrong_id = [0xCCu8; 32]; + assert_ne!( + nssa_core::account::AccountId::new(wrong_id), + correct_id, + ); + + let accounts = vec![ + make_account_with_id(wrong_id, false), + make_account_with_id([2u8; 32], true), + ]; + + let result = treasury::__validate_register_entity( + &accounts, + &program_id, + &empty_ix_data(), + &domain, + &name, + ); + let err = result.expect_err("should reject wrong PDA"); + assert!( + matches!(err, spel_framework::error::SpelError::PdaMismatch { .. }), + "expected PdaMismatch, got: {err:?}" + ); + } + + #[test] + fn validate_register_entity_accepts_correct_pda() { + use spel_framework::pda::ToSeed; + + let program_id = test_program_id(); + let domain = String::from("gaming"); + let name = String::from("player1"); + + let correct_id = spel_framework::pda::compute_pda_multi( + &program_id, + &[&domain, &name], + ); + + let accounts = vec![ + make_account_with_id(*correct_id.value(), false), + make_account_with_id([2u8; 32], true), + ]; + + let result = treasury::__validate_register_entity( + &accounts, + &program_id, + &empty_ix_data(), + &domain, + &name, + ); + assert!(result.is_ok(), "correct PDA should pass: {result:?}"); + } + + // ── create_record (account(...) PDA seed) ──────────────────────────────── + + #[test] + fn handler_create_record_callable() { + let acc = make_account(true); + let result = treasury::create_record(acc.clone(), acc.clone()); + assert!(result.is_ok()); + } + + /// Critical regression test: __claims_create_record must encode the *owner's account ID* + /// as the PDA seed, not a hash of the string "owner". Before the fix, Account PDA seeds + /// used seed_from_str(account_name) which is always wrong. + #[test] + fn claims_create_record_encodes_owner_account_id_as_seed() { + let owner_id = [42u8; 32]; + let claims = treasury::__claims_create_record(&owner_id); + + assert_eq!(claims.len(), 2); + + // record (index 0): must be a PDA claim — not None, not Authorized + assert!( + matches!(&claims[0], spel_framework::spel_output::AutoClaim::Claimed(_)), + "record claim should be Claimed(Pda(...)), got: {:?}", + &claims[0] + ); + + // owner (index 1): signer only, not init → no claim + assert!( + matches!(&claims[1], spel_framework::spel_output::AutoClaim::None), + "owner claim should be None, got: {:?}", + &claims[1] + ); + + // The encoded seed must be the owner_id bytes, not seed_from_str("owner"). + let wrong_seed = spel_framework::pda::seed_from_str("owner"); + let wrong_claim = spel_framework::spel_output::AutoClaim::Claimed( + nssa_core::program::Claim::Pda(nssa_core::program::PdaSeed::new(wrong_seed)) + ); + assert_ne!( + claims[0], wrong_claim, + "claim must use the runtime account ID, not seed_from_str(\"owner\")" + ); + + // It must match the claim built from the actual owner_id bytes. + let correct_claim = spel_framework::spel_output::AutoClaim::Claimed( + nssa_core::program::Claim::Pda(nssa_core::program::PdaSeed::new(owner_id)) + ); + assert_eq!(claims[0], correct_claim); + } + + #[test] + fn validate_create_record_accepts_correct_pda() { + let program_id = test_program_id(); + let owner_id = [42u8; 32]; + let correct_pda = spel_framework::pda::compute_pda(&program_id, &[&owner_id]); + + let accounts = vec![ + make_account_with_id(*correct_pda.value(), false), // record — correct PDA + make_account_with_id(owner_id, true), // owner — signer + ]; + + let result = treasury::__validate_create_record(&accounts, &program_id, &empty_ix_data()); + assert!(result.is_ok(), "correct PDA should pass: {result:?}"); + } + + #[test] + fn validate_create_record_rejects_wrong_pda() { + let program_id = test_program_id(); + let owner_id = [42u8; 32]; + + let accounts = vec![ + make_account_with_id([0xFFu8; 32], false), // record — wrong address + make_account_with_id(owner_id, true), // owner — signer + ]; + + let result = treasury::__validate_create_record(&accounts, &program_id, &empty_ix_data()); + let err = result.expect_err("wrong PDA should fail"); + assert!( + matches!(err, spel_framework::error::SpelError::PdaMismatch { .. }), + "expected PdaMismatch, got: {err:?}" + ); + } + + // ── batch_update (rest accounts / ExecuteTransformer arbitrary expression) ── + + #[test] + fn handler_batch_update_callable() { + let acc = make_account(true); + let targets = vec![make_account(false), make_account(false), make_account(false)]; + let result = treasury::batch_update(acc, targets, 42); + assert!(result.is_ok()); + } + + #[test] + fn handler_batch_update_empty_targets() { + let acc = make_account(true); + let result = treasury::batch_update(acc, vec![], 0); + assert!(result.is_ok()); + assert_eq!(result.unwrap().post_states.len(), 1); // only authority + } + + #[test] + fn idl_has_batch_update_instruction() { + let idl = __program_idl(); + let ix = idl.instructions.iter().find(|i| i.name == "batch_update") + .expect("batch_update instruction should be in IDL"); + assert_eq!(ix.args.len(), 1); + assert_eq!(ix.args[0].name, "value"); + } + + /// Tests the ExecuteTransformer rest-branch (arbitrary accounts expression): + /// __claims_batch_update(rest_count) must return 1 + rest_count claims. + #[test] + fn claims_batch_update_rest_count() { + let claims = treasury::__claims_batch_update(3); + assert_eq!(claims.len(), 4); // 1 fixed (authority) + 3 rest (targets) + assert!(matches!(&claims[0], spel_framework::spel_output::AutoClaim::None)); // authority + for claim in &claims[1..] { + assert!(matches!(claim, spel_framework::spel_output::AutoClaim::None)); // targets + } + } + + /// Tests that the rest-branch ExecuteTransformer produces the correct number of + /// post_states, confirming the accounts expression is evaluated and extracted correctly. + #[test] + fn batch_update_post_states_match_account_count() { + let authority = make_account(true); + let targets = vec![make_account(false), make_account(false)]; + let result = treasury::batch_update(authority, targets, 99).unwrap(); + assert_eq!(result.post_states.len(), 3); // authority + 2 targets + } + + // ── output filtering ───────────────────────────────────────────────────── + // The non-owned account filter runs inside the generated `pub fn main()` which is + // `#[cfg(not(test))]`. It cannot be unit-tested here without a full zkVM harness. + // The filter logic (pre_states_clone.zip(post_states).filter(...)) is covered by + // integration/e2e tests that invoke the guest binary end-to-end. + +} 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); +}