diff --git a/.github/workflows/qwen-dispatch.yml b/.github/workflows/qwen-dispatch.yml new file mode 100644 index 0000000..e05a98e --- /dev/null +++ b/.github/workflows/qwen-dispatch.yml @@ -0,0 +1,204 @@ +name: '🔀 Qwen Code Dispatch' + +on: + pull_request_review_comment: + types: + - 'created' + pull_request_review: + types: + - 'submitted' + pull_request: + types: + - 'opened' + issues: + types: + - 'opened' + - 'reopened' + issue_comment: + types: + - 'created' + +defaults: + run: + shell: 'bash' + +jobs: + debugger: + if: |- + ${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }} + runs-on: 'ubuntu-latest' + permissions: + contents: 'read' + steps: + - name: 'Print context for debugging' + env: + DEBUG_event_name: '${{ github.event_name }}' + DEBUG_event__action: '${{ github.event.action }}' + DEBUG_event__comment__author_association: '${{ github.event.comment.author_association }}' + DEBUG_event__issue__author_association: '${{ github.event.issue.author_association }}' + DEBUG_event__pull_request__author_association: '${{ github.event.pull_request.author_association }}' + DEBUG_event__review__author_association: '${{ github.event.review.author_association }}' + DEBUG_event: '${{ toJSON(github.event) }}' + run: |- + env | grep '^DEBUG_' + + dispatch: + # For PRs: only if not from a fork + # For issues: only on open/reopen + # For comments: only if user types @gemini-cli and is OWNER/MEMBER/COLLABORATOR + if: |- + ( + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork == false + ) || ( + github.event_name == 'issues' && + contains(fromJSON('["opened", "reopened"]'), github.event.action) + ) || ( + github.event.sender.type == 'User' && + startsWith(github.event.comment.body || github.event.review.body || github.event.issue.body, '@qwen-code') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association) + ) + runs-on: 'ubuntu-latest' + permissions: + contents: 'read' + issues: 'write' + pull-requests: 'write' + outputs: + command: '${{ steps.extract_command.outputs.command }}' + request: '${{ steps.extract_command.outputs.request }}' + additional_context: '${{ steps.extract_command.outputs.additional_context }}' + issue_number: '${{ github.event.pull_request.number || github.event.issue.number }}' + steps: + - name: 'Mint identity token' + id: 'mint_identity_token' + if: |- + ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ vars.APP_ID }}' + private-key: '${{ secrets.APP_PRIVATE_KEY }}' + permission-contents: 'read' + permission-issues: 'write' + permission-pull-requests: 'write' + + - name: 'Extract command' + id: 'extract_command' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7 + env: + EVENT_TYPE: '${{ github.event_name }}.${{ github.event.action }}' + REQUEST: '${{ github.event.comment.body || github.event.review.body || github.event.issue.body }}' + with: + script: | + const eventType = process.env.EVENT_TYPE; + const request = process.env.REQUEST; + core.setOutput('request', request); + + if (eventType === 'pull_request.opened') { + core.setOutput('command', 'review'); + } else if (['issues.opened', 'issues.reopened'].includes(eventType)) { + core.setOutput('command', 'triage'); + } else if (request.startsWith("@qwen-code /review")) { + core.setOutput('command', 'review'); + const additionalContext = request.replace(/^@qwen-code \/review/, '').trim(); + core.setOutput('additional_context', additionalContext); + } else if (request.startsWith("@qwen-code /triage")) { + core.setOutput('command', 'triage'); + } else if (request.startsWith("@qwen-code")) { + const additionalContext = request.replace(/^@qwen-code/, '').trim(); + core.setOutput('command', 'invoke'); + core.setOutput('additional_context', additionalContext); + } else { + core.setOutput('command', 'fallthrough'); + } + + - name: 'Acknowledge request' + env: + GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' + MESSAGE: |- + 🤖 Hi @${{ github.actor }}, I've received your request, and I'm working on it now! You can track my progress [in the logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. + REPOSITORY: '${{ github.repository }}' + run: |- + gh issue comment "${ISSUE_NUMBER}" \ + --body "${MESSAGE}" \ + --repo "${REPOSITORY}" + + review: + needs: 'dispatch' + if: |- + ${{ needs.dispatch.outputs.command == 'review' }} + uses: './.github/workflows/qwen-review.yml' + permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + pull-requests: 'write' + with: + additional_context: '${{ needs.dispatch.outputs.additional_context }}' + secrets: 'inherit' + + triage: + needs: 'dispatch' + if: |- + ${{ needs.dispatch.outputs.command == 'triage' }} + uses: './.github/workflows/qwen-triage.yml' + permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + pull-requests: 'write' + with: + additional_context: '${{ needs.dispatch.outputs.additional_context }}' + secrets: 'inherit' + + invoke: + needs: 'dispatch' + if: |- + ${{ needs.dispatch.outputs.command == 'invoke' }} + uses: './.github/workflows/qwen-invoke.yml' + permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + pull-requests: 'write' + with: + additional_context: '${{ needs.dispatch.outputs.additional_context }}' + secrets: 'inherit' + + fallthrough: + needs: + - 'dispatch' + - 'review' + - 'triage' + - 'invoke' + if: |- + ${{ always() && !cancelled() && (failure() || needs.dispatch.outputs.command == 'fallthrough') }} + runs-on: 'ubuntu-latest' + permissions: + contents: 'read' + issues: 'write' + pull-requests: 'write' + steps: + - name: 'Mint identity token' + id: 'mint_identity_token' + if: |- + ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ vars.APP_ID }}' + private-key: '${{ secrets.APP_PRIVATE_KEY }}' + permission-contents: 'read' + permission-issues: 'write' + permission-pull-requests: 'write' + + - name: 'Send failure comment' + env: + GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' + MESSAGE: |- + 🤖 I'm sorry @${{ github.actor }}, but I was unable to process your request. Please [see the logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. + REPOSITORY: '${{ github.repository }}' + run: |- + gh issue comment "${ISSUE_NUMBER}" \ + --body "${MESSAGE}" \ + --repo "${REPOSITORY}" diff --git a/.github/workflows/qwen-invoke.yml b/.github/workflows/qwen-invoke.yml new file mode 100644 index 0000000..0d4bdb3 --- /dev/null +++ b/.github/workflows/qwen-invoke.yml @@ -0,0 +1,116 @@ +name: '▶️ Qwen Code Invoke' + +on: + workflow_call: + inputs: + additional_context: + type: 'string' + description: 'Any additional context from the request' + required: false + +concurrency: + group: '${{ github.workflow }}-invoke-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}' + cancel-in-progress: false + +defaults: + run: + shell: 'bash' + +jobs: + invoke: + runs-on: 'ubuntu-latest' + permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + pull-requests: 'write' + steps: + - name: 'Mint identity token' + id: 'mint_identity_token' + if: |- + ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ vars.APP_ID }}' + private-key: '${{ secrets.APP_PRIVATE_KEY }}' + permission-contents: 'read' + permission-issues: 'write' + permission-pull-requests: 'write' + + - name: 'Run Qwen Code CLI' + id: 'run_qwen' + uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + env: + TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' + DESCRIPTION: '${{ github.event.pull_request.body || github.event.issue.body }}' + EVENT_NAME: '${{ github.event_name }}' + GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + IS_PULL_REQUEST: '${{ !!github.event.pull_request }}' + ISSUE_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' + REPOSITORY: '${{ github.repository }}' + ADDITIONAL_CONTEXT: '${{ inputs.additional_context }}' + with: + openai_api_key: '${{ secrets.QWEN_API_KEY }}' + openai_base_url: '${{ vars.QWEN_BASE_URL }}' + openai_model: '${{ vars.QWEN_MODEL }}' + qwen_cli_version: '${{ vars.QWEN_CLI_VERSION }}' + qwen_debug: '${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }}' + upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' + workflow_name: 'qwen-invoke' + settings: |- + { + "model": { + "maxSessionTurns": 25 + }, + "telemetry": { + "enabled": true, + "target": "local", + "outfile": ".qwen/telemetry.log" + }, + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:v0.18.0" + ], + "includeTools": [ + "add_issue_comment", + "get_issue", + "get_issue_comments", + "list_issues", + "search_issues", + "create_pull_request", + "pull_request_read", + "list_pull_requests", + "search_pull_requests", + "create_branch", + "create_or_update_file", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "list_commits", + "push_files", + "search_code" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" + } + } + }, + "tools": { + "core": [ + "run_shell_command(cat)", + "run_shell_command(echo)", + "run_shell_command(grep)", + "run_shell_command(head)", + "run_shell_command(tail)" + ] + } + } + prompt: '/qwen-invoke' diff --git a/.github/workflows/qwen-review.yml b/.github/workflows/qwen-review.yml new file mode 100644 index 0000000..f140e8c --- /dev/null +++ b/.github/workflows/qwen-review.yml @@ -0,0 +1,104 @@ +name: '🔎 Qwen Code Review' + +on: + workflow_call: + inputs: + additional_context: + type: 'string' + description: 'Any additional context from the request' + required: false + +concurrency: + group: '${{ github.workflow }}-review-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +jobs: + review: + runs-on: 'ubuntu-latest' + timeout-minutes: 7 + permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + pull-requests: 'write' + steps: + - name: 'Mint identity token' + id: 'mint_identity_token' + if: |- + ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ vars.APP_ID }}' + private-key: '${{ secrets.APP_PRIVATE_KEY }}' + permission-contents: 'read' + permission-issues: 'write' + permission-pull-requests: 'write' + + - name: 'Checkout repository' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + + - name: 'Run Qwen Code pull request review' + uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + id: 'qwen_pr_review' + env: + GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + ISSUE_TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}' + ISSUE_BODY: '${{ github.event.pull_request.body || github.event.issue.body }}' + PULL_REQUEST_NUMBER: '${{ github.event.pull_request.number || github.event.issue.number }}' + REPOSITORY: '${{ github.repository }}' + ADDITIONAL_CONTEXT: '${{ inputs.additional_context }}' + with: + openai_api_key: '${{ secrets.QWEN_API_KEY }}' + openai_base_url: '${{ vars.QWEN_BASE_URL }}' + openai_model: '${{ vars.QWEN_MODEL }}' + qwen_cli_version: '${{ vars.QWEN_CLI_VERSION }}' + qwen_debug: '${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }}' + upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' + workflow_name: 'qwen-review' + settings: |- + { + "model": { + "maxSessionTurns": 25 + }, + "telemetry": { + "enabled": true, + "target": "local", + "outfile": ".qwen/telemetry.log" + }, + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:v0.18.0" + ], + "includeTools": [ + "add_comment_to_pending_review", + "create_pending_pull_request_review", + "pull_request_read", + "submit_pending_pull_request_review" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" + } + } + }, + "tools": { + "core": [ + "run_shell_command(cat)", + "run_shell_command(echo)", + "run_shell_command(grep)", + "run_shell_command(head)", + "run_shell_command(tail)" + ] + } + } + prompt: '/qwen-review' diff --git a/.github/workflows/qwen-scheduled-triage.yml b/.github/workflows/qwen-scheduled-triage.yml new file mode 100644 index 0000000..930c63d --- /dev/null +++ b/.github/workflows/qwen-scheduled-triage.yml @@ -0,0 +1,208 @@ +name: '📋 Qwen Code Scheduled Issue Triage' + +on: + schedule: + - cron: '0 * * * *' # Runs every hour + pull_request: + branches: + - 'main' + - 'release/**/*' + paths: + - '.github/workflows/qwen-scheduled-triage.yml' + push: + branches: + - 'main' + - 'release/**/*' + paths: + - '.github/workflows/qwen-scheduled-triage.yml' + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +jobs: + triage: + runs-on: 'ubuntu-latest' + timeout-minutes: 7 + permissions: + contents: 'read' + id-token: 'write' + issues: 'read' + pull-requests: 'read' + outputs: + available_labels: '${{ steps.get_labels.outputs.available_labels }}' + triaged_issues: '${{ env.TRIAGED_ISSUES }}' + steps: + - name: 'Get repository labels' + id: 'get_labels' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + with: + # NOTE: we intentionally do not use the minted token. The default + # GITHUB_TOKEN provided by the action has enough permissions to read + # the labels. + script: |- + const labels = []; + for await (const response of github.paginate.iterator(github.rest.issues.listLabelsForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, // Maximum per page to reduce API calls + })) { + labels.push(...response.data); + } + + if (!labels || labels.length === 0) { + core.setFailed('There are no issue labels in this repository.') + } + + const labelNames = labels.map(label => label.name).sort(); + core.setOutput('available_labels', labelNames.join(',')); + core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); + return labelNames; + + - name: 'Find untriaged issues' + id: 'find_issues' + env: + GITHUB_REPOSITORY: '${{ github.repository }}' + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN || github.token }}' + run: |- + echo '🔍 Finding unlabeled issues and issues marked for triage...' + ISSUES="$(gh issue list \ + --state 'open' \ + --search 'no:label label:"status/needs-triage"' \ + --json number,title,body \ + --limit '100' \ + --repo "${GITHUB_REPOSITORY}" + )" + + echo '📝 Setting output for GitHub Actions...' + echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}" + + ISSUE_COUNT="$(echo "${ISSUES}" | jq 'length')" + echo "✅ Found ${ISSUE_COUNT} issue(s) to triage! 🎯" + + - name: 'Run Qwen Code Issue Analysis' + id: 'qwen_issue_analysis' + if: |- + ${{ steps.find_issues.outputs.issues_to_triage != '[]' }} + uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + env: + GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs + ISSUES_TO_TRIAGE: '${{ steps.find_issues.outputs.issues_to_triage }}' + REPOSITORY: '${{ github.repository }}' + AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' + with: + openai_api_key: '${{ secrets.QWEN_API_KEY }}' + openai_base_url: '${{ vars.QWEN_BASE_URL }}' + openai_model: '${{ vars.QWEN_MODEL }}' + qwen_cli_version: '${{ vars.QWEN_CLI_VERSION }}' + qwen_debug: '${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }}' + upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' + workflow_name: 'qwen-scheduled-triage' + settings: |- + { + "model": { + "maxSessionTurns": 25 + }, + "telemetry": { + "enabled": true, + "target": "local", + "outfile": ".qwen/telemetry.log" + }, + "tools": { + "core": [ + "run_shell_command(echo)", + "run_shell_command(jq)", + "run_shell_command(printenv)" + ] + } + } + prompt: '/qwen-scheduled-triage' + + label: + runs-on: 'ubuntu-latest' + needs: + - 'triage' + if: |- + needs.triage.outputs.available_labels != '' && + needs.triage.outputs.available_labels != '[]' && + needs.triage.outputs.triaged_issues != '' && + needs.triage.outputs.triaged_issues != '[]' + permissions: + contents: 'read' + issues: 'write' + pull-requests: 'write' + steps: + - name: 'Mint identity token' + id: 'mint_identity_token' + if: |- + ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ vars.APP_ID }}' + private-key: '${{ secrets.APP_PRIVATE_KEY }}' + permission-contents: 'read' + permission-issues: 'write' + permission-pull-requests: 'write' + + - name: 'Apply labels' + env: + AVAILABLE_LABELS: '${{ needs.triage.outputs.available_labels }}' + TRIAGED_ISSUES: '${{ needs.triage.outputs.triaged_issues }}' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + with: + # Use the provided token so that the "gemini-cli" is the actor in the + # log for what changed the labels. + github-token: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + script: |- + // Parse the available labels + const availableLabels = (process.env.AVAILABLE_LABELS || '').split(',') + .map((label) => label.trim()) + .sort() + + // Parse out the triaged issues + const triagedIssues = (JSON.parse(process.env.TRIAGED_ISSUES || '{}')) + .sort((a, b) => a.issue_number - b.issue_number) + + core.debug(`Triaged issues: ${JSON.stringify(triagedIssues)}`); + + // Iterate over each label + for (const issue of triagedIssues) { + if (!issue) { + core.debug(`Skipping empty issue: ${JSON.stringify(issue)}`); + continue; + } + + const issueNumber = issue.issue_number; + if (!issueNumber) { + core.debug(`Skipping issue with no data: ${JSON.stringify(issue)}`); + continue; + } + + // Extract and reject invalid labels - we do this just in case + // someone was able to prompt inject malicious labels. + let labelsToSet = (issue.labels_to_set || []) + .map((label) => label.trim()) + .filter((label) => availableLabels.includes(label)) + .sort() + + core.debug(`Identified labels to set: ${JSON.stringify(labelsToSet)}`); + + if (labelsToSet.length === 0) { + core.info(`Skipping issue #${issueNumber} - no labels to set.`) + continue; + } + + core.debug(`Setting labels on issue #${issueNumber} to ${labelsToSet.join(', ')} (${issue.explanation || 'no explanation'})`) + + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: labelsToSet, + }); + } diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml new file mode 100644 index 0000000..cfea89d --- /dev/null +++ b/.github/workflows/qwen-triage.yml @@ -0,0 +1,152 @@ +name: '🔀 Qwen Code Triage' + +on: + workflow_call: + inputs: + additional_context: + type: 'string' + description: 'Any additional context from the request' + required: false + +concurrency: + group: '${{ github.workflow }}-triage-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.issue.number }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +jobs: + triage: + runs-on: 'ubuntu-latest' + timeout-minutes: 7 + outputs: + available_labels: '${{ steps.get_labels.outputs.available_labels }}' + selected_labels: '${{ env.SELECTED_LABELS }}' + permissions: + contents: 'read' + id-token: 'write' + issues: 'read' + pull-requests: 'read' + steps: + - name: 'Get repository labels' + id: 'get_labels' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + with: + # NOTE: we intentionally do not use the given token. The default + # GITHUB_TOKEN provided by the action has enough permissions to read + # the labels. + script: |- + const labels = []; + for await (const response of github.paginate.iterator(github.rest.issues.listLabelsForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, // Maximum per page to reduce API calls + })) { + labels.push(...response.data); + } + + if (!labels || labels.length === 0) { + core.setFailed('There are no issue labels in this repository.') + } + + const labelNames = labels.map(label => label.name).sort(); + core.setOutput('available_labels', labelNames.join(',')); + core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); + return labelNames; + + - name: 'Run Qwen Code issue analysis' + id: 'qwen_analysis' + if: |- + ${{ steps.get_labels.outputs.available_labels != '' }} + uses: 'QwenLM/qwen-code-action@v1' # ratchet:exclude + env: + GITHUB_TOKEN: '' # Do NOT pass any auth tokens here since this runs on untrusted inputs + ISSUE_TITLE: '${{ github.event.issue.title }}' + ISSUE_BODY: '${{ github.event.issue.body }}' + AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' + with: + openai_api_key: '${{ secrets.QWEN_API_KEY }}' + openai_base_url: '${{ vars.QWEN_BASE_URL }}' + openai_model: '${{ vars.QWEN_MODEL }}' + qwen_cli_version: '${{ vars.QWEN_CLI_VERSION }}' + qwen_debug: '${{ fromJSON(vars.DEBUG || vars.ACTIONS_STEP_DEBUG || false) }}' + upload_artifacts: '${{ vars.UPLOAD_ARTIFACTS }}' + workflow_name: 'qwen-triage' + settings: |- + { + "model": { + "maxSessionTurns": 25 + }, + "telemetry": { + "enabled": true, + "target": "local", + "outfile": ".qwen/telemetry.log" + }, + "tools": { + "core": [ + "run_shell_command(echo)" + ] + } + } + prompt: '/qwen-triage' + + label: + runs-on: 'ubuntu-latest' + needs: + - 'triage' + if: |- + ${{ needs.triage.outputs.selected_labels != '' }} + permissions: + contents: 'read' + issues: 'write' + pull-requests: 'write' + steps: + - name: 'Mint identity token' + id: 'mint_identity_token' + if: |- + ${{ vars.APP_ID }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ vars.APP_ID }}' + private-key: '${{ secrets.APP_PRIVATE_KEY }}' + permission-contents: 'read' + permission-issues: 'write' + permission-pull-requests: 'write' + + - name: 'Apply labels' + env: + ISSUE_NUMBER: '${{ github.event.issue.number }}' + AVAILABLE_LABELS: '${{ needs.triage.outputs.available_labels }}' + SELECTED_LABELS: '${{ needs.triage.outputs.selected_labels }}' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v7.0.1 + with: + # Use the provided token so that the "gemini-cli" is the actor in the + # log for what changed the labels. + github-token: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}' + script: |- + // Parse the available labels + const availableLabels = (process.env.AVAILABLE_LABELS || '').split(',') + .map((label) => label.trim()) + .sort() + + // Parse the label as a CSV, reject invalid ones - we do this just + // in case someone was able to prompt inject malicious labels. + const selectedLabels = (process.env.SELECTED_LABELS || '').split(',') + .map((label) => label.trim()) + .filter((label) => availableLabels.includes(label)) + .sort() + + // Set the labels + const issueNumber = process.env.ISSUE_NUMBER; + if (selectedLabels && selectedLabels.length > 0) { + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: selectedLabels, + }); + core.info(`Successfully set labels: ${selectedLabels.join(',')}`); + } else { + core.info(`Failed to determine labels to set. There may not be enough information in the issue or pull request.`) + } diff --git a/.gitignore b/.gitignore index d110ba7..c065744 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,12 @@ screenshots/ # Qwen Code configuration (local only) .qwen/ + +# Claude Code configuration (local only) +.claude/ + +# GSD planning artifacts (local only) +.planning/ + +# Legal documents (personal data) +docs/associazione/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6b008e3..cc5dd1a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -39,7 +39,6 @@ android { "\"https://ipfs.io/ipfs/,https://cloudflare-ipfs.com/ipfs/,https://gateway.pinata.cloud/ipfs/\"" ) buildConfigField("String", "API_BASE_URL", "\"https://api.raventag.com\"") - buildConfigField("String", "ADMIN_KEY", "\"\"") } flavorDimensions += "variant" diff --git a/android/app/src/brand/java/io/raventag/app/config/AppConfig.kt b/android/app/src/brand/java/io/raventag/app/config/AppConfig.kt index da86262..6480e4a 100644 --- a/android/app/src/brand/java/io/raventag/app/config/AppConfig.kt +++ b/android/app/src/brand/java/io/raventag/app/config/AppConfig.kt @@ -24,4 +24,40 @@ object AppConfig { /** Whether the Settings screen shows the admin/brand configuration fields */ const val SHOW_BRAND_SETTINGS = true + + /** + * D-09: Hardcoded public ElectrumX fallback pool. Round-robin via + * [io.raventag.app.wallet.health.NodeHealthMonitor]. + * + * Researched 2026-04 from: + * - github.com/Electrum-RVN-SIG/electrum-ravencoin servers.json (3 hosts) + * - rvn4lyfe.com operator-hosted (confirms 4th host 51.222.139.25) + * + * Note: "rvn-dashboard.com" may rotate off SSL in the future; quarantine + * handles silently (D-11, 1h quarantine on TOFU mismatch). If a future + * community list expands coverage, add hosts here (no user-configurable + * list in v1, deferred to a later "power user" phase). + * + * Current count: 4 (marginal per RESEARCH Pitfall 8; a single cert + * rotation leaves 3 operational which is acceptable for D-09). + */ + val ELECTRUM_SERVERS: List> = listOf( + // Verified live (probed via server.version + headers.subscribe). The bare + // IPs that used to live here pointed to the same hosts as the rvn4lyfe.com / + // rvn-dashboard.com domains and were dropped after the cert rotation. Cipig + // (KomodoPlatform) operates the three "rvn.electrumN.cipig.net" mirrors + // and they are listed in coins_config.json. + "rvn4lyfe.com" to 50002, + "rvn-dashboard.com" to 50002, + "rvn.electrum1.cipig.net" to 20051, + "rvn.electrum2.cipig.net" to 20051, + "rvn.electrum3.cipig.net" to 20051, + ) + + /** + * Block explorer URL prefix for Ravencoin transactions (D-19). + * Appending a txid yields a browsable transaction page, e.g. `${EXPLORER_URL}`. + * Verified 2026-04 against Ravencoin mainnet (community explorer). + */ + const val EXPLORER_URL: String = "https://ravencoin.network/tx/" } diff --git a/android/app/src/consumer/java/io/raventag/app/config/AppConfig.kt b/android/app/src/consumer/java/io/raventag/app/config/AppConfig.kt index 57c26d0..ef9acf5 100644 --- a/android/app/src/consumer/java/io/raventag/app/config/AppConfig.kt +++ b/android/app/src/consumer/java/io/raventag/app/config/AppConfig.kt @@ -35,4 +35,42 @@ object AppConfig { /** Consumer app does not show brand/admin configuration in Settings */ const val SHOW_BRAND_SETTINGS = false + + /** + * D-09: Hardcoded public ElectrumX fallback pool. Round-robin via + * [io.raventag.app.wallet.health.NodeHealthMonitor]. + * + * Researched 2026-04 from: + * - github.com/Electrum-RVN-SIG/electrum-ravencoin servers.json (3 hosts) + * - rvn4lyfe.com operator-hosted (confirms 4th host 51.222.139.25) + * + * Note: "rvn-dashboard.com" may rotate off SSL in the future; quarantine + * handles silently (D-11, 1h quarantine on TOFU mismatch). If a future + * community list expands coverage, add hosts here (no user-configurable + * list in v1, deferred to a later "power user" phase). + * + * Current count: 4 (marginal per RESEARCH Pitfall 8; a single cert + * rotation leaves 3 operational which is acceptable for D-09). + */ + val ELECTRUM_SERVERS: List> = listOf( + // Verified live (probed via server.version + headers.subscribe). The bare + // IPs that used to live here pointed to the same hosts as the rvn4lyfe.com / + // rvn-dashboard.com domains and were dropped after the cert rotation. Cipig + // (KomodoPlatform) operates the three "rvn.electrumN.cipig.net" mirrors + // and they are listed in coins_config.json. + "rvn4lyfe.com" to 50002, + "rvn-dashboard.com" to 50002, + "rvn.electrum1.cipig.net" to 20051, + "rvn.electrum2.cipig.net" to 20051, + "rvn.electrum3.cipig.net" to 20051, + ) + + /** + * Block explorer URL prefix for Ravencoin transactions (D-19). + * Appending a txid yields a browsable transaction page, e.g. `${EXPLORER_URL}`. + * Verified 2026-04 against Ravencoin mainnet (community explorer). + * If the explorer rotates in the future, update here: no runtime override is + * exposed in v1 (deferred to a later "power user" phase). + */ + const val EXPLORER_URL: String = "https://ravencoin.network/tx/" } diff --git a/android/app/src/main/java/io/raventag/app/MainActivity.kt b/android/app/src/main/java/io/raventag/app/MainActivity.kt index 098b0b3..1768ccd 100644 --- a/android/app/src/main/java/io/raventag/app/MainActivity.kt +++ b/android/app/src/main/java/io/raventag/app/MainActivity.kt @@ -28,6 +28,7 @@ import android.content.Intent import android.nfc.NfcAdapter import android.os.Bundle import android.util.Log +import io.raventag.app.utils.RetryUtils import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -38,19 +39,28 @@ import androidx.fragment.app.FragmentActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import io.raventag.app.nfc.NfcCounterCache @@ -82,8 +92,11 @@ import io.raventag.app.ui.theme.stringsZh import io.raventag.app.wallet.AssetIssueParams import io.raventag.app.wallet.AssetManager import io.raventag.app.wallet.BurnParams +import io.raventag.app.wallet.RavencoinPublicNode +import io.raventag.app.wallet.RavencoinTxBuilder import io.raventag.app.wallet.SubAssetIssueParams import io.raventag.app.wallet.WalletManager +import io.raventag.app.security.AdminKeyStorage import io.raventag.app.config.AppConfig import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey @@ -93,6 +106,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import android.Manifest import android.content.pm.PackageManager @@ -102,6 +117,7 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import io.raventag.app.worker.NotificationHelper +import io.raventag.app.worker.TransactionNotificationHelper import io.raventag.app.worker.WalletPollingWorker import java.util.concurrent.TimeUnit @@ -109,6 +125,24 @@ import java.util.concurrent.TimeUnit // ViewModel // ============================================================ +sealed class IssueStep { + object Idle : IssueStep() + data class InProgress(val step: StepName) : IssueStep() + data class Success(val step: StepName) : IssueStep() + data class Failed(val step: StepName, val error: String, val canRetry: Boolean) : IssueStep() + + enum class StepName { + IPFS_UPLOAD, + BALANCE_CHECK, + NAME_CHECK, + ISSUING, + CONFIRMING, + NFC_PROGRAMMING + } +} + +enum class WarningType { INSUFFICIENT_BALANCE, DUPLICATE_NAME } + /** * MainViewModel holds all application state and drives the coroutine-heavy * business logic (NFC verification, asset issuance, wallet operations). @@ -123,6 +157,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { // A counter that does not increase is a sign of tag cloning. private val nfcCounterCache = NfcCounterCache(application) + /** Encrypted storage for the admin key; injected via [initWallet]. */ + internal var adminKeyStorage: AdminKeyStorage? = null + /** AES-128 key used to decrypt the SUN encrypted UID field (sdmmac input key). */ var sdmmacKey: ByteArray = ByteArray(16) @@ -151,6 +188,55 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** Human-readable error message for display in the UI (null = no error). */ var errorMessage by mutableStateOf(null) + // ── Async error display state (per 20-UI-SPEC.md Error State Patterns) ───── + // These two properties back the top-level banner + dialog shown by + // [RavenTagApp]. Transient errors (timeout, network) auto-dismiss after a + // few seconds; critical errors are modal and require an explicit OK tap. + + /** Transient error message shown as a dismissible banner with a Retry action. */ + var transientError by mutableStateOf(null) + + /** Critical error shown as a modal AlertDialog requiring user intervention. */ + var criticalError by mutableStateOf(null) + + /** + * Classify [throwable] via [RetryUtils.isTransientError] and surface it to the + * user through the appropriate UI pattern. Transient failures trigger a banner + * that auto-dismisses after 5 seconds; anything else becomes a modal dialog + * so the user explicitly acknowledges the failure. + */ + fun reportAsyncError(throwable: Throwable, prefix: String? = null) { + val full = if (prefix != null) "$prefix: ${throwable.message ?: "Unknown error"}" else (throwable.message ?: "Unknown error") + val isTransient = throwable is Exception && RetryUtils.isTransientError(throwable) + if (isTransient) showTransientError(full) else showCriticalError(full) + } + + /** Show a transient error banner that auto-dismisses after 5 seconds. */ + fun showTransientError(message: String) { + transientError = message + viewModelScope.launch { + kotlinx.coroutines.delay(5000) + // Only clear if the user has not already dismissed (value could have + // changed to a newer message during the delay). + if (transientError == message) transientError = null + } + } + + /** Show a critical error dialog that requires explicit dismissal. */ + fun showCriticalError(message: String) { + criticalError = message + } + + /** Clear the transient error banner (called from the banner Dismiss button). */ + fun clearTransientError() { + transientError = null + } + + /** Clear the critical error dialog (called from the dialog OK button). */ + fun clearCriticalError() { + criticalError = null + } + /** Backend base URL used for revocation checks and tag verification calls. */ var currentVerifyUrl by mutableStateOf(BuildConfig.API_BASE_URL) @@ -171,6 +257,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** True while mnemonic entropy is being generated (prevents double-click). */ var walletGenerating by mutableStateOf(false) + /** Error from the last failed wallet restore (invalid mnemonic, network failure). */ + var restoreError by mutableStateOf(null) + + + // ── Transaction details (per D-04) ──────────────────────────────────────── + + /** Transaction ID for transaction details screen (per D-04) */ + var viewingTxid by mutableStateOf(null) + + /** True when viewing transaction details overlay */ + var isViewingTransaction by mutableStateOf(false) + // ── Issue / revoke / register / transfer state ──────────────────────────── /** Currently active issue/revoke/transfer mode (null = no overlay shown). */ @@ -185,6 +283,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** True = last operation succeeded, false = failed, null = not yet run. */ var issueSuccess by mutableStateOf(null) + /** Phase 40: Current step in the multi-step issuance flow. */ + var issueStep by mutableStateOf(IssueStep.Idle) + + /** Phase 40: Transaction ID of the most recently issued asset (for explorer link). */ + var issuedTxid by mutableStateOf(null) + + /** Phase 40: Inline pre-issuance warning type (null = no warning). */ + var warningType by mutableStateOf(null) + /** nfc_pub_id returned by a chip registration response (shown in the result UI). */ var registerNfcPubId by mutableStateOf(null) @@ -220,6 +327,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return appStringsFor(langCode) } + fun handleViewTransactionIntent(txid: String) { + viewingTxid = txid + isViewingTransaction = true + } + /** Admin or operator key passed to the backend during chip registration. */ var writeTagAdminKey = "" @@ -313,7 +425,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { electrumStatus = ElectrumStatus.CHECKING viewModelScope.launch { val ok = withContext(Dispatchers.IO) { - try { io.raventag.app.wallet.RavencoinPublicNode().ping() } catch (_: Exception) { false } + try { io.raventag.app.wallet.RavencoinPublicNode(getApplication()).ping() } catch (_: Exception) { false } } electrumStatus = if (ok) ElectrumStatus.ONLINE else ElectrumStatus.OFFLINE } @@ -328,10 +440,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun fetchBlockHeight() { viewModelScope.launch { val h = withContext(Dispatchers.IO) { - try { io.raventag.app.wallet.RavencoinPublicNode().getBlockHeight() } + try { io.raventag.app.wallet.RavencoinPublicNode(getApplication()).getBlockHeight() } catch (_: Exception) { null } } - if (h != null) blockHeight = h + if (h != null) { + blockHeight = h + // Persist so the next cold start renders the chain-tip pill instantly + try { io.raventag.app.wallet.cache.WalletCacheDao.writeBlockHeight(h) } catch (_: Throwable) {} + } } } @@ -437,6 +553,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Validate the admin key against the backend using OkHttp. + * + * Makes an actual API call to the backend validation endpoint and returns + * the validation status. This is a suspend function intended to be called + * from coroutines. + * + * @param key The admin key to validate. + * @param apiBaseUrl The backend API base URL. + * @return The validation status (VALID, INVALID, WRONG_TYPE, or INVALID on error). + */ + suspend fun validateAdminKey(key: String, apiBaseUrl: String): AdminKeyStatus { + adminKeyStatus = AdminKeyStatus.CHECKING + return try { + val client = OkHttpClient() + val request = Request.Builder() + .url("$apiBaseUrl/api/admin/validate-key") + .header("X-Admin-Key", key) + .get() + .build() + val response = client.newCall(request).execute() + when (response.code) { + 200 -> AdminKeyStatus.VALID + 401 -> AdminKeyStatus.INVALID + 403 -> AdminKeyStatus.WRONG_TYPE + else -> AdminKeyStatus.INVALID + } + } catch (e: Exception) { + AdminKeyStatus.INVALID + } + } + // ── Operator key validation ─────────────────────────────────────────────── /** @@ -529,7 +677,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { var txHistoryLoadedCount by mutableStateOf(0) /** Page size for loading more transactions. */ - private val txHistoryPageSize = 15 + private val txHistoryPageSize = 20 // ── Asset portfolio ─────────────────────────────────────────────────────── @@ -545,6 +693,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** True if the last asset-list fetch failed with an error. */ var assetsLoadError by mutableStateOf(false) + /** True when portfolio scan found funds on old addresses that need consolidation. */ + var needsConsolidation by mutableStateOf(false) + + /** True while consolidation is in progress (prevents banner from reappearing). */ + var consolidationInProgress by mutableStateOf(false) + /** * Load the asset portfolio for the wallet address. * @@ -553,32 +707,141 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * Phase 2: enrich each asset with IPFS metadata in parallel (max 4 concurrent * requests via a semaphore) and update the list progressively. */ + private fun saveAssetsCache(assets: List) { + try { + val addr = walletManager?.getCurrentAddress() ?: return + val json = com.google.gson.Gson().toJson(assets) + getApplication().getSharedPreferences("raventag_assets_cache", Application.MODE_PRIVATE) + .edit().putString(addr, json).apply() + } catch (_: Exception) {} + } + + private fun loadAssetsCache(): List? { + return try { + val addr = walletManager?.getCurrentAddress() ?: return null + val prefs = getApplication().getSharedPreferences("raventag_assets_cache", Application.MODE_PRIVATE) + val json = prefs.getString(addr, null) ?: return null + val type = object : com.google.gson.reflect.TypeToken>() {}.type + com.google.gson.Gson().fromJson>(json, type) + } catch (_: Exception) { null } + } + fun loadOwnedAssets() { - val address = walletManager?.getAddress() ?: return + val wm = walletManager ?: return + + // Don't reset consolidation flag if consolidation is in progress + if (!consolidationInProgress) { + needsConsolidation = false + } + viewModelScope.launch { - assetsLoading = true assetsLoadError = false + + // Show the spinner only when there is nothing to display yet. + // If assets are already on screen (from cache or a previous load), refresh + // silently in the background so the list never flashes or shows a spinner. + if (ownedAssets.isNullOrEmpty()) { + val cached = withContext(Dispatchers.IO) { loadAssetsCache() } + if (!cached.isNullOrEmpty()) { + ownedAssets = cached + } else { + assetsLoading = true + } + } + try { - // 1. Fetch raw balances first - this is one fast call - val basic = withContext(Dispatchers.IO) { rpcClient.listAssetsByAddress(address) } + // One Keystore decrypt + one pipelined batch for all asset balances. + val basic = withContext(Dispatchers.IO) { + val currentIndex = wm.getCurrentAddressIndex() + val addresses = wm.getAddressBatch(0, 0..currentIndex).values.toList() + val node = io.raventag.app.wallet.RavencoinPublicNode(getApplication()) + + // Fetch both asset balances and RVN balance in parallel + val (totals, _) = coroutineScope { + val assetsDeferred = async { node.getTotalAssetBalances(addresses) } + val rvnDeferred = async { + try { node.getTotalBalance(addresses) } catch (_: Exception) { 0.0 } + } + + Pair(assetsDeferred.await(), rvnDeferred.await()) + } + + // Check if any old address still holds CONSOLIDATABLE funds. + // Skip dust-only RVN residues (< 0.001 RVN) — common after a sweep + // when ElectrumX leaves a few hundred sat as a mempool change leftover. + if (currentIndex > 0) { + val oldAddresses = wm.getAddressBatch(0, 0 until currentIndex).values.toList() + val funded = try { + node.getAddressesWithSignificantFunds(oldAddresses, minRvnSat = 100_000L) + } catch (_: Exception) { emptySet() } + needsConsolidation = funded.isNotEmpty() + } + + totals.map { (name, amount) -> + val type = when { + name.contains('#') -> io.raventag.app.ravencoin.AssetType.UNIQUE + name.contains('/') -> io.raventag.app.ravencoin.AssetType.SUB + else -> io.raventag.app.ravencoin.AssetType.ROOT + } + io.raventag.app.ravencoin.OwnedAsset( + name = name, + balance = amount, + type = type, + ipfsHash = null + ) + }.sortedWith(compareBy({ it.type.ordinal }, { it.name })) + } - // Show balances IMMEDIATELY - ownedAssets = basic + // Merge balances with already-loaded metadata so images never disappear on refresh. + // IPFS content is immutable: same CID always serves the same image, so cached + // imageUrl and description can be reused without re-fetching. + val previous = ownedAssets?.associateBy { it.name } ?: emptyMap() + val merged = basic.map { asset -> + val prev = previous[asset.name] + if (prev?.imageUrl != null) { + asset.copy(ipfsHash = prev.ipfsHash, imageUrl = prev.imageUrl, description = prev.description) + } else { + asset + } + } + // Avoid wiping the visible asset list when a transient network error + // returns an empty `basic`. Keep the previous list visible. + if (merged.isNotEmpty() || ownedAssets.isNullOrEmpty()) { + ownedAssets = merged + saveAssetsCache(merged) + } assetsLoading = false - // 2. Fetch all metadata (blockchain IPFS hash + IPFS JSON) in parallel - // Max 3 concurrent IPFS requests to avoid gateway rate limiting - val semaphore = Semaphore(3) - basic.forEach { asset -> - // Launch a separate coroutine per asset enrichment for maximum reactivity - viewModelScope.launch(Dispatchers.IO) { + // Only fetch metadata for assets not yet enriched. + val needsEnrichment = merged.filter { it.imageUrl == null } + if (needsEnrichment.isEmpty()) return@launch + + // Pre-fetch IPFS hashes for un-enriched assets in one batch RPC call. + val withHashes = withContext(Dispatchers.IO) { + val node = io.raventag.app.wallet.RavencoinPublicNode(getApplication()) + val names = needsEnrichment.map { it.name } + val metaBatch = try { node.getAssetMetaBatch(names) } catch (_: Exception) { emptyMap() } + needsEnrichment.map { asset -> + val hash = metaBatch[asset.name]?.ipfsHash + if (hash != null) asset.copy(ipfsHash = hash) else asset + } + } + // Update only the un-enriched entries with their hashes. + ownedAssets = ownedAssets?.map { existing -> + withHashes.find { it.name == existing.name } ?: existing + } + + // Fetch IPFS metadata in parallel only for assets that still need it. + // Using async instead of launch so we can await completion and update the cache. + val semaphore = Semaphore(8) + val enrichmentJobs = withHashes.map { asset -> + viewModelScope.async(Dispatchers.IO) { try { semaphore.withPermit { val enriched = rpcClient.enrichWithIpfsData(asset) - // Update UI as EACH item finishes independently withContext(Dispatchers.Main) { - ownedAssets = ownedAssets?.map { - if (it.name == enriched.name) enriched else it + ownedAssets = ownedAssets?.map { + if (it.name == enriched.name) enriched else it } } } @@ -588,7 +851,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - Log.d("MainActivity", "loadOwnedAssets: background enrichment started for ${basic.size} assets") + // Save cache once all enrichments complete so images survive the next startup. + viewModelScope.launch { + enrichmentJobs.awaitAll() + val current = ownedAssets + if (!current.isNullOrEmpty()) { + withContext(Dispatchers.IO) { saveAssetsCache(current) } + } + Log.d("MainActivity", "loadOwnedAssets: enrichment done, cache updated with images") + } } catch (e: Exception) { Log.e("MainActivity", "loadOwnedAssets failed", e) assetsLoadError = true @@ -603,26 +874,69 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { */ fun loadTransactionHistory() { val wm = walletManager ?: return - val address = wm.getAddress() ?: return txHistoryLoading = true viewModelScope.launch { try { - // Fetch total count first - val totalCount = withContext(Dispatchers.IO) { - io.raventag.app.wallet.RavencoinPublicNode().getTransactionCount(address) + val currentIndex = wm.getCurrentAddressIndex() + val node = io.raventag.app.wallet.RavencoinPublicNode(getApplication()) + + // One Keystore decrypt for all addresses, then parallel ElectrumX queries. + // Include currentIndex+1 (change address) in the owned set so cycled outputs + // are correctly classified as "back to wallet" instead of "sent to others". + val allHistory = withContext(Dispatchers.IO) { + val addresses = wm.getAddressBatch(0, 0..(currentIndex + 1)) + val ownedSet = addresses.values.toSet() + val deferreds = addresses.values.map { addr -> + async { + try { node.getTransactionHistory(addr, limit = txHistoryPageSize, ownedAddresses = ownedSet) } + catch (_: Throwable) { emptyList() } + } + } + deferreds.awaitAll().flatten() } - txHistoryTotal = totalCount - // Fetch first page - val history = withContext(Dispatchers.IO) { - io.raventag.app.wallet.RavencoinPublicNode().getTransactionHistory( - address, - limit = txHistoryPageSize, - offset = 0 + // Deduplicate by txid (same tx may appear in multiple address histories) + val deduped = allHistory.distinctBy { it.txid } + .sortedWith( + compareByDescending { + if (it.height <= 0) Int.MAX_VALUE else it.height + }.thenByDescending { it.timestamp } ) + + // Avoid wiping the visible list when a transient network error + // returns an empty result during a refresh. Initial display caps + // at txHistoryPageSize (Load more pulls successive pages). + if (deduped.isNotEmpty() || txHistory.isEmpty()) { + val firstPage = deduped.take(txHistoryPageSize) + txHistory = firstPage + txHistoryTotal = deduped.size + txHistoryLoadedCount = firstPage.size + } + // Persist so the next cold start renders the list instantly + // from cache instead of waiting for the network round-trip. + if (deduped.isNotEmpty()) { + withContext(Dispatchers.IO) { + try { + val now = System.currentTimeMillis() + val rows = deduped.map { e -> + io.raventag.app.wallet.cache.TxHistoryDao.TxHistoryRow( + txid = e.txid, + height = e.height, + confirms = e.confirmations, + amountSat = e.amountSat, + sentSat = e.sentSat, + cycledSat = e.cycledSat, + feeSat = e.feeSat, + isIncoming = e.isIncoming, + isSelf = e.isSelfTransfer, + timestamp = e.timestamp, + cachedAt = now + ) + } + io.raventag.app.wallet.cache.TxHistoryDao.upsert(rows) + } catch (_: Throwable) {} + } } - txHistory = history - txHistoryLoadedCount = history.size } catch (_: Throwable) { // silently ignore: tx history is optional } finally { @@ -633,30 +947,33 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** * Load more transactions (next page). - * Called when user taps "Load More" button. + * With multi-address aggregation, all transactions are loaded at once, + * so this is a no-op for now. */ fun loadMoreTransactions() { - val wm = walletManager ?: return - val address = wm.getAddress() ?: return - - // Check if we've loaded all transactions already if (txHistoryLoadedCount >= txHistoryTotal) return - + + val wm = walletManager ?: return + val address = wm.getCurrentAddress() ?: return + viewModelScope.launch { try { + val currentIndex = wm.getCurrentAddressIndex() + val ownedSet = withContext(Dispatchers.IO) { + wm.getAddressBatch(0, 0..(currentIndex + 1)).values.toSet() + } val history = withContext(Dispatchers.IO) { - io.raventag.app.wallet.RavencoinPublicNode().getTransactionHistory( + io.raventag.app.wallet.RavencoinPublicNode(getApplication()).getTransactionHistory( address, limit = txHistoryPageSize, - offset = txHistoryLoadedCount + offset = txHistoryLoadedCount, + ownedAddresses = ownedSet ) } - - // Append new transactions to existing list + txHistory = txHistory + history txHistoryLoadedCount += history.size } catch (_: Throwable) { - // silently ignore: tx history is optional } } } @@ -729,11 +1046,69 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * Inject wallet and asset managers after they are created in [MainActivity.onCreate]. * If a wallet already exists, immediately loads balance and owned assets. */ - fun initWallet(wm: WalletManager, am: AssetManager) { + fun initWallet(wm: WalletManager, am: AssetManager, aks: AdminKeyStorage) { walletManager = wm assetManager = am + adminKeyStorage = aks hasWallet = wm.hasWallet() - if (hasWallet) { loadWalletInfo(); loadOwnedAssets() } + // Synchronously seed walletInfo + cached assets from on-disk cache BEFORE the + // first UI render so the screen never flashes "0 RVN / no assets" while the + // async load runs. Network refresh kicks in via loadWalletInfo right after. + if (hasWallet && walletInfo == null) { + try { + val cachedState = io.raventag.app.wallet.cache.WalletCacheDao.readState() + val cachedAddr = try { wm.getCurrentAddress() } catch (_: Throwable) { null }.orEmpty() + walletInfo = WalletInfo( + address = cachedAddr, + balanceRvn = cachedState?.balanceSat?.let { it / 1e8 }, + isLoading = true + ) + // Seed block height from cache so the chain-tip pill renders instantly + if (cachedState != null && cachedState.blockHeight > 0) { + blockHeight = cachedState.blockHeight + } + val cachedTx = io.raventag.app.wallet.cache.TxHistoryDao.getPage(offset = 0, limit = 50) + if (cachedTx.isNotEmpty()) { + txHistory = cachedTx.map { row -> + io.raventag.app.wallet.TxHistoryEntry( + txid = row.txid, + height = row.height, + confirmations = row.confirms, + amountSat = row.amountSat, + sentSat = row.sentSat, + cycledSat = row.cycledSat, + feeSat = row.feeSat, + isIncoming = row.isIncoming, + isSelfTransfer = row.isSelf, + timestamp = row.timestamp + ) + } + txHistoryTotal = txHistory.size + txHistoryLoadedCount = txHistory.size + } + val cachedAssets = loadAssetsCache() + if (!cachedAssets.isNullOrEmpty()) { + ownedAssets = cachedAssets + } + } catch (_: Throwable) {} + loadWalletInfo() + } + startHealthHeartbeat() + } + + // 30s heartbeat: keep the ElectrumX pill fresh between wallet refreshes so + // transient disconnects surface quickly without waiting for the next user action. + private var heartbeatStarted = false + private fun startHealthHeartbeat() { + if (heartbeatStarted) return + heartbeatStarted = true + viewModelScope.launch(Dispatchers.IO) { + val node = io.raventag.app.wallet.RavencoinPublicNode(getApplication()) + while (true) { + try { node.heartbeat() } catch (_: Exception) {} + delay(30_000L) + } + } } /** Delete the wallet from secure storage and clear all wallet state. */ @@ -741,6 +1116,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { walletManager?.deleteWallet() hasWallet = false walletInfo = null + // Reset all dependent UI state so the next restore-after-delete does NOT + // see stale balance / assets / tx history (which would falsely trigger + // the "Replace current wallet?" gate). + ownedAssets = null + txHistory = emptyList() + txHistoryTotal = 0 + txHistoryLoadedCount = 0 + needsConsolidation = false + try { saveAssetsCache(emptyList()) } catch (_: Throwable) {} } /** @@ -765,7 +1149,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { pendingMnemonic = mnemonic // wallet is NOT yet stored, hasWallet stays false } catch (e: Throwable) { - walletInfo = WalletInfo(address = "", balanceRvn = 0.0, error = "Wallet creation failed: ${e.message}") + walletInfo = WalletInfo(address = "", balanceRvn = null, error = "Wallet creation failed: ${e.message}") } finally { walletGenerating = false } @@ -782,10 +1166,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { val address = withContext(Dispatchers.Default) { wm.finalizeWallet(mnemonic) - wm.getAddress() ?: "" + wm.getCurrentAddress() ?: "" } hasWallet = true - walletInfo = WalletInfo(address = address, balanceRvn = 0.0, isLoading = true) + walletInfo = WalletInfo(address = address, balanceRvn = null, isLoading = true) pendingMnemonic = null loadWalletBalance() } @@ -795,26 +1179,73 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * Restore a wallet from a BIP39 mnemonic phrase. * On success, loads balance, assets, and transaction history. * On failure, sets an error message in [walletInfo]. + * + * Guards against double-click with [walletGenerating]. + * Runs BIP44 address discovery before loading balance to ensure correct index. */ fun restoreWallet(mnemonic: String) { val wm = walletManager ?: return + if (walletGenerating) return viewModelScope.launch { + walletGenerating = true + // Hide any stale wallet UI and clear previous errors during discovery. + // hasWallet stays false until the correct index is found, so address and + // balance are never shown before discovery completes. + hasWallet = false + walletInfo = null + restoreError = null try { - val address = withContext(Dispatchers.Default) { - if (!wm.restoreWallet(mnemonic)) return@withContext null - wm.getAddress() + val restored = withContext(Dispatchers.Default) { + wm.restoreWallet(mnemonic) } - if (address != null) { - hasWallet = true - walletInfo = WalletInfo(address = address, balanceRvn = 0.0, isLoading = true) - loadWalletBalance() - loadOwnedAssets() - loadTransactionHistory() - } else { - walletInfo = WalletInfo(address = "", balanceRvn = 0.0, error = "Invalid mnemonic") + if (!restored) { + restoreError = "Invalid mnemonic" + return@launch + } + + // Discover the correct address index on the blockchain. + // isGenerating stays true so WalletSetupCard shows a spinner, not the form. + try { + wm.discoverCurrentIndex() + } catch (_: Exception) { + Log.w("MainActivity", "discoverCurrentIndex failed, using index 0") } + + val address = wm.getCurrentAddress() ?: "" + walletInfo = WalletInfo(address = address, balanceRvn = null, isLoading = true) + hasWallet = true + + // Parallel restore: load balance, assets, and history simultaneously + coroutineScope { + val balanceDeferred = async(Dispatchers.IO) { + RetryUtils.retryWithBackoff { + loadWalletBalanceInternal(wm) + } + } + + val assetsDeferred = async(Dispatchers.IO) { + RetryUtils.retryWithBackoff { + loadOwnedAssetsInternal(wm) + } + } + + val historyDeferred = async(Dispatchers.IO) { + RetryUtils.retryWithBackoff { + loadTransactionHistoryInternal(wm) + } + } + + // Wait for all three operations to complete + awaitAll(balanceDeferred, assetsDeferred, historyDeferred) + } + + walletInfo = walletInfo?.copy(isLoading = false) } catch (e: Throwable) { - walletInfo = WalletInfo(address = "", balanceRvn = 0.0, error = "Restore failed: ${e.message}") + restoreError = "Restore failed: ${e.message}" + walletInfo = walletInfo?.copy(isLoading = false) + Log.e("MainViewModel", "Wallet restore failed", e) + } finally { + walletGenerating = false } } } @@ -822,13 +1253,212 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** Initialise [walletInfo] with the address and start loading balance + history. */ private fun loadWalletInfo() { val wm = walletManager ?: return - walletInfo = WalletInfo(address = wm.getAddress() ?: "", balanceRvn = 0.0, isLoading = true) - loadWalletBalance() - loadTransactionHistory() + // Preserve existing data while refreshing so the UI never flashes 0. + // On a cold start (walletInfo==null) seed from the persistent cache so the + // user sees their last-known balance/address immediately instead of zero. + if (walletInfo == null) { + val cachedState = try { + io.raventag.app.wallet.cache.WalletCacheDao.readState() + } catch (_: Throwable) { null } + val cachedAddr = try { wm.getCurrentAddress() } catch (_: Throwable) { null }.orEmpty() + walletInfo = WalletInfo( + address = cachedAddr, + balanceRvn = cachedState?.balanceSat?.let { it / 1e8 }, + isLoading = true + ) + // Seed block height from cache so the chain-tip pill renders instantly + if (cachedState != null && cachedState.blockHeight > 0) { + blockHeight = cachedState.blockHeight + } + // Seed tx history from cache as well so the section is populated on resume. + try { + val cachedTx = io.raventag.app.wallet.cache.TxHistoryDao.getPage(offset = 0, limit = 50) + if (cachedTx.isNotEmpty()) { + val mapped = cachedTx.map { row -> + io.raventag.app.wallet.TxHistoryEntry( + txid = row.txid, + height = row.height, + confirmations = row.confirms, + amountSat = row.amountSat, + sentSat = row.sentSat, + cycledSat = row.cycledSat, + feeSat = row.feeSat, + isIncoming = row.isIncoming, + isSelfTransfer = row.isSelf, + timestamp = row.timestamp + ) + } + txHistory = mapped + txHistoryTotal = mapped.size + txHistoryLoadedCount = mapped.size + } + } catch (_: Throwable) {} + } else { + walletInfo = walletInfo?.copy(isLoading = true) + } + + viewModelScope.launch { + // STEP 1: Load balance + assets + tx history immediately from the stored index. + // Do NOT wait for reconcile/sweep: users see data in seconds instead of 30+ s. + // getLocalBalance() uses getAddressBatch() internally: one Keystore decrypt, then + // parallel ElectrumX balance queries. + val balanceDeferred = async { wm.getLocalBalance() } + loadOwnedAssets() + loadTransactionHistory() + + val balance = balanceDeferred.await() + // cachedAddress is populated by getAddressBatch() inside getLocalBalance(). + val address = withContext(Dispatchers.IO) { wm.getCurrentAddress() ?: "" } + walletInfo = walletInfo?.copy( + // Keep existing address/balance if new load fails (network error) + address = address.ifEmpty { walletInfo?.address ?: "" }, + balanceRvn = balance ?: walletInfo?.balanceRvn, + isLoading = false + ) + + // Persist the just-fetched balance so the next cold start can render + // it instantly from cache instead of showing "Loading…" again. + if (balance != null) { + try { + io.raventag.app.wallet.cache.WalletCacheDao.writeBalanceSat( + (balance * 1e8).toLong() + ) + } catch (_: Throwable) {} + } + + // STEP 2: Background maintenance (does not block the UI). + launch(Dispatchers.IO) { + + // Auto-discovery: run when index is 0 and balance is 0 or null. + // This handles the case where the stored index was lost/reset but + // the user has funds at a higher address index. + val currentIdx = wm.getCurrentAddressIndex() + if (currentIdx == 0 && (balance == null || balance == 0.0)) { + try { + Log.i("MainViewModel", "Zero balance at index 0, running discoverCurrentIndex") + wm.discoverCurrentIndex() + val discoveredAddr = wm.getCurrentAddress() + if (discoveredAddr != null && discoveredAddr != walletInfo?.address) { + withContext(Dispatchers.Main) { + walletInfo = walletInfo?.copy(address = discoveredAddr, isLoading = true) + } + val newBalance = wm.getLocalBalance() + withContext(Dispatchers.Main) { + walletInfo = walletInfo?.copy( + balanceRvn = newBalance, + isLoading = false + ) + } + loadOwnedAssets() + loadTransactionHistory() + } + } catch (_: Exception) {} + } + + try { + val txids = wm.sweepOldAddresses() + if (txids.isNotEmpty()) { + Log.i("MainViewModel", "Startup sweep: ${txids.size} txs") + withContext(Dispatchers.Main) { loadWalletBalance() } + } + } catch (_: Exception) {} + + // Refresh address after sweep: sweep advances the index to a fresh address. + wm.getCurrentAddress()?.let { addr -> + withContext(Dispatchers.Main) { + if (addr != walletInfo?.address) walletInfo = walletInfo?.copy(address = addr) + } + } + } + + // Update ElectrumX health pill and chain info after initial load. + // Skip full refreshBalance() — the initial load already fetched + // balance, assets, and tx history. A second full round trip is wasteful. + checkElectrumStatus() + fetchBlockHeight() + fetchRvnPrice() + fetchNetworkHashrate() + } } - /** Refresh balance, owned assets, and transaction history (pull-to-refresh). */ - fun refreshBalance() { loadWalletBalance(); loadOwnedAssets(); loadTransactionHistory() } + /** + * Refresh balance, owned assets, and transaction history (pull-to-refresh). + * AUTO-SWEEP: Automatically consolidates any funds sent to old/exposed addresses + * to the current clean address (currentIndex+1) before loading the balance. + */ + private val isRefreshing = java.util.concurrent.atomic.AtomicBoolean(false) + + fun refreshBalance() { + if (isRefreshing.getAndSet(true)) return + + val wm = walletManager ?: run { isRefreshing.set(false); return } + + viewModelScope.launch { + try { + walletInfo = walletInfo?.copy(isLoading = true) + + // Sync index first (sequential dependency) + val indexChanged = try { + wm.syncCurrentIndex() + } catch (e: Exception) { + Log.e("MainActivity", "syncCurrentIndex failed", e) + false + } + + if (indexChanged) { + val newAddress = wm.getCurrentAddress() ?: "" + withContext(Dispatchers.Main) { + walletInfo = walletInfo?.copy(address = newAddress) + } + } + + // Parallel refresh: balance, assets, and history simultaneously + coroutineScope { + val balanceDeferred = async(Dispatchers.IO) { + RetryUtils.retryWithBackoff { + loadWalletBalanceInternal(wm) + } + } + + val assetsDeferred = async(Dispatchers.IO) { + RetryUtils.retryWithBackoff { + loadOwnedAssetsInternal(wm) + } + } + + val historyDeferred = async(Dispatchers.IO) { + RetryUtils.retryWithBackoff { + loadTransactionHistoryInternal(wm) + } + } + + awaitAll(balanceDeferred, assetsDeferred, historyDeferred) + } + + walletInfo = walletInfo?.copy(isLoading = false) + + // Sweep after parallel refresh (still sequential as before) + try { + val txids = wm.sweepOldAddresses() + if (txids.isNotEmpty()) { + Log.i("MainViewModel", "Auto-sweep completed: ${txids.size} transactions") + withContext(Dispatchers.Main) { + loadWalletBalanceInternal(wm) + loadOwnedAssetsInternal(wm) + loadTransactionHistoryInternal(wm) + } + } + } catch (e: Exception) { + Log.e("MainActivity", "Auto-sweep failed", e) + } + } catch (e: Exception) { + Log.e("MainActivity", "refreshBalance failed", e) + } finally { + isRefreshing.set(false) + walletInfo = walletInfo?.copy(isLoading = false) + } + } + } /** * Load the RVN balance for the wallet address. @@ -841,9 +1471,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val wm = walletManager ?: return viewModelScope.launch { try { - val balance = withContext(Dispatchers.IO) { wm.getLocalBalance() } + val balance = wm.getLocalBalance() if (balance != null) { walletInfo = walletInfo?.copy(balanceRvn = balance, isLoading = false) + try { + io.raventag.app.wallet.cache.WalletCacheDao.writeBalanceSat( + (balance * 1e8).toLong() + ) + } catch (_: Throwable) {} return@launch } val am = assetManager ?: run { @@ -852,7 +1487,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } val info = withContext(Dispatchers.IO) { am.getWalletInfo() } walletInfo = walletInfo?.copy( - balanceRvn = info?.first ?: 0.0, + // Preserve the last known balance if backend also fails; never overwrite with 0 + balanceRvn = info?.first ?: walletInfo?.balanceRvn, isLoading = false ) } catch (_: Throwable) { @@ -861,6 +1497,191 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + // Extract existing load functions to internal versions for use in parallel restore + private suspend fun loadWalletBalanceInternal(wm: WalletManager) { + val balance = wm.getLocalBalance() + if (balance != null) { + withContext(Dispatchers.Main) { + walletInfo = walletInfo?.copy(balanceRvn = balance) + } + // Persist the freshly loaded balance so the next cold start can render + // the last-known value instantly instead of flashing 0 RVN. + try { + io.raventag.app.wallet.cache.WalletCacheDao.writeBalanceSat( + (balance * 1e8).toLong() + ) + } catch (_: Throwable) {} + } + } + + private suspend fun loadOwnedAssetsInternal(wm: WalletManager) { + assetsLoading = true + val currentIndex = wm.getCurrentAddressIndex() + val addresses = wm.getAddressBatch(0, 0..currentIndex).values.toList() + val node = io.raventag.app.wallet.RavencoinPublicNode(getApplication()) + + try { + // One Keystore decrypt + one pipelined batch for all asset balances + val totals = withContext(Dispatchers.IO) { + val (assets, _) = coroutineScope { + val assetsDeferred = async { node.getTotalAssetBalances(addresses) } + val rvnDeferred = async { + try { node.getTotalBalance(addresses) } catch (_: Exception) { 0.0 } + } + + Pair(assetsDeferred.await(), rvnDeferred.await()) + } + + assets.map { (name, amount) -> + val type = when { + name.contains('#') -> io.raventag.app.ravencoin.AssetType.UNIQUE + name.contains('/') -> io.raventag.app.ravencoin.AssetType.SUB + else -> io.raventag.app.ravencoin.AssetType.ROOT + } + io.raventag.app.ravencoin.OwnedAsset( + name = name, + balance = amount, + type = type, + ipfsHash = null + ) + }.sortedWith(compareBy({ it.type.ordinal }, { it.name })) + } + + // Merge balances with already-loaded metadata so images never disappear on refresh + val previous = ownedAssets?.associateBy { it.name } ?: emptyMap() + val merged = totals.map { asset -> + val prev = previous[asset.name] + if (prev?.imageUrl != null) { + asset.copy(ipfsHash = prev.ipfsHash, imageUrl = prev.imageUrl, description = prev.description) + } else { + asset + } + } + withContext(Dispatchers.Main) { + if (merged.isNotEmpty() || ownedAssets.isNullOrEmpty()) { + ownedAssets = merged + } + assetsLoading = false + } + if (merged.isNotEmpty()) saveAssetsCache(merged) + + // IPFS enrichment for assets that still need it (refreshBalance path + // previously skipped this — that's why brand previews never appeared). + val needsEnrichment = merged.filter { it.imageUrl == null } + if (needsEnrichment.isNotEmpty()) { + val withHashes = withContext(Dispatchers.IO) { + val metaBatch = try { node.getAssetMetaBatch(needsEnrichment.map { it.name }) } + catch (_: Exception) { emptyMap() } + needsEnrichment.map { a -> + val h = metaBatch[a.name]?.ipfsHash + if (h != null) a.copy(ipfsHash = h) else a + } + } + withContext(Dispatchers.Main) { + ownedAssets = ownedAssets?.map { existing -> + withHashes.find { it.name == existing.name } ?: existing + } + } + val sem = Semaphore(8) + val jobs = withHashes.map { asset -> + viewModelScope.async(Dispatchers.IO) { + try { + sem.withPermit { + val enriched = rpcClient.enrichWithIpfsData(asset) + withContext(Dispatchers.Main) { + ownedAssets = ownedAssets?.map { + if (it.name == enriched.name) enriched else it + } + } + } + } catch (_: Exception) {} + } + } + viewModelScope.launch { + jobs.awaitAll() + val current = ownedAssets + if (!current.isNullOrEmpty()) { + withContext(Dispatchers.IO) { saveAssetsCache(current) } + } + } + } + } catch (_: Throwable) { + withContext(Dispatchers.Main) { + assetsLoadError = true + assetsLoading = false + } + } + } + + private suspend fun loadTransactionHistoryInternal(wm: WalletManager) { + txHistoryLoading = true + val currentIndex = wm.getCurrentAddressIndex() + val node = io.raventag.app.wallet.RavencoinPublicNode(getApplication()) + + try { + // One Keystore decrypt for all addresses, then parallel ElectrumX queries. + // Include currentIndex+1 in the owned set so change outputs are classified correctly. + val allHistory = withContext(Dispatchers.IO) { + val addresses = wm.getAddressBatch(0, 0..(currentIndex + 1)) + val ownedSet = addresses.values.toSet() + val deferreds = addresses.values.map { addr -> + async { + try { node.getTransactionHistory(addr, limit = txHistoryPageSize, ownedAddresses = ownedSet) } + catch (_: Throwable) { emptyList() } + } + } + deferreds.awaitAll().flatten() + } + + // Deduplicate by txid (same tx may appear in multiple address histories) + val deduped = allHistory.distinctBy { it.txid } + .sortedWith( + compareByDescending { + if (it.height <= 0) Int.MAX_VALUE else it.height + }.thenByDescending { it.timestamp } + ) + + withContext(Dispatchers.Main) { + // Keep prior list visible if this refresh returned empty (network blip). + // Show only the first page (txHistoryPageSize); Load more appends the rest. + if (deduped.isNotEmpty() || txHistory.isEmpty()) { + val firstPage = deduped.take(txHistoryPageSize) + txHistory = firstPage + txHistoryTotal = deduped.size + txHistoryLoadedCount = firstPage.size + } + txHistoryLoading = false + } + // Persist tx history rows so the next cold start can render the list + // immediately from cache instead of waiting for the network. + if (deduped.isNotEmpty()) { + try { + val now = System.currentTimeMillis() + val rows = deduped.map { e -> + io.raventag.app.wallet.cache.TxHistoryDao.TxHistoryRow( + txid = e.txid, + height = e.height, + confirms = e.confirmations, + amountSat = e.amountSat, + sentSat = e.sentSat, + cycledSat = e.cycledSat, + feeSat = e.feeSat, + isIncoming = e.isIncoming, + isSelf = e.isSelfTransfer, + timestamp = e.timestamp, + cachedAt = now + ) + } + io.raventag.app.wallet.cache.TxHistoryDao.upsert(rows) + } catch (_: Throwable) {} + } + } catch (_: Throwable) { + withContext(Dispatchers.Main) { + txHistoryLoading = false + } + } + } + // ── Asset issuance ──────────────────────────────────────────────────────── /** @@ -873,19 +1694,102 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { issueLoading = true try { val assetName = name.uppercase() - val txid = withContext(Dispatchers.IO) { - wm.issueAssetLocal(assetName, qty.toDouble(), toAddress, units = 0, reissuable = reissuable, ipfsHash = ipfsHash) + + // D-04 Step 1: Wallet balance check + val modeBurnSat = RavencoinTxBuilder.BURN_ROOT_SAT + val burnRvn = modeBurnSat / 1e8 + val networkFeeRvn = 0.01 + if ((walletInfo?.balanceRvn ?: 0.0) < burnRvn + networkFeeRvn) { + warningType = WarningType.INSUFFICIENT_BALANCE + issueResult = getStrings().balanceWarningRoot + issueSuccess = false + issueLoading = false + return@launch + } + // D-04 Step 2: Asset name uniqueness check + if (!ownedAssets.isNullOrEmpty()) { + val duplicate = ownedAssets!!.any { it.name.equals(assetName, ignoreCase = true) } + if (duplicate) { + warningType = WarningType.DUPLICATE_NAME + issueResult = getStrings().issueErrorDuplicateName + issueSuccess = false + issueLoading = false + return@launch + } + } + warningType = null + + val txid = try { + RetryUtils.retryWithBackoff(maxAttempts = 5) { + withContext(Dispatchers.IO) { + try { + wm.issueAssetLocal(assetName, qty.toDouble(), toAddress, units = 0, reissuable = reissuable, ipfsHash = ipfsHash) + } catch (e: java.net.SocketTimeoutException) { + // D-08: SocketTimeout must NOT be retried (tx may be broadcast). + // Throw as RuntimeException so isTransientError returns false. + throw RuntimeException(e) + } + } + } + } catch (e: RuntimeException) { + if (e.cause is java.net.SocketTimeoutException) { + // D-08: RPC timeout -- tx may have been broadcast, cannot verify without txid + issueSuccess = false + issueResult = getStrings().issueErrorTimeout + issueStep = IssueStep.Failed(IssueStep.StepName.ISSUING, getStrings().issueErrorTimeout, canRetry = true) + return@launch + } + throw e + } catch (e: Exception) { + // Non-transient or connection retries exhausted + issueSuccess = false + issueResult = classifyIssuanceError(e, getStrings()) + issueStep = IssueStep.Failed(IssueStep.StepName.ISSUING, classifyIssuanceError(e, getStrings()), canRetry = RetryUtils.isTransientError(e)) + return@launch } + issueSuccess = true val s = getStrings() issueResult = s.issueRootSuccess.replace("%1", assetName).replace("%2", "${txid.take(16)}...") + issuedTxid = txid + walletInfo = walletInfo?.copy(address = wm.getCurrentAddress() ?: walletInfo?.address ?: "") notifyRavenTagRegistry(assetName, txid, "root") + + // D-10: Start confirmation polling + issueStep = IssueStep.InProgress(IssueStep.StepName.CONFIRMING) + viewModelScope.launch { + pollingLoop(txid) + } } catch (e: Throwable) { - issueSuccess = false; issueResult = getStrings().issueFailed + issueSuccess = false; issueResult = classifyIssuanceError(e, getStrings()) } finally { issueLoading = false } } } + private suspend fun pollingLoop(txid: String) { + val node = RavencoinPublicNode(getApplication()) + var confirmations = 0 + while (confirmations < 6) { + delay(30_000L) + try { + val tx = withContext(Dispatchers.IO) { + node.callElectrumRawOrNull("blockchain.transaction.get", listOf(txid, true)) + } + val height = tx?.asJsonObject?.get("height")?.asInt ?: 0 + val tip = withContext(Dispatchers.IO) { node.getBlockHeight() } ?: 0 + confirmations = if (height > 0) tip - height + 1 else 0 + } catch (_: Exception) { } + } + if (confirmations >= 6) { + issueStep = IssueStep.Success(IssueStep.StepName.CONFIRMING) + delay(2_000L) + issueResult = null + issueSuccess = null + issueStep = IssueStep.Idle + issuedTxid = null + } + } + /** * Issue a sub-asset ("PARENT/CHILD") on-chain using the local HD wallet. * On success, notifies the RavenTag public registry (fire-and-forget). @@ -896,15 +1800,66 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { issueLoading = true try { val fullName = "${parent.uppercase()}/${child.uppercase()}" - val txid = withContext(Dispatchers.IO) { - wm.issueAssetLocal(fullName, qty.toDouble(), toAddress, units = 0, reissuable = reissuable, ipfsHash = ipfsHash) + + // D-04 Step 1: Wallet balance check + val modeBurnSat = RavencoinTxBuilder.BURN_SUB_SAT + val burnRvn = modeBurnSat / 1e8 + val networkFeeRvn = 0.01 + if ((walletInfo?.balanceRvn ?: 0.0) < burnRvn + networkFeeRvn) { + warningType = WarningType.INSUFFICIENT_BALANCE + issueResult = getStrings().balanceWarningSub + issueSuccess = false + issueLoading = false + return@launch } + // D-04 Step 2: Asset name uniqueness check + if (!ownedAssets.isNullOrEmpty()) { + val duplicate = ownedAssets!!.any { it.name.equals(fullName, ignoreCase = true) } + if (duplicate) { + warningType = WarningType.DUPLICATE_NAME + issueResult = getStrings().issueErrorDuplicateName + issueSuccess = false + issueLoading = false + return@launch + } + } + warningType = null + + val txid = try { + RetryUtils.retryWithBackoff(maxAttempts = 5) { + withContext(Dispatchers.IO) { + try { + wm.issueAssetLocal(fullName, qty.toDouble(), toAddress, units = 0, reissuable = reissuable, ipfsHash = ipfsHash) + } catch (e: java.net.SocketTimeoutException) { + throw RuntimeException(e) + } + } + } + } catch (e: RuntimeException) { + if (e.cause is java.net.SocketTimeoutException) { + issueSuccess = false + issueResult = getStrings().issueErrorTimeout + issueStep = IssueStep.Failed(IssueStep.StepName.ISSUING, getStrings().issueErrorTimeout, canRetry = true) + return@launch + } + throw e + } catch (e: Exception) { + issueSuccess = false + issueResult = classifyIssuanceError(e, getStrings()) + issueStep = IssueStep.Failed(IssueStep.StepName.ISSUING, classifyIssuanceError(e, getStrings()), canRetry = RetryUtils.isTransientError(e)) + return@launch + } + issueSuccess = true val s = getStrings() issueResult = s.issueSubSuccess.replace("%1", fullName).replace("%2", "${txid.take(16)}...") + issuedTxid = txid + walletInfo = walletInfo?.copy(address = wm.getCurrentAddress() ?: walletInfo?.address ?: "") notifyRavenTagRegistry(fullName, txid, "sub") + issueStep = IssueStep.InProgress(IssueStep.StepName.CONFIRMING) + viewModelScope.launch { pollingLoop(txid) } } catch (e: Throwable) { - issueSuccess = false; issueResult = getStrings().issueFailed + issueSuccess = false; issueResult = classifyIssuanceError(e, getStrings()) } finally { issueLoading = false } } } @@ -920,15 +1875,66 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { issueLoading = true try { val fullName = "${parentSub.uppercase()}#${serial.uppercase()}" - val txid = withContext(Dispatchers.IO) { - wm.issueAssetLocal(fullName, qty = 1.0, toAddress = toAddress, units = 0, reissuable = false, ipfsHash = ipfsHash) + + // D-04 Step 1: Wallet balance check + val modeBurnSat = RavencoinTxBuilder.BURN_UNIQUE_SAT + val burnRvn = modeBurnSat / 1e8 + val networkFeeRvn = 0.01 + if ((walletInfo?.balanceRvn ?: 0.0) < burnRvn + networkFeeRvn) { + warningType = WarningType.INSUFFICIENT_BALANCE + issueResult = getStrings().balanceWarningUnique + issueSuccess = false + issueLoading = false + return@launch + } + // D-04 Step 2: Asset name uniqueness check + if (!ownedAssets.isNullOrEmpty()) { + val duplicate = ownedAssets!!.any { it.name.equals(fullName, ignoreCase = true) } + if (duplicate) { + warningType = WarningType.DUPLICATE_NAME + issueResult = getStrings().issueErrorDuplicateName + issueSuccess = false + issueLoading = false + return@launch + } } + warningType = null + + val txid = try { + RetryUtils.retryWithBackoff(maxAttempts = 5) { + withContext(Dispatchers.IO) { + try { + wm.issueAssetLocal(fullName, qty = 1.0, toAddress = toAddress, units = 0, reissuable = false, ipfsHash = ipfsHash) + } catch (e: java.net.SocketTimeoutException) { + throw RuntimeException(e) + } + } + } + } catch (e: RuntimeException) { + if (e.cause is java.net.SocketTimeoutException) { + issueSuccess = false + issueResult = getStrings().issueErrorTimeout + issueStep = IssueStep.Failed(IssueStep.StepName.ISSUING, getStrings().issueErrorTimeout, canRetry = true) + return@launch + } + throw e + } catch (e: Exception) { + issueSuccess = false + issueResult = classifyIssuanceError(e, getStrings()) + issueStep = IssueStep.Failed(IssueStep.StepName.ISSUING, classifyIssuanceError(e, getStrings()), canRetry = RetryUtils.isTransientError(e)) + return@launch + } + issueSuccess = true val s = getStrings() issueResult = s.issueUniqueSuccess.replace("%1", fullName).replace("%2", "${txid.take(16)}...") + issuedTxid = txid + walletInfo = walletInfo?.copy(address = wm.getCurrentAddress() ?: walletInfo?.address ?: "") notifyRavenTagRegistry(fullName, txid, "unique") + issueStep = IssueStep.InProgress(IssueStep.StepName.CONFIRMING) + viewModelScope.launch { pollingLoop(txid) } } catch (e: Throwable) { - issueSuccess = false; issueResult = getStrings().issueFailed + issueSuccess = false; issueResult = classifyIssuanceError(e, getStrings()) } finally { issueLoading = false } } } @@ -941,7 +1947,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * on-chain burn cannot be undone; only the database flag is cleared. */ fun unrevokeAsset(assetName: String, adminKey: String) { - val am = AssetManager(adminKey = adminKey) + val am = AssetManager(context = getApplication(), adminKeyStorage = adminKeyStorage!!) viewModelScope.launch { issueLoading = true try { @@ -968,18 +1974,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * Soft revocation is sufficient to mark assets as invalid. */ fun revokeAsset(assetName: String, reason: String, adminKey: String) { - val am = AssetManager(adminKey = adminKey) + val am = AssetManager(context = getApplication(), adminKeyStorage = adminKeyStorage!!) viewModelScope.launch { issueLoading = true try { - withContext(Dispatchers.IO) { - // Mark revoked in backend SQLite + val result = withContext(Dispatchers.IO) { am.revokeAsset(BurnParams(assetName, reason = reason, burnOnChain = false)) } - issueSuccess = true - issueResult = "Asset $assetName revocato" + issueSuccess = result.success + issueResult = if (result.success) getStrings().revokeSuccess else (result.error ?: getStrings().revokeFailed) } catch (e: Throwable) { - issueSuccess = false; issueResult = e.message ?: "Revoca fallita" + issueSuccess = false; issueResult = e.message ?: getStrings().revokeFailed } finally { issueLoading = false } } } @@ -1024,16 +2029,50 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun clearIssueResult() { issueResult = null issueSuccess = null + issueStep = IssueStep.Idle + issuedTxid = null + warningType = null registerNfcPubId = null prefilledTransferAssetName = null } + /** + * Phase 40: Classify an exception caught during asset issuance into a localized + * user-facing error message. Falls back to raw exception message for unknown errors. + */ + private fun classifyIssuanceError(e: Throwable, s: AppStrings): String { + val msg = e.message?.lowercase() ?: "" + return when { + msg.contains("insufficient funds") || msg.contains("fondi insufficienti") + || msg.contains("no spendable") || msg.contains("nessun rvn spendibile") + -> s.issueErrorInsufficientFunds + msg.contains("duplicate") || msg.contains("already exists") || msg.contains("gia esiste") + -> s.issueErrorDuplicateName + msg.contains("connection refused") || msg.contains("unreachable") || msg.contains("irraggiungibile") + || msg.contains("unknownhost") + -> s.issueErrorNodeUnreachable + msg.contains("timeout") + -> s.issueErrorTimeout + msg.contains("fee") && (msg.contains("estimate") || msg.contains("commissione")) + -> s.issueErrorFeeEstimation + msg.contains("pinata") && (msg.contains("jwt") || msg.contains("auth") || msg.contains("scaduto")) + -> s.issueErrorIpfsAuth + msg.contains("ipfs") || msg.contains("caricamento ipfs fallito") + -> s.issueErrorIpfsFailed + msg.contains("invalid address") || msg.contains("indirizzo non valido") + -> s.issueErrorInvalidAddress + msg.contains("wallet non disponibile") || msg.contains("no wallet") + -> s.issueErrorNoWallet + else -> "${s.issueFailed}: ${e.message ?: ""}" + } + } + /** * Register a physical NFC chip against an asset on the backend. * Calls POST /api/brand/chips with the asset name and tag UID. */ fun registerChip(assetName: String, tagUid: String, adminKey: String) { - val am = AssetManager(adminKey = adminKey) + val am = AssetManager(context = getApplication(), adminKeyStorage = adminKeyStorage!!) viewModelScope.launch { issueLoading = true try { @@ -1065,6 +2104,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** True when the fee estimate is unavailable (ElectrumX offline or no UTXOs). */ var sendFeeUnavailable by mutableStateOf(false) + /** Estimated network fee for the pending send operation, in RVN. */ + var estimatedFee by mutableStateOf(0.0) + /** True when the Receive QR-code overlay is shown. */ var showReceive by mutableStateOf(false) @@ -1096,25 +2138,104 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { sendLoading = true sendFeeUnavailable = false + try { - val result = withContext(Dispatchers.IO) { wm.sendRvnLocal(toAddress, amount) } + // Show broadcasting notification (D-03, D-05) + TransactionNotificationHelper.showBroadcasting(getApplication()) + + // Execute send with retry (D-06) + val result = RetryUtils.retryWithBackoff { + withContext(Dispatchers.IO) { wm.sendRvnLocal(toAddress, amount) } + } + val txid = result.substringBefore("|fee:") val feeRvn = result.substringAfter("|fee:", "0").toLongOrNull()?.let { it / 1e8 } ?: 0.0 - val s = getStrings() + + // Show confirming notification (waiting for blocks) + TransactionNotificationHelper.showConfirming(getApplication(), 1, 1) + + // Brief delay to allow user to see confirming state, then show completed + kotlinx.coroutines.delay(2000) + + // Show completed notification (D-03, D-04, D-05) + TransactionNotificationHelper.showCompleted(getApplication(), txid) + + // Update UI state sendLoading = false sendSuccess = true sendResult = s.walletSendResult.replace("%1", amount.toString()) .replace("%2", "%.5f".format(feeRvn)) .replace("%3", "${txid.take(20)}...") + + // Update displayed address (rotated after send) + walletInfo = walletInfo?.copy(address = wm.getCurrentAddress() ?: walletInfo?.address ?: "") + + // Optimistically deduct sent amount + fee so the balance updates instantly + // instead of waiting for the network refresh round-trip. + val currentBalance = walletInfo?.balanceRvn + if (currentBalance != null) { + walletInfo = walletInfo?.copy( + balanceRvn = (currentBalance - amount - feeRvn).coerceAtLeast(0.0) + ) + } + + // Refresh balance from network (confirms the exact post-send amount) loadWalletBalance() } catch (e: io.raventag.app.wallet.FeeUnavailableException) { sendLoading = false sendFeeUnavailable = true + TransactionNotificationHelper.showFailed(getApplication(), "Fee unavailable: ${e.message}") } catch (e: Throwable) { + // Show failed notification (D-05, D-06) + TransactionNotificationHelper.showFailed(getApplication(), "Send failed: ${e.message}") + val s = getStrings() sendLoading = false sendSuccess = false - sendResult = e.message ?: s.walletSendFailed + sendResult = s.walletSendError.replace("%1", e.message ?: "Unknown error") + + // Classify error: transient (timeout, network) -> banner with auto-dismiss; + // non-transient (validation, wallet logic) -> modal dialog. + // Per 20-UI-SPEC.md Error State Patterns and Claude's discretion areas in 20-CONTEXT.md. + reportAsyncError(e, prefix = "Send failed") + + android.util.Log.e("MainActivity", "sendRvn failed", e) + } + } + } + + /** + * Consolidate all funds (RVN + assets) from scattered addresses to a fresh virgin address. + * Triggered when the portfolio scan detects funds on old addresses that need to be moved. + */ + fun consolidateFunds() { + val wm = walletManager ?: return + + // Set flag to prevent banner from reappearing during consolidation + consolidationInProgress = true + + viewModelScope.launch { + try { + assetsLoading = true + val txid = withContext(Dispatchers.IO) { wm.consolidateAllFundsToFreshAddress() } + + if (txid != null) { + needsConsolidation = false + // Update current address to the target address (currentIndex + 1) + // so the UI shows the correct receiving address and balance + val newAddress = wm.getCurrentAddress() ?: wm.getAddress(0, wm.getCurrentAddressIndex() + 1) + walletInfo = walletInfo?.copy(address = newAddress ?: walletInfo?.address ?: "") + + // Reload balance and assets after consolidation + loadWalletBalance() + loadOwnedAssets() + } + } catch (e: Exception) { + Log.e("MainActivity", "consolidateFunds failed", e) + } finally { + // Clear the flag when done (success or failure) + consolidationInProgress = false + assetsLoading = false } } } @@ -1128,17 +2249,79 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val wm = walletManager ?: run { issueSuccess = false; issueResult = s.walletNoWallet; return } viewModelScope.launch { issueLoading = true + try { - val txid = withContext(Dispatchers.IO) { wm.transferAssetLocal(assetName, toAddress, qty.toDouble()) } + // Show broadcasting notification (D-03, D-05) + TransactionNotificationHelper.showBroadcasting(getApplication()) + + // Execute transfer with retry (D-06) + val txid = RetryUtils.retryWithBackoff { + withContext(Dispatchers.IO) { + wm.transferAssetLocal(assetName, toAddress, qty.toDouble()) + } + } + + // Show confirming notification (waiting for blocks) + TransactionNotificationHelper.showConfirming(getApplication(), 1, 1) + + // Brief delay to allow user to see confirming state, then show completed + kotlinx.coroutines.delay(2000) + + // Show completed notification (D-03, D-04, D-05) + TransactionNotificationHelper.showCompleted(getApplication(), txid) + + // Update UI state val s = getStrings() issueLoading = false issueSuccess = true issueResult = s.walletTransferResult.replace("%1", assetName).replace("%2", "${txid.take(20)}...") + + // Update displayed address (rotated after transfer) and optimistically + // mark loading so the UI shows the previous balance + spinner instead + // of a temporarily wrong value while ElectrumX mempool propagates. + walletInfo = walletInfo?.copy( + address = wm.getCurrentAddress() ?: walletInfo?.address ?: "", + isLoading = true + ) + + // Optimistically remove the sent asset from the visible list so the + // user does not see it lingering after a UNIQUE token transfer. + // For fungible assets, decrement the local quantity by `qty`. + ownedAssets = ownedAssets?.mapNotNull { a -> + if (a.name == assetName) { + val remaining = (a.balance - qty.toDouble()).coerceAtLeast(0.0) + if (remaining <= 0.0) null else a.copy(balance = remaining) + } else a + } + + // Snapshot the pre-broadcast balance so we can reject obviously wrong + // (mempool race) refresh values that would temporarily double the total. + val preBalance = walletInfo?.balanceRvn ?: 0.0 + + // Wait longer for ElectrumX to settle: with a 1s block-cycle emulator + // the mempool view across multiple servers stabilizes around 8s. + kotlinx.coroutines.delay(8000) + loadWalletBalance() + // Sanity guard: if the just-loaded balance is more than 1.05× the + // pre-send balance, ElectrumX is in a transient inconsistent state. + // Keep the user-trusted previous balance and try again shortly. + val postBalance = walletInfo?.balanceRvn ?: 0.0 + if (preBalance > 0.0 && postBalance > preBalance * 1.05) { + walletInfo = walletInfo?.copy(balanceRvn = preBalance, isLoading = true) + kotlinx.coroutines.delay(5000) + loadWalletBalance() + } + loadOwnedAssets() } catch (e: Throwable) { + // Show failed notification (D-05, D-06) + TransactionNotificationHelper.showFailed(getApplication(), "Transfer failed: ${e.message}") + val s = getStrings() issueLoading = false issueSuccess = false - issueResult = e.message ?: s.walletTransferFailed + issueResult = s.walletTransferError.replace("%1", e.message ?: "Unknown error") + + android.util.Log.e("MainActivity", "transferAssetConsumer failed", e) } } } @@ -1260,7 +2443,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (result.isFailure) { writeTagStep = WriteTagStep.ERROR - writeTagError = result.exceptionOrNull()?.message ?: "Errore sconosciuto" + val errorMsg = result.exceptionOrNull()?.message ?: "Errore sconosciuto" + writeTagError = errorMsg + if (!isStandaloneWrite) { + issueStep = IssueStep.Failed(IssueStep.StepName.ISSUING, errorMsg, canRetry = false) + } } else { writeTagStep = WriteTagStep.SUCCESS writeTagKeys = result.getOrNull() @@ -1297,7 +2484,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { */ private suspend fun processStandaloneWrite(tag: android.nfc.Tag, uid: ByteArray): Result { val uidHex = uid.toHex() - val am = AssetManager(adminKey = writeTagAdminKey) + val am = AssetManager(context = getApplication(), adminKeyStorage = adminKeyStorage!!) Log.i("IssueWriteFlow", "processStandaloneWrite start uid=$uidHex asset=$writeTagAssetName") val currentMasterKey = resolveInitialMasterKey() .getOrElse { return Result.failure(it) } @@ -1354,7 +2541,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private suspend fun processIssueAndWrite(tag: android.nfc.Tag, uid: ByteArray): Result { val args = pendingWriteArgs ?: return Result.failure(Exception("Parametri di emissione mancanti")) val uidHex = uid.toHex() - val am = AssetManager(adminKey = writeTagAdminKey) + val am = AssetManager(context = getApplication(), adminKeyStorage = adminKeyStorage!!) val fullName = args.fullAssetName Log.i("IssueWriteFlow", "processIssueAndWrite start asset=$fullName uid=$uidHex kind=${args.assetKind}") val currentMasterKey = resolveInitialMasterKey() @@ -1390,25 +2577,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } // 4. Upload metadata to IPFS via Pinata, Kubo, or the backend (in that priority order) - val ipfsHash = uploadMetadata(metadata, am) - ?: return Result.failure(Exception("Caricamento IPFS fallito")) + issueStep = IssueStep.InProgress(IssueStep.StepName.IPFS_UPLOAD) + val ipfsHash = try { + uploadMetadata(metadata, am) ?: throw Exception("ipfs upload failed") + } catch (e: Exception) { + val msg = classifyIssuanceError(e, getStrings()) + return Result.failure(Exception(msg)) + } + issueStep = IssueStep.Success(IssueStep.StepName.IPFS_UPLOAD) Log.i("IssueWriteFlow", "processIssueAndWrite metadata-uploaded asset=$fullName metadataIpfs=$ipfsHash") // 5. Issue the Ravencoin asset on-chain; the IPFS hash is embedded in the issuance tx - val wm = walletManager ?: return Result.failure(Exception("Wallet non disponibile")) + val wm = walletManager ?: return Result.failure(Exception(classifyIssuanceError(Exception("no wallet"), getStrings()))) + issueStep = IssueStep.InProgress(IssueStep.StepName.ISSUING) val txid = try { - wm.issueAssetLocal( - fullName, - qty = 1.0, - toAddress = args.toAddress, - units = 0, - reissuable = false, - ipfsHash = ipfsHash - ) + RetryUtils.retryWithBackoff(maxAttempts = 5) { + try { + wm.issueAssetLocal(fullName, qty = 1.0, toAddress = args.toAddress, + units = 0, reissuable = false, ipfsHash = ipfsHash) + } catch (e: java.net.SocketTimeoutException) { + throw RuntimeException(e) + } + } + } catch (e: RuntimeException) { + if (e.cause is java.net.SocketTimeoutException) { + val msg = classifyIssuanceError(e.cause!!, getStrings()) + return Result.failure(Exception(msg)) + } + val msg = classifyIssuanceError(e, getStrings()) + return Result.failure(Exception(msg)) } catch (e: Exception) { - return Result.failure(Exception("Emissione Ravencoin fallita: ${e.message}")) + val msg = classifyIssuanceError(e, getStrings()) + return Result.failure(Exception(msg)) } Log.i("IssueWriteFlow", "processIssueAndWrite asset-issued asset=$fullName txid=$txid") + issueStep = IssueStep.Success(IssueStep.StepName.ISSUING) + issuedTxid = txid + // Update displayed address (rotated after issuance) + walletInfo = walletInfo?.copy(address = wm.getCurrentAddress() ?: walletInfo?.address ?: "") notifyRavenTagRegistry( assetName = fullName, txid = txid, @@ -1416,6 +2622,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) // 6. Program the tag: authenticate, set keys derived from this specific UID, write NDEF URL + issueStep = IssueStep.InProgress(IssueStep.StepName.NFC_PROGRAMMING) val verifyUrl = "${args.baseUrl}/verify?asset=${fullName.uppercase().replace("#", "%23")}&" val params = io.raventag.app.nfc.Ntag424Configurator.WriteParams( baseUrl = verifyUrl, @@ -1427,12 +2634,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) val configResult = ntag424.configure(tag, params) if (configResult.isFailure) return Result.failure(configResult.exceptionOrNull()!!) + issueStep = IssueStep.Success(IssueStep.StepName.NFC_PROGRAMMING) Log.i("IssueWriteFlow", "processIssueAndWrite tag-configured asset=$fullName uid=$uidHex") // 7. Register the chip on the backend (links UID to the new asset record) val regResult = am.registerChip(fullName, uidHex) Log.i("IssueWriteFlow", "processIssueAndWrite registerChip asset=$fullName success=${regResult.success} error=${regResult.error}") + // D-10: Start confirmation polling after combined flow + issueStep = IssueStep.InProgress(IssueStep.StepName.CONFIRMING) + viewModelScope.launch { pollingLoop(txid) } + return Result.success(WriteTagKeys( sdmmacInputKey = keys.sdmmacInputKey.toHex(), sdmEncKey = keys.sdmEncKey.toHex(), @@ -1512,7 +2724,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } catch (_: Exception) { currentVerifyUrl } val healthy = withContext(Dispatchers.IO) { - io.raventag.app.wallet.AssetManager(apiBaseUrl = tagBaseUrl).checkHealth() + io.raventag.app.wallet.AssetManager(context = getApplication(), apiBaseUrl = tagBaseUrl, adminKeyStorage = adminKeyStorage!!).checkHealth() } if (!healthy) { @@ -1539,7 +2751,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (asset != null) { verifyStep = VerifyStep.CHECKING_BLOCKCHAIN val response = withContext(Dispatchers.IO) { - io.raventag.app.wallet.AssetManager(apiBaseUrl = currentVerifyUrl) + io.raventag.app.wallet.AssetManager(context = getApplication(), apiBaseUrl = currentVerifyUrl, adminKeyStorage = adminKeyStorage!!) .verifyTag(asset, e, m) } // CRIT-1: update client-side counter cache for defense-in-depth replay detection @@ -1621,7 +2833,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { // Check revocation status from the backend database val revocationStatus = withContext(Dispatchers.IO) { - io.raventag.app.wallet.AssetManager(apiBaseUrl = currentVerifyUrl).checkRevocationStatus(assetName) + io.raventag.app.wallet.AssetManager(context = getApplication(), apiBaseUrl = currentVerifyUrl, adminKeyStorage = adminKeyStorage!!).checkRevocationStatus(assetName) } val revoked = revocationStatus.revoked @@ -1701,6 +2913,9 @@ class MainActivity : FragmentActivity() { /** EncryptedSharedPreferences for admin/operator/master keys (AES256-GCM, Keystore-backed). */ private lateinit var securePrefs: android.content.SharedPreferences + /** AdminKeyStorage for encrypted admin key persistence (AES256-GCM, Keystore-backed). */ + private lateinit var adminKeyStorage: AdminKeyStorage + /** * Compose state that gates rendering until [securePrefs] is initialised. * Kept as a mutableStateOf so the Compose tree re-renders when it flips to true. @@ -1787,16 +3002,36 @@ class MainActivity : FragmentActivity() { nfcAdapter = NfcAdapter.getDefaultAdapter(this) + // Initialize AdminKeyStorage (does not require securePrefs, uses its own Keystore) + adminKeyStorage = AdminKeyStorage(applicationContext) + // Process any NFC intent that launched or re-launched this activity handleIntent(intent) + // Initialize wallet reliability database BEFORE initWallet so the cache + // reads inside initWallet (balance, tx history, block height) actually + // hit SQLite instead of throwing "DB not initialized" and silently + // falling into the catch-all that wipes the cold-start cache view. + io.raventag.app.wallet.cache.WalletReliabilityDb.init(this) + io.raventag.app.wallet.health.NodeHealthMonitor.init(this) + val walletManager = WalletManager(applicationContext) - val assetManager = AssetManager(adminKey = BuildConfig.ADMIN_KEY) - viewModel.initWallet(walletManager, assetManager) + val assetManager = AssetManager(context = applicationContext, adminKeyStorage = adminKeyStorage) + viewModel.initWallet(walletManager, assetManager, adminKeyStorage) // Create notification channel (safe to call on every start, system ignores duplicates) NotificationHelper.createChannel(this) + // Create transaction progress notification channel + TransactionNotificationHelper.createChannel(applicationContext) + + // D-06, D-07: create incoming_tx notification channel for received RVN/assets + io.raventag.app.worker.IncomingTxNotificationHelper.createChannel(applicationContext) + + // Pitfall 6: prune stale reservations older than 48h on startup (D-20) + io.raventag.app.wallet.cache.ReservedUtxoDao.pruneOlderThan( + System.currentTimeMillis() - 48L * 3600_000L + ) // Schedule periodic wallet polling every 15 minutes. // UPDATE policy: replaces any previously scheduled instance so app updates always @@ -1842,7 +3077,7 @@ class MainActivity : FragmentActivity() { // Persisted user preferences (read from SharedPreferences, updated on save) var langCode by remember { mutableStateOf(prefs.getString("language", "en") ?: "en") } - var savedAdminKey by remember { mutableStateOf(securePrefs.getString("admin_key", "") ?: "") } + var savedAdminKey by remember { mutableStateOf(adminKeyStorage.getAdminKey() ?: "") } var savedInitialMasterKey by remember { mutableStateOf(securePrefs.getString("initial_master_key", "") ?: "") } var savedOperatorKey by remember { mutableStateOf(securePrefs.getString("operator_key", "") ?: "") } var walletRole by remember { mutableStateOf(prefs.getString("wallet_role", "") ?: "") } @@ -1993,6 +3228,7 @@ class MainActivity : FragmentActivity() { savedAdminKey = key; savedOperatorKey = "" viewModel.adminKeyStatus = MainViewModel.AdminKeyStatus.VALID viewModel.operatorKeyStatus = MainViewModel.AdminKeyStatus.UNKNOWN + try { viewModel.adminKeyStorage?.setAdminKey(key) } catch (_: Throwable) {} } else { securePrefs.edit().putString("operator_key", key).putString("admin_key", "").apply() savedOperatorKey = key; savedAdminKey = "" @@ -2108,6 +3344,9 @@ class MainActivity : FragmentActivity() { } } + /** Set when the activity goes to background; cleared on resume after triggering refresh. */ + private var resumeRefreshNeeded = false + /** Re-enables NFC dispatch when returning from background. * Enabled if on Scan tab OR if the tag-write flow is waiting for a tap. */ override fun onResume() { @@ -2118,6 +3357,12 @@ class MainActivity : FragmentActivity() { if (viewModel.isScanTabActive && viewModel.verifyStep == null) { viewModel.scanState = ScanState.SCANNING } + // Refresh wallet immediately when returning from background so address, balance, + // and asset list are up to date (e.g. the other app flavor sent a tx while away). + if (resumeRefreshNeeded && viewModel.hasWallet) { + resumeRefreshNeeded = false + viewModel.refreshBalance() + } } /** @@ -2128,6 +3373,7 @@ class MainActivity : FragmentActivity() { super.onPause() nfcAdapter?.disableForegroundDispatch(this) Log.d("NFC", "Foreground dispatch disabled") + resumeRefreshNeeded = true } /** @@ -2164,6 +3410,14 @@ class MainActivity : FragmentActivity() { * @param intent The NFC or deep-link intent to handle */ private fun handleIntent(intent: Intent) { + // Handle VIEW_TRANSACTION intent from notification (per D-04) + if (intent.action == TransactionNotificationHelper.ACTION_VIEW_TRANSACTION_EXT) { + val txid = intent.getStringExtra(TransactionNotificationHelper.EXTRA_TXID_EXT) + if (txid != null) { + viewModel.handleViewTransactionIntent(txid) + } + } + // Extract the Tag object in an API-level-safe way (getParcelableExtra deprecated in API 33) val tag = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, android.nfc.Tag::class.java) @@ -2338,6 +3592,37 @@ fun RavenTagApp( ) } + // ── Critical error dialog (per 20-UI-SPEC.md Dialog Error pattern) ──────── + // Shown for non-recoverable async failures (validation errors, wallet logic + // errors). The user must explicitly acknowledge before proceeding. + viewModel.criticalError?.let { msg -> + AlertDialog( + onDismissRequest = { viewModel.clearCriticalError() }, + containerColor = Color(0xFF101020), + icon = { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = Color(0xFFF87171) + ) + }, + title = { Text("Error", color = Color.White, fontWeight = FontWeight.Bold) }, + text = { + Text( + msg, + color = RavenMuted, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { viewModel.clearCriticalError() }, + colors = ButtonDefaults.buttonColors(containerColor = RavenOrange) + ) { Text("OK", fontWeight = FontWeight.Bold) } + } + ) + } + /** * Check the wallet balance before navigating to an issue screen. * If the balance is zero, show the no-funds warning dialog instead @@ -2389,11 +3674,15 @@ fun RavenTagApp( // ── Send RVN overlay ────────────────────────────────────────────────────── if (viewModel.showSend) { + val sendCtx = LocalContext.current + val sendFeeEstimator = remember { io.raventag.app.wallet.fee.FeeEstimator(io.raventag.app.wallet.RavencoinPublicNode(sendCtx)) } SendRvnScreen( isLoading = viewModel.sendLoading, resultMessage = viewModel.sendResult, resultSuccess = viewModel.sendSuccess, feeUnavailable = viewModel.sendFeeUnavailable, + estimatedFee = viewModel.estimatedFee, + feeEstimator = sendFeeEstimator, prefillAddress = if (viewModel.donateMode) viewModel.donateAddress else "", donateMode = viewModel.donateMode, walletBalance = viewModel.walletInfo?.balanceRvn ?: 0.0, @@ -2403,6 +3692,7 @@ fun RavenTagApp( viewModel.sendResult = null viewModel.sendSuccess = null viewModel.sendFeeUnavailable = false + viewModel.estimatedFee = 0.0 }, onSend = viewModel::sendRvn ) @@ -2437,9 +3727,20 @@ fun RavenTagApp( // Register chip is now integrated into the "Program NFC Tag" flow, no separate overlay needed. + // ── Transaction details overlay (per D-04) ──────────────────────────────── + if (viewModel.isViewingTransaction && viewModel.viewingTxid != null) { + TransactionDetailsScreen( + txid = viewModel.viewingTxid!!, + onClose = { viewModel.isViewingTransaction = false } + ) + return + } + // ── Transfer overlay ────────────────────────────────────────────────────── // Handles token transfers, root-asset transfers, and sub-asset transfers. if (issueMode == IssueMode.TRANSFER || issueMode == IssueMode.TRANSFER_ROOT || issueMode == IssueMode.TRANSFER_SUB) { + val transferCtx = LocalContext.current + val transferFeeEstimator = remember { io.raventag.app.wallet.fee.FeeEstimator(io.raventag.app.wallet.RavencoinPublicNode(transferCtx)) } TransferScreen( isLoading = viewModel.issueLoading, resultMessage = viewModel.issueResult, @@ -2447,6 +3748,7 @@ fun RavenTagApp( mode = issueMode, prefilledAssetName = viewModel.prefilledTransferAssetName, showLowRvnWarning = !AppConfig.IS_BRAND_APP && (viewModel.walletInfo?.balanceRvn ?: 0.0) < 0.01, + feeEstimator = transferFeeEstimator, onBack = { viewModel.issueMode = null; viewModel.clearIssueResult() }, onTransfer = { assetName, toAddress, qty -> viewModel.transferAssetConsumer(assetName, toAddress, qty) @@ -2467,6 +3769,9 @@ fun RavenTagApp( ownedAssets = viewModel.ownedAssets ?: emptyList(), savedAdminKey = savedAdminKey, savedKuboNodeUrl = savedKuboNodeUrl, + currentStep = viewModel.issueStep, + issuedTxid = viewModel.issuedTxid, + warningType = viewModel.warningType, onBack = { viewModel.issueMode = null; viewModel.clearIssueResult() }, onIssueRoot = viewModel::issueRootAsset, onIssueSub = viewModel::issueSubAsset, @@ -2520,27 +3825,30 @@ fun RavenTagApp( } } ) { innerPadding -> - when (currentTab) { - // ── Scan tab ────────────────────────────────────────────────────── - AppTab.SCAN -> ScanScreen( - modifier = Modifier.padding(innerPadding), - scanState = viewModel.scanState, - errorMessage = viewModel.errorMessage, - nfcSupported = nfcSupported, - nfcEnabled = nfcEnabled, - onStartScan = { viewModel.scanState = ScanState.SCANNING } - ) - - // ── Wallet tab ──────────────────────────────────────────────────── - AppTab.WALLET -> { - WalletScreen( - modifier = Modifier.padding(innerPadding), + Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + + // WalletScreen: keep alive in the composition tree after the first visit. + // `when` branches are destroyed on every tab switch, and for a large screen like + // WalletScreen (many asset cards, scroll state, dialogs) that initial composition + // costs more than one frame and produces visible lag. + // Using alpha(0f) + pointer-blocking overlay keeps it alive without rendering. + val walletEverShown = remember { mutableStateOf(currentTab == AppTab.WALLET) } + if (currentTab == AppTab.WALLET) walletEverShown.value = true + val walletVisible = currentTab == AppTab.WALLET + + if (walletEverShown.value) { + Box(modifier = Modifier.fillMaxSize().alpha(if (walletVisible) 1f else 0f)) { + WalletScreen( + modifier = Modifier.fillMaxSize(), walletInfo = viewModel.walletInfo, hasWallet = viewModel.hasWallet, isGenerating = viewModel.walletGenerating, ownedAssets = viewModel.ownedAssets, assetsLoading = viewModel.assetsLoading, assetsLoadError = viewModel.assetsLoadError, + needsConsolidation = viewModel.needsConsolidation, + consolidationInProgress = viewModel.consolidationInProgress, + onConsolidateFunds = { viewModel.consolidateFunds() }, electrumStatus = viewModel.electrumStatus, blockHeight = viewModel.blockHeight, rvnPrice = viewModel.rvnPrice, @@ -2548,6 +3856,7 @@ fun RavenTagApp( walletRole = walletRole, controlKeyValidating = viewModel.controlKeyValidating, controlKeyError = viewModel.controlKeyError, + restoreError = viewModel.restoreError, onGenerateWallet = { controlKey -> if (!io.raventag.app.config.AppConfig.IS_BRAND_APP) { viewModel.generateWallet() @@ -2615,88 +3924,176 @@ fun RavenTagApp( onRestoreModeChange = { restoreActive -> viewModel.restoreModeActive = restoreActive } - ) + ) + // When hidden: consume all pointer events to prevent the invisible + // scrollable wallet content from intercepting touches on the active tab. + if (!walletVisible) { + Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial) + .changes.forEach { it.consume() } + } + } + }) + } + } } - // ── Brand tab ───────────────────────────────────────────────────── - AppTab.BRAND -> { - // Auto-check server and key statuses when landing on Brand tab - LaunchedEffect(Unit) { - if (viewModel.serverStatus == MainViewModel.ServerStatus.UNKNOWN) - viewModel.checkServerStatus(viewModel.currentVerifyUrl) - } - LaunchedEffect(viewModel.serverStatus) { - if (viewModel.serverStatus == MainViewModel.ServerStatus.ONLINE) { - if (viewModel.pinataJwtStatus == MainViewModel.AdminKeyStatus.UNKNOWN) - viewModel.checkPinataJwt(savedPinataJwt) - if (viewModel.kuboNodeStatus == MainViewModel.AdminKeyStatus.UNKNOWN) - viewModel.checkKuboNode(savedKuboNodeUrl) + // Other tabs render on top (drawn after wallet = higher z-order in Box) + when (currentTab) { + AppTab.WALLET -> { /* alive above */ } + + // ── Scan tab ────────────────────────────────────────────────── + AppTab.SCAN -> ScanScreen( + modifier = Modifier.fillMaxSize(), + scanState = viewModel.scanState, + errorMessage = viewModel.errorMessage, + nfcSupported = nfcSupported, + nfcEnabled = nfcEnabled, + onStartScan = { viewModel.scanState = ScanState.SCANNING } + ) + + // ── Brand tab ───────────────────────────────────────────────── + AppTab.BRAND -> { + LaunchedEffect(Unit) { + if (viewModel.serverStatus == MainViewModel.ServerStatus.UNKNOWN) + viewModel.checkServerStatus(viewModel.currentVerifyUrl) } + LaunchedEffect(viewModel.serverStatus) { + if (viewModel.serverStatus == MainViewModel.ServerStatus.ONLINE) { + if (viewModel.pinataJwtStatus == MainViewModel.AdminKeyStatus.UNKNOWN) + viewModel.checkPinataJwt(savedPinataJwt) + if (viewModel.kuboNodeStatus == MainViewModel.AdminKeyStatus.UNKNOWN) + viewModel.checkKuboNode(savedKuboNodeUrl) + } + } + BrandDashboardScreen( + modifier = Modifier.fillMaxSize(), + hasWallet = viewModel.hasWallet, + serverStatus = viewModel.serverStatus, + walletRole = walletRole, + onIssueAsset = { checkAndIssue(IssueMode.ROOT_ASSET) }, + onIssueSubAsset = { checkAndIssue(IssueMode.SUB_ASSET) }, + onIssueUnique = { checkAndIssue(IssueMode.UNIQUE_TOKEN) }, + onRevokeAsset = { viewModel.issueMode = IssueMode.REVOKE }, + onUnrevokeAsset = { viewModel.issueMode = IssueMode.UNREVOKE }, + onGoToWallet = { switchTab(AppTab.WALLET) } + ) } - BrandDashboardScreen( - modifier = Modifier.padding(innerPadding), - hasWallet = viewModel.hasWallet, - serverStatus = viewModel.serverStatus, - walletRole = walletRole, - onIssueAsset = { checkAndIssue(IssueMode.ROOT_ASSET) }, - onIssueSubAsset = { checkAndIssue(IssueMode.SUB_ASSET) }, - onIssueUnique = { checkAndIssue(IssueMode.UNIQUE_TOKEN) }, - onRevokeAsset = { viewModel.issueMode = IssueMode.REVOKE }, - onUnrevokeAsset = { viewModel.issueMode = IssueMode.UNREVOKE }, - onGoToWallet = { switchTab(AppTab.WALLET) } - ) - } - // ── Settings tab ────────────────────────────────────────────────── - AppTab.SETTINGS -> { - LaunchedEffect(Unit) { - if (viewModel.serverStatus == MainViewModel.ServerStatus.UNKNOWN) { - viewModel.checkServerStatus(viewModel.currentVerifyUrl) + // ── Settings tab ────────────────────────────────────────────── + AppTab.SETTINGS -> { + LaunchedEffect(Unit) { + if (viewModel.serverStatus == MainViewModel.ServerStatus.UNKNOWN) { + viewModel.checkServerStatus(viewModel.currentVerifyUrl) + } } + LaunchedEffect(viewModel.serverStatus) { + if (viewModel.serverStatus == MainViewModel.ServerStatus.ONLINE) { + if (viewModel.pinataJwtStatus == MainViewModel.AdminKeyStatus.UNKNOWN) + viewModel.checkPinataJwt(savedPinataJwt) + if (viewModel.kuboNodeStatus == MainViewModel.AdminKeyStatus.UNKNOWN) + viewModel.checkKuboNode(savedKuboNodeUrl) + if (viewModel.adminKeyStatus == MainViewModel.AdminKeyStatus.UNKNOWN && savedAdminKey.isNotEmpty()) + viewModel.checkAdminKey(viewModel.currentVerifyUrl, savedAdminKey) + } + } + SettingsScreen( + modifier = Modifier.fillMaxSize(), + currentLang = when (s) { + stringsIt -> "it"; stringsFr -> "fr"; stringsDe -> "de"; stringsEs -> "es" + stringsZh -> "zh"; stringsJa -> "ja"; stringsKo -> "ko"; stringsRu -> "ru" + else -> "en" + }, + currentVerifyUrl = viewModel.currentVerifyUrl, + currentInitialMasterKey = savedInitialMasterKey, + currentPinataJwt = savedPinataJwt, + currentKuboNodeUrl = savedKuboNodeUrl, + onPinataJwtSave = onPinataJwtSave, + onKuboNodeUrlSave = onKuboNodeUrlSave, + currentAdminKey = savedAdminKey, + onAdminKeySave = { key -> + viewModel.adminKeyStorage?.setAdminKey(key) + viewModel.viewModelScope.launch { + viewModel.validateAdminKey(key, viewModel.currentVerifyUrl) + } + }, + adminKeyStatus = viewModel.adminKeyStatus, + serverStatus = viewModel.serverStatus, + pinataJwtStatus = viewModel.pinataJwtStatus, + kuboNodeStatus = viewModel.kuboNodeStatus, + onLangChange = onLangChange, + onVerifyUrlSave = onVerifyUrlSave, + onInitialMasterKeySave = onInitialMasterKeySave, + onDonate = { + viewModel.donateMode = true + viewModel.showSend = true + }, + walletBalance = viewModel.walletInfo?.balanceRvn ?: 0.0, + hasWallet = viewModel.hasWallet, + requireAuthOnStart = requireAuthOnStart, + onRequireAuthChange = onRequireAuthChange, + hasLockScreen = hasLockScreen, + allowScreenshots = allowScreenshots, + onAllowScreenshotsChange = onAllowScreenshotsChange, + notificationsEnabled = notificationsEnabled, + onNotificationsEnabledChange = onNotificationsEnabledChange + ) } - // Auto-check admin key whenever server becomes Online (brand app only) - LaunchedEffect(viewModel.serverStatus) { - if (viewModel.serverStatus == MainViewModel.ServerStatus.ONLINE) { - if (viewModel.pinataJwtStatus == MainViewModel.AdminKeyStatus.UNKNOWN) - viewModel.checkPinataJwt(savedPinataJwt) - if (viewModel.kuboNodeStatus == MainViewModel.AdminKeyStatus.UNKNOWN) - viewModel.checkKuboNode(savedKuboNodeUrl) + } + + // ── Transient error banner overlay ──────────────────────────────── + // Drawn last inside the Box so it sits above all tab content. + // Auto-dismisses after 5s (see MainViewModel.showTransientError) or + // on explicit Dismiss tap. Used for recoverable errors (network + // timeout, transient failures) per 20-UI-SPEC.md Banner Error pattern. + viewModel.transientError?.let { msg -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 16.dp, start = 16.dp, end = 16.dp), + contentAlignment = androidx.compose.ui.Alignment.TopCenter + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFF2D0A0A)), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFF87171).copy(alpha = 0.4f)), + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp) + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = Color(0xFFF87171), + modifier = Modifier.size(20.dp) + ) + Text( + text = msg, + color = Color(0xFFF87171), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + Button( + onClick = { viewModel.clearTransientError() }, + colors = ButtonDefaults.buttonColors(containerColor = RavenOrange), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 0.dp), + modifier = Modifier.height(32.dp) + ) { + Text( + "Dismiss", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } } } - SettingsScreen( - modifier = Modifier.padding(innerPadding), - // Map AppStrings instance back to a language code for the selector UI - currentLang = when (s) { - stringsIt -> "it"; stringsFr -> "fr"; stringsDe -> "de"; stringsEs -> "es" - stringsZh -> "zh"; stringsJa -> "ja"; stringsKo -> "ko"; stringsRu -> "ru" - else -> "en" - }, - currentVerifyUrl = viewModel.currentVerifyUrl, - currentInitialMasterKey = savedInitialMasterKey, - currentPinataJwt = savedPinataJwt, - currentKuboNodeUrl = savedKuboNodeUrl, - onPinataJwtSave = onPinataJwtSave, - onKuboNodeUrlSave = onKuboNodeUrlSave, - serverStatus = viewModel.serverStatus, - pinataJwtStatus = viewModel.pinataJwtStatus, - kuboNodeStatus = viewModel.kuboNodeStatus, - onLangChange = onLangChange, - onVerifyUrlSave = onVerifyUrlSave, - onInitialMasterKeySave = onInitialMasterKeySave, - onDonate = { - viewModel.donateMode = true - viewModel.showSend = true - }, - walletBalance = viewModel.walletInfo?.balanceRvn ?: 0.0, - hasWallet = viewModel.hasWallet, - requireAuthOnStart = requireAuthOnStart, - onRequireAuthChange = onRequireAuthChange, - hasLockScreen = hasLockScreen, - allowScreenshots = allowScreenshots, - onAllowScreenshotsChange = onAllowScreenshotsChange, - notificationsEnabled = notificationsEnabled, - onNotificationsEnabledChange = onNotificationsEnabledChange - ) } } } diff --git a/android/app/src/main/java/io/raventag/app/ipfs/IpfsResolver.kt b/android/app/src/main/java/io/raventag/app/ipfs/IpfsResolver.kt index 0df7566..ca11fb2 100644 --- a/android/app/src/main/java/io/raventag/app/ipfs/IpfsResolver.kt +++ b/android/app/src/main/java/io/raventag/app/ipfs/IpfsResolver.kt @@ -57,6 +57,9 @@ object IpfsResolver { * ALL configured gateways. This provides resilience even if the brand * metadata pointed to a specific dead gateway. * + * If the input is already a direct HTTP URL that does NOT contain an IPFS + * path segment, it is returned as the sole candidate (for non-IPFS images). + * * @param ipfsRef The IPFS reference to resolve. * @return Ordered list of candidate HTTP URLs; callers should try them in sequence. */ @@ -64,6 +67,11 @@ object IpfsResolver { val normalized = ipfsRef.trim() if (normalized.isEmpty()) return emptyList() + // If it's already a direct HTTP URL with no IPFS path, use it as-is + if (normalized.startsWith("http") && !normalized.contains("/ipfs/") && !normalized.contains(".ipfs.")) { + return listOf(normalized) + } + // Extract the raw CID from various known formats. val cid = when { normalized.startsWith("ipfs://") -> normalized.removePrefix("ipfs://") diff --git a/android/app/src/main/java/io/raventag/app/ipfs/KuboUploader.kt b/android/app/src/main/java/io/raventag/app/ipfs/KuboUploader.kt index 31dfebc..91b285e 100644 --- a/android/app/src/main/java/io/raventag/app/ipfs/KuboUploader.kt +++ b/android/app/src/main/java/io/raventag/app/ipfs/KuboUploader.kt @@ -8,9 +8,8 @@ * (e.g. a Raspberry Pi on the same LAN, or a VPS) instead of the Pinata cloud service. * The node URL is stored in app settings and passed to each upload call at runtime. * - * All network calls are synchronous and must be dispatched from a background coroutine - * or thread by the caller. No suspend functions are used here; OkHttp's blocking API - * is used directly. + * All network calls are suspend functions using Kotlin coroutines with suspendCancellableCoroutine, + * allowing proper dispatcher switching and preventing UI thread blocking. * * Relevant Kubo API endpoints used: * POST /api/v0/add?pin=true Upload and pin a file, returns JSON with "Hash" field. @@ -20,6 +19,7 @@ package io.raventag.app.ipfs import com.google.gson.Gson import com.google.gson.JsonObject +import io.raventag.app.network.executeSuspend import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -79,7 +79,7 @@ object KuboUploader { * @throws Exception if the HTTP response is not successful or the response body * does not contain a "Hash" field. */ - fun uploadFile(bytes: ByteArray, mimeType: String, filename: String, nodeUrl: String): String { + suspend fun uploadFile(bytes: ByteArray, mimeType: String, filename: String, nodeUrl: String): String { // Build a multipart body with the file content as the "file" part. val body = MultipartBody.Builder() .setType(MultipartBody.FORM) @@ -89,7 +89,7 @@ object KuboUploader { .url("${apiBase(nodeUrl)}/add?pin=true") // pin=true keeps the content alive .post(body) .build() - http.newCall(request).execute().use { response -> + http.newCall(request).executeSuspend().use { response -> if (!response.isSuccessful) throw Exception("Kubo upload failed: ${response.code}") // Kubo returns a JSON object like: {"Name":"metadata.json","Hash":"Qm...","Size":"123"} val json = Gson().fromJson(response.body!!.string(), JsonObject::class.java) @@ -107,7 +107,7 @@ object KuboUploader { * @param nodeUrl Base URL of the Kubo node. * @return The IPFS CID of the uploaded JSON file. */ - fun uploadJson(json: String, nodeUrl: String): String = + suspend fun uploadJson(json: String, nodeUrl: String): String = uploadFile(json.toByteArray(Charsets.UTF_8), "application/json", "metadata.json", nodeUrl) /** @@ -122,13 +122,13 @@ object KuboUploader { * @param url Base URL of the Kubo node to test. * @return true if the node is reachable and returns a valid version response, false otherwise. */ - fun testNode(url: String): Boolean { + suspend fun testNode(url: String): Boolean { val request = Request.Builder() .url("${apiBase(url)}/version") // Kubo's /api/v0/version requires a POST; an empty body is sufficient. .post(ByteArray(0).toRequestBody(null)) .build() - return http.newCall(request).execute().use { response -> + return http.newCall(request).executeSuspend().use { response -> if (!response.isSuccessful) return@use false val body = response.body?.string().orEmpty() // A valid Kubo response contains a JSON "Version" field. diff --git a/android/app/src/main/java/io/raventag/app/ipfs/PinataUploader.kt b/android/app/src/main/java/io/raventag/app/ipfs/PinataUploader.kt index 0c33353..8e542dc 100644 --- a/android/app/src/main/java/io/raventag/app/ipfs/PinataUploader.kt +++ b/android/app/src/main/java/io/raventag/app/ipfs/PinataUploader.kt @@ -13,13 +13,14 @@ * POST https://api.pinata.cloud/pinning/pinFileToIPFS Upload and pin a file. * GET https://api.pinata.cloud/data/testAuthentication Validate the JWT. * - * All network calls are synchronous and must be dispatched from a background coroutine - * or thread by the caller. + * All network calls are suspend functions using Kotlin coroutines with suspendCancellableCoroutine, + * allowing proper dispatcher switching and preventing UI thread blocking. */ package io.raventag.app.ipfs import com.google.gson.Gson import com.google.gson.JsonObject +import io.raventag.app.network.executeSuspend import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient @@ -67,7 +68,7 @@ object PinataUploader { * @throws Exception if the HTTP response is not successful or the response body * does not contain an "IpfsHash" field. */ - fun uploadFile(bytes: ByteArray, mimeType: String, filename: String, jwt: String): String { + suspend fun uploadFile(bytes: ByteArray, mimeType: String, filename: String, jwt: String): String { // Build a multipart body with the file content as the "file" part. val body = MultipartBody.Builder() .setType(MultipartBody.FORM) @@ -79,7 +80,7 @@ object PinataUploader { .header("Authorization", "Bearer $jwt") .post(body) .build() - http.newCall(request).execute().use { response -> + http.newCall(request).executeSuspend().use { response -> if (!response.isSuccessful) throw Exception("Pinata upload failed: ${response.code}") // Pinata returns JSON like: {"IpfsHash":"Qm...","PinSize":123,"Timestamp":"..."} val json = Gson().fromJson(response.body!!.string(), JsonObject::class.java) @@ -97,7 +98,7 @@ object PinataUploader { * @param jwt The Pinata JWT token from app settings. * @return The IPFS CID of the uploaded JSON file. */ - fun uploadJson(json: String, jwt: String): String = + suspend fun uploadJson(json: String, jwt: String): String = uploadFile(json.toByteArray(Charsets.UTF_8), "application/json", "metadata.json", jwt) /** @@ -110,12 +111,12 @@ object PinataUploader { * @param jwt The Pinata JWT token to validate. * @return true if the JWT is accepted by Pinata (HTTP 200 response), false otherwise. */ - fun testAuthentication(jwt: String): Boolean { + suspend fun testAuthentication(jwt: String): Boolean { val request = Request.Builder() .url("https://api.pinata.cloud/data/testAuthentication") .header("Authorization", "Bearer $jwt") .get() .build() - return http.newCall(request).execute().use { it.isSuccessful } + return http.newCall(request).executeSuspend().use { it.isSuccessful } } } diff --git a/android/app/src/main/java/io/raventag/app/network/NetworkModule.kt b/android/app/src/main/java/io/raventag/app/network/NetworkModule.kt index 3c564a0..a22a28f 100644 --- a/android/app/src/main/java/io/raventag/app/network/NetworkModule.kt +++ b/android/app/src/main/java/io/raventag/app/network/NetworkModule.kt @@ -66,9 +66,11 @@ object NetworkModule { return OkHttpClient.Builder() .cache(cache) + // D-10: single canonical timeout pair for all HTTP traffic + // (ElectrumX TLS sockets have their own per-socket timeouts). .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .writeTimeout(15, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(20, TimeUnit.SECONDS) // Use a browser-like User-Agent: public IPFS gateways (ipfs.io, cloudflare) // block requests with the default okhttp/* user agent. .addInterceptor { chain -> @@ -82,8 +84,8 @@ object NetworkModule { .followRedirects(true) .followSslRedirects(true) .dispatcher(okhttp3.Dispatcher().apply { - maxRequests = 50 - maxRequestsPerHost = 20 + maxRequests = 100 + maxRequestsPerHost = 50 }) .build() } diff --git a/android/app/src/main/java/io/raventag/app/network/OkHttpExtensions.kt b/android/app/src/main/java/io/raventag/app/network/OkHttpExtensions.kt new file mode 100644 index 0000000..a8ce89f --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/network/OkHttpExtensions.kt @@ -0,0 +1,24 @@ +package io.raventag.app.network + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Request + +/** + * Suspend extension function for OkHttp Call. + * Converts blocking execute() to suspend using withContext(Dispatchers.IO). + * This approach properly switches dispatchers to prevent UI thread blocking. + * The call is executed on the IO dispatcher, allowing non-blocking behavior from calling contexts. + */ +suspend fun Call.executeSuspend(): okhttp3.Response = withContext(Dispatchers.IO) { + execute() +} + +/** + * Convenience suspend function that builds a Request and executes it on the shared client. + */ +suspend fun okhttp3.OkHttpClient.getWithTimeout(url: String): okhttp3.Response = withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).get().build() + newCall(request).execute() +} diff --git a/android/app/src/main/java/io/raventag/app/nfc/NfcCounterCache.kt b/android/app/src/main/java/io/raventag/app/nfc/NfcCounterCache.kt index 084d126..db88d64 100644 --- a/android/app/src/main/java/io/raventag/app/nfc/NfcCounterCache.kt +++ b/android/app/src/main/java/io/raventag/app/nfc/NfcCounterCache.kt @@ -38,27 +38,33 @@ import androidx.security.crypto.MasterKey * * @param context Application context, used to access SharedPreferences. */ -class NfcCounterCache(context: Context) { +class NfcCounterCache(private val context: Context) { /** * The underlying preferences store. Tries EncryptedSharedPreferences first * (AES-256-SIV for keys, AES-256-GCM for values). Falls back to plain * SharedPreferences if hardware encryption is unavailable. + * + * Initialized lazily on first use (during NFC verification, not at ViewModel + * construction time) so that the Keystore operation does not block the main thread + * during app startup. */ - private val prefs: SharedPreferences = try { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - context, - PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } catch (_: Throwable) { - // Fallback to plain prefs if encryption unavailable - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val prefs: SharedPreferences by lazy { + try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (_: Throwable) { + // Fallback to plain prefs if encryption unavailable + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } } /** diff --git a/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt b/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt index a052249..5428e18 100644 --- a/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt +++ b/android/app/src/main/java/io/raventag/app/ravencoin/RpcClient.kt @@ -6,6 +6,9 @@ import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName import io.raventag.app.ipfs.IpfsResolver +import io.raventag.app.network.executeSuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -105,7 +108,7 @@ class RpcClient( val params: List ) - private fun rpcCall(method: String, params: List = emptyList()): JsonObject { + private suspend fun rpcCall(method: String, params: List = emptyList()): JsonObject { val payload = RpcPayload(method = method, params = params) val body = gson.toJson(payload).toRequestBody(json) val request = Request.Builder() @@ -113,7 +116,7 @@ class RpcClient( .post(body) .build() - val response = http.newCall(request).execute() + val response = http.newCall(request).executeSuspend() if (!response.isSuccessful) { throw IOException("RPC HTTP error: ${response.code}") } @@ -132,9 +135,9 @@ class RpcClient( * Get raw asset data via ElectrumX blockchain.asset.get_meta (no backend required). * Falls back to backend proxy if ElectrumX call fails. */ - fun getAssetData(assetName: String): AssetData? { + suspend fun getAssetData(assetName: String): AssetData? { val meta = try { - io.raventag.app.wallet.RavencoinPublicNode().getAssetMeta(assetName.uppercase()) + context?.let { io.raventag.app.wallet.RavencoinPublicNode(it).getAssetMeta(assetName.uppercase()) } } catch (_: Exception) { null } if (meta != null) { return AssetData( @@ -151,7 +154,7 @@ class RpcClient( val request = Request.Builder() .url("$rpcUrl/api/assets/${assetName.uppercase()}") .get().build() - val response = http.newCall(request).execute() + val response = http.newCall(request).executeSuspend() if (!response.isSuccessful) return null val obj = gson.fromJson(response.body?.string(), JsonObject::class.java) AssetData( @@ -168,7 +171,7 @@ class RpcClient( /** * Fetch metadata JSON from IPFS gateway. */ - fun fetchIpfsMetadata(ipfsUri: String): RaventagMetadata? { + suspend fun fetchIpfsMetadata(ipfsUri: String): RaventagMetadata? { val urls = IpfsResolver.candidateUrls(ipfsUri).ifEmpty { when { ipfsUri.startsWith("ipfs://") -> listOf(ipfsGateway + ipfsUri.removePrefix("ipfs://")) @@ -179,7 +182,7 @@ class RpcClient( urls.forEach { url -> runCatching { val request = Request.Builder().url(url).get().build() - val response = http.newCall(request).execute() + val response = http.newCall(request).executeSuspend() if (!response.isSuccessful) { Log.w(TAG, "fetchIpfsMetadata $ipfsUri via $url http=${response.code}") return@runCatching null @@ -196,12 +199,12 @@ class RpcClient( /** * Search assets by name pattern via backend proxy. */ - fun searchAssets(query: String): List { + suspend fun searchAssets(query: String): List { return try { val request = Request.Builder() .url("$rpcUrl/api/assets?search=${query.uppercase()}") .get().build() - val response = http.newCall(request).execute() + val response = http.newCall(request).executeSuspend() if (!response.isSuccessful) return emptyList() val obj = gson.fromJson(response.body?.string(), JsonObject::class.java) val arr = obj["assets"]?.asJsonArray ?: return emptyList() @@ -213,7 +216,8 @@ class RpcClient( * List all Ravencoin assets owned by a given address via ElectrumX (no backend required). */ fun listAssetsByAddress(address: String): List { - val node = io.raventag.app.wallet.RavencoinPublicNode() + val node = context?.let { io.raventag.app.wallet.RavencoinPublicNode(it) } + ?: return emptyList() val assetBalances = node.getAssetBalances(address) // Parallelize metadata fetching using a fixed thread pool or coroutines scope @@ -243,7 +247,7 @@ class RpcClient( * IPFS metadata is cached by IPFS hash to avoid redundant network calls * while ensuring each asset gets its own correct metadata. */ - fun enrichWithIpfsData(asset: OwnedAsset): OwnedAsset { + suspend fun enrichWithIpfsData(asset: OwnedAsset): OwnedAsset { // Use ipfsHash already fetched in listAssetsByAddress when available, // falling back to a fresh getAssetData call only if needed. val hash = asset.ipfsHash ?: run { @@ -267,7 +271,7 @@ class RpcClient( data class Result(val imageUrl: String?, val description: String?) val found: Result? = try { val req = Request.Builder().url(url).get().build() - http.newCall(req).execute().use { resp -> + http.newCall(req).executeSuspend().use { resp -> if (!resp.isSuccessful) { Log.w(TAG, "enrichWithIpfsData ${asset.name} HTTP ${resp.code} via $url") return@use null @@ -286,8 +290,8 @@ class RpcClient( when { img.startsWith("http") -> img img.startsWith("ipfs://") -> img - img.startsWith("/ipfs/") -> "ipfs://${img.removePrefix("/ipfs/")}" - else -> "ipfs://$img" + img.startsWith("/ipfs/") -> img.removePrefix("/ipfs/") + else -> img // Already a bare CID } } val description = json["description"]?.takeIf { !it.isJsonNull }?.asString @@ -311,7 +315,7 @@ class RpcClient( /** * Get asset with RTP-1 metadata. */ - fun getAssetWithMetadata(assetName: String): Pair? { + suspend fun getAssetWithMetadata(assetName: String): Pair? { val asset = getAssetData(assetName) ?: return null val metadata = asset.ipfsHash?.let { try { fetchIpfsMetadata("ipfs://$it") } catch (e: Exception) { null } diff --git a/android/app/src/main/java/io/raventag/app/security/AdminKeyStorage.kt b/android/app/src/main/java/io/raventag/app/security/AdminKeyStorage.kt new file mode 100644 index 0000000..a030610 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/security/AdminKeyStorage.kt @@ -0,0 +1,90 @@ +/** + * AdminKeyStorage.kt + * + * Secure storage for the admin API key using AndroidX Security Crypto. + * + * This class provides encrypted storage for the admin key using EncryptedSharedPreferences + * with AES-256-GCM encryption via the Android Keystore. This prevents extraction of the + * admin key from the compiled APK (unlike BuildConfig, which is extractable via static + * analysis tools like strings or JADX). + * + * The admin key is persisted across app restarts in encrypted form, and can be + * configured via the Settings screen by the user. + * + * @property context Application context used to create EncryptedSharedPreferences. + */ +package io.raventag.app.security + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +/** + * Secure storage wrapper for the admin API key. + * + * Uses AES-256-GCM encryption via Android Keystore when available. The key is never + * stored in plain text in the APK (BuildConfig) or in shared preferences. + */ +class AdminKeyStorage(context: Context) { + + /** + * Master key for encrypted preferences. + * Uses AES-256-GCM encryption scheme for maximum security. + */ + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + /** + * Encrypted shared preferences instance. + * All values stored here are encrypted/decrypted transparently by AndroidX Security. + */ + private val sharedPrefs = EncryptedSharedPreferences.create( + context, + "admin_key_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + private companion object { + /** Key name for storing the admin key in preferences. */ + private const val KEY_ADMIN_KEY = "admin_key" + } + + /** + * Retrieve the stored admin key. + * + * @return The admin key as a string, or null if not configured. + */ + fun getAdminKey(): String? { + return sharedPrefs.getString(KEY_ADMIN_KEY, null) + } + + /** + * Store the admin key in encrypted storage. + * + * @param key The admin key to store. + */ + fun setAdminKey(key: String) { + sharedPrefs.edit().putString(KEY_ADMIN_KEY, key).apply() + } + + /** + * Check whether an admin key has been configured. + * + * @return true if an admin key is stored, false otherwise. + */ + fun hasAdminKey(): Boolean { + return sharedPrefs.contains(KEY_ADMIN_KEY) + } + + /** + * Remove the stored admin key. + * + * This effectively logs out the user from admin mode. + */ + fun clearAdminKey() { + sharedPrefs.edit().remove(KEY_ADMIN_KEY).apply() + } +} diff --git a/android/app/src/main/java/io/raventag/app/security/BiometricGate.kt b/android/app/src/main/java/io/raventag/app/security/BiometricGate.kt new file mode 100644 index 0000000..e623b64 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/security/BiometricGate.kt @@ -0,0 +1,66 @@ +package io.raventag.app.security + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import javax.crypto.Cipher +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * D-15: binds BiometricPrompt authentication to a Keystore decrypt operation via + * `BiometricPrompt.CryptoObject`. Authentication is NOT a boolean flag; no auth, no + * plaintext. + * + * Caller constructs a fresh instance per reveal. Not thread-safe on purpose. + */ +class BiometricGate(private val activity: FragmentActivity) { + + suspend fun decryptWithBiometric( + cipher: Cipher, + ciphertext: ByteArray, + titleRes: Int, + subtitleRes: Int + ): ByteArray = suspendCancellableCoroutine { cont -> + val prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + try { + val c = result.cryptoObject?.cipher + ?: return cont.resumeWithException( + IllegalStateException("no cipher bound") + ) + cont.resume(c.doFinal(ciphertext)) + } catch (t: Throwable) { + cont.resumeWithException(t) + } + } + + override fun onAuthenticationError(code: Int, msg: CharSequence) { + cont.resumeWithException( + BiometricCancelledException(code, msg.toString()) + ) + } + } + ) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(titleRes)) + .setSubtitle(activity.getString(subtitleRes)) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .build() + prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) + cont.invokeOnCancellation { prompt.cancelAuthentication() } + } +} + +class BiometricCancelledException( + val code: Int, + message: String +) : RuntimeException(message) diff --git a/android/app/src/main/java/io/raventag/app/security/MnemonicExporter.kt b/android/app/src/main/java/io/raventag/app/security/MnemonicExporter.kt new file mode 100644 index 0000000..98e3fc7 Binary files /dev/null and b/android/app/src/main/java/io/raventag/app/security/MnemonicExporter.kt differ diff --git a/android/app/src/main/java/io/raventag/app/security/TofuFingerprintDao.kt b/android/app/src/main/java/io/raventag/app/security/TofuFingerprintDao.kt new file mode 100644 index 0000000..d9a10e6 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/security/TofuFingerprintDao.kt @@ -0,0 +1,116 @@ +package io.raventag.app.security + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +/** + * SQLite DAO for persistent TOFU certificate fingerprints. + * + * Stores ElectrumX server certificate fingerprints in a local SQLite database, + * allowing TOFU (Trust On First Use) certificate pinning to survive app restarts. + * This closes the security gap where in-memory-only TOFU caches would accept + * different certificates after each restart, creating a window for MITM attacks. + * + * Database: electrum_certificates.db + * Table: tofu_fingerprints + * Schema: host (TEXT PRIMARY KEY), fingerprint (TEXT NOT NULL), pinned_at (INTEGER NOT NULL) + */ +object TofuFingerprintDao { + private const val CERT_DB_NAME = "electrum_certificates.db" + private const val CERT_TABLE = "tofu_fingerprints" + private const val DB_VERSION = 1 + + /** + * SQLite helper class for the certificate fingerprint database. + */ + private class CertDbHelper(context: Context) : SQLiteOpenHelper(context, CERT_DB_NAME, null, DB_VERSION) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS $CERT_TABLE ( + host TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + pinned_at INTEGER NOT NULL + ) + """.trimIndent()) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // No migration needed for version 1 + } + } + + private var dbHelper: CertDbHelper? = null + private var db: SQLiteDatabase? = null + private var initialized = false + private val initLock = Any() + + /** + * Initializes the DAO with the application context. + * This must be called before any other methods. + * Thread-safe and idempotent. + * + * @param context Application context (use applicationContext for safety) + */ + fun init(context: Context) { + synchronized(initLock) { + if (initialized) return + dbHelper = CertDbHelper(context.applicationContext) + db = dbHelper!!.writableDatabase + initialized = true + } + } + + /** + * Retrieves the stored fingerprint for a given ElectrumX host. + * + * @param host ElectrumX server hostname + * @return Fingerprint hex string if previously pinned, null otherwise + */ + fun getFingerprint(host: String): String? { + db ?: return null + val cursor = db!!.query( + CERT_TABLE, + arrayOf("fingerprint"), + "host = ?", + arrayOf(host), + null, null, null + ) + return cursor.use { + if (it.moveToFirst()) it.getString(0) else null + } + } + + /** + * Pins a certificate fingerprint for an ElectrumX host. + * If a fingerprint already exists for the host, it is replaced. + * + * @param host ElectrumX server hostname + * @param fingerprint SHA-256 fingerprint hex string + */ + fun pinFingerprint(host: String, fingerprint: String) { + db ?: return + val values = ContentValues().apply { + put("host", host) + put("fingerprint", fingerprint) + put("pinned_at", System.currentTimeMillis()) + } + db!!.insertWithOnConflict( + CERT_TABLE, + null, + values, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + + /** + * Clears all stored fingerprints from the database. + * Use this when the user wants to reset TOFU trust (e.g., after a legitimate server certificate rotation). + */ + fun clearFingerprints() { + db ?: return + db!!.delete(CERT_TABLE, null, null) + } +} diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/BrandDashboardScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/BrandDashboardScreen.kt index 77aa71a..19416fd 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/BrandDashboardScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/BrandDashboardScreen.kt @@ -93,7 +93,7 @@ fun BrandDashboardScreen( Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Icon(Icons.Default.Info, contentDescription = null, tint = AuthenticGreen, modifier = Modifier.size(18.dp)) Column { - Text("Protocol RTP-1", fontWeight = FontWeight.SemiBold, color = AuthenticGreen, style = MaterialTheme.typography.bodyMedium) + Text(s.protocolRtpBadge, fontWeight = FontWeight.SemiBold, color = AuthenticGreen, style = MaterialTheme.typography.bodyMedium) Text(s.brandProtocolDesc, style = MaterialTheme.typography.bodySmall, color = RavenMuted, modifier = Modifier.padding(top = 4.dp)) } } diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/IssueAssetScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/IssueAssetScreen.kt index b610c27..a55fc7a 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/IssueAssetScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/IssueAssetScreen.kt @@ -24,6 +24,7 @@ package io.raventag.app.ui.screens import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -41,6 +42,12 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.platform.LocalContext +import io.raventag.app.IssueStep +import io.raventag.app.WarningType +import io.raventag.app.config.AppConfig import io.raventag.app.ravencoin.AssetType import io.raventag.app.ravencoin.OwnedAsset import io.raventag.app.ui.theme.* @@ -83,6 +90,9 @@ fun IssueAssetScreen( isLoading: Boolean, resultMessage: String?, resultSuccess: Boolean?, + currentStep: IssueStep = IssueStep.Idle, + issuedTxid: String? = null, + warningType: WarningType? = null, prefilledAddress: String = "", ownedAssets: List = emptyList(), savedAdminKey: String = "", @@ -99,6 +109,7 @@ fun IssueAssetScreen( onIssueUniqueAndWriteTag: ((parentSub: String, serial: String, toAddress: String, ipfsHash: String?, description: String?) -> Unit)? = null ) { val s = LocalStrings.current + val context = LocalContext.current // Read API base URL from BuildConfig so the IPFS uploader can reach the backend. val apiBaseUrl = io.raventag.app.BuildConfig.API_BASE_URL @@ -259,15 +270,40 @@ fun IssueAssetScreen( border = BorderStroke(1.dp, if (success) AuthenticGreen.copy(0.4f) else NotAuthenticRed.copy(0.4f)), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() ) { - Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(if (success) Icons.Default.CheckCircle else Icons.Default.Error, contentDescription = null, - tint = if (success) AuthenticGreen else NotAuthenticRed, modifier = Modifier.size(20.dp)) - Text(resultMessage ?: "", color = if (success) AuthenticGreen else NotAuthenticRed, style = MaterialTheme.typography.bodySmall) + Column(modifier = Modifier.padding(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(if (success) Icons.Default.CheckCircle else Icons.Default.Error, contentDescription = null, + tint = if (success) AuthenticGreen else NotAuthenticRed, modifier = Modifier.size(20.dp)) + Text(resultMessage ?: "", color = if (success) AuthenticGreen else NotAuthenticRed, style = MaterialTheme.typography.bodySmall) + } + // Tappable txid: opens block explorer when txid is available + if (success && issuedTxid != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = issuedTxid, + color = AuthenticGreen, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier + .clickable { + val uri = Uri.parse(AppConfig.EXPLORER_URL + issuedTxid) + try { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } catch (_: Exception) { } + } + .heightIn(min = 48.dp) + ) + } } } Spacer(modifier = Modifier.height(16.dp)) } + // Phase 40: Inline pre-issuance warning (driven by ViewModel warningType) + if (warningType != null) { + PreIssuanceWarning(warningType = warningType) + Spacer(modifier = Modifier.height(12.dp)) + } + // Form fields: each branch shows only the inputs relevant to its mode. when (mode) { IssueMode.ROOT_ASSET -> { @@ -296,8 +332,12 @@ fun IssueAssetScreen( val effectiveIpfs = imageState.value.ipfsCid val currentReissuable = reissuable // Minimum validation: asset name at least 3 chars and a plausible RVN address length. - SubmitButton(s.btnIssueRoot, isLoading, assetName.length >= 3 && toAddress.length >= 26, RavenOrange) { - onIssueRoot(assetName, qty.toLongOrNull() ?: 1, toAddress, effectiveIpfs, currentReissuable) + if (currentStep is IssueStep.Idle) { + SubmitButton(s.btnIssueRoot, isLoading, assetName.length >= 3 && toAddress.length >= 26, RavenOrange) { + onIssueRoot(assetName, qty.toLongOrNull() ?: 1, toAddress, effectiveIpfs, currentReissuable) + } + } else { + MultiStepProgressIndicator(currentStep = currentStep, showNfcStep = false) } } @@ -338,8 +378,12 @@ fun IssueAssetScreen( val subEffectiveIpfs = imageState.value.ipfsCid val subEnabled = parentAsset.length >= 3 && childName.length >= 1 && toAddress.length >= 26 - SubmitButton(s.btnIssueSub, isLoading, subEnabled, RavenOrange) { - onIssueSub(parentAsset, childName, qty.toLongOrNull() ?: 1, toAddress, subEffectiveIpfs, currentReissuable) + if (currentStep is IssueStep.Idle) { + SubmitButton(s.btnIssueSub, isLoading, subEnabled, RavenOrange) { + onIssueSub(parentAsset, childName, qty.toLongOrNull() ?: 1, toAddress, subEffectiveIpfs, currentReissuable) + } + } else { + MultiStepProgressIndicator(currentStep = currentStep, showNfcStep = false) } } @@ -402,14 +446,18 @@ fun IssueAssetScreen( // If the caller provides a combined issue-and-write callback, use that button instead. // This path is taken when the screen is launched from the "Issue + Write Tag" flow, // allowing the asset issuance and NFC programming to happen in a single user action. - if (onIssueUniqueAndWriteTag != null) { - SubmitButton(s.btnIssueAndWrite, isLoading, uniqueEnabled, AuthenticGreen) { - onIssueUniqueAndWriteTag(subAsset, serial, toAddress, uniqueEffectiveIpfs, uniqueDescription) + if (currentStep is IssueStep.Idle) { + if (onIssueUniqueAndWriteTag != null) { + SubmitButton(s.btnIssueAndWrite, isLoading, uniqueEnabled, AuthenticGreen) { + onIssueUniqueAndWriteTag(subAsset, serial, toAddress, uniqueEffectiveIpfs, uniqueDescription) + } + } else { + SubmitButton(s.btnIssueUnique, isLoading, uniqueEnabled, AuthenticGreen) { + onIssueUnique(subAsset, serial, toAddress, uniqueEffectiveIpfs, uniqueDescription) + } } } else { - SubmitButton(s.btnIssueUnique, isLoading, uniqueEnabled, AuthenticGreen) { - onIssueUnique(subAsset, serial, toAddress, uniqueEffectiveIpfs, uniqueDescription) - } + MultiStepProgressIndicator(currentStep = currentStep, showNfcStep = onIssueUniqueAndWriteTag != null) } } @@ -685,6 +733,147 @@ private fun RavenSwitch(label: String, checked: Boolean, onCheckedChange: (Boole } } +@Composable +private fun PreIssuanceWarning(warningType: WarningType) { + when (warningType) { + WarningType.INSUFFICIENT_BALANCE -> { + Card( + colors = CardDefaults.cardColors(containerColor = RavenCard), + border = BorderStroke(1.dp, AmberWarning.copy(alpha = 0.4f)), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Warning, contentDescription = null, + tint = AmberWarning, modifier = Modifier.size(16.dp)) + Text("Insufficient balance for this asset type.", + color = AmberWarning, style = MaterialTheme.typography.bodySmall) + } + } + } + WarningType.DUPLICATE_NAME -> { + Card( + colors = CardDefaults.cardColors(containerColor = RavenCard), + border = BorderStroke(1.dp, RavenOrange.copy(alpha = 0.4f)), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Info, contentDescription = null, + tint = RavenOrange, modifier = Modifier.size(16.dp)) + Text("Asset name already exists. Choose a different name.", + color = RavenOrange, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +@Composable +private fun StepRow(stepName: IssueStep.StepName, currentStep: IssueStep) { + val isActive = currentStep is IssueStep.InProgress && currentStep.step == stepName + val isDone = currentStep is IssueStep.Success && currentStep.step == stepName + val isFailed = currentStep is IssueStep.Failed && currentStep.step == stepName + val labelColor = when { + isActive -> RavenOrange + isDone -> AuthenticGreen.copy(alpha = 0.7f) + isFailed -> NotAuthenticRed + else -> RavenMuted + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.width(28.dp), contentAlignment = Alignment.Center) { + when { + isActive -> CircularProgressIndicator( + modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = RavenOrange + ) + isDone -> Icon(Icons.Default.CheckCircle, contentDescription = null, + tint = AuthenticGreen, modifier = Modifier.size(20.dp)) + isFailed -> Icon(Icons.Default.Error, contentDescription = null, + tint = NotAuthenticRed, modifier = Modifier.size(20.dp)) + else -> Box( + modifier = Modifier.size(20.dp) + .border(2.dp, RavenBorder, RoundedCornerShape(10.dp)) + ) + } + } + Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { + Text(stepLabel(stepName), color = labelColor, style = MaterialTheme.typography.bodySmall) + if (currentStep is IssueStep.Failed) { + Spacer(modifier = Modifier.height(2.dp)) + Text(currentStep.error, color = NotAuthenticRed, style = MaterialTheme.typography.labelSmall) + } + } + } +} + +private fun stepLabel(step: IssueStep.StepName): String = when (step) { + IssueStep.StepName.IPFS_UPLOAD -> "Uploading to IPFS..." + IssueStep.StepName.BALANCE_CHECK -> "Checking balance..." + IssueStep.StepName.NAME_CHECK -> "Checking name..." + IssueStep.StepName.ISSUING -> "Issuing on Ravencoin..." + IssueStep.StepName.CONFIRMING -> "Confirming..." + IssueStep.StepName.NFC_PROGRAMMING -> "Programming NFC tag..." +} + +@Composable +private fun MultiStepProgressIndicator(currentStep: IssueStep, showNfcStep: Boolean) { + val steps = buildList { + add(IssueStep.StepName.IPFS_UPLOAD) + add(IssueStep.StepName.BALANCE_CHECK) + add(IssueStep.StepName.NAME_CHECK) + add(IssueStep.StepName.ISSUING) + add(IssueStep.StepName.CONFIRMING) + if (showNfcStep) add(IssueStep.StepName.NFC_PROGRAMMING) + } + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + steps.forEachIndexed { index, step -> + if (index > 0) { + Box( + modifier = Modifier + .width(2.dp) + .height(16.dp) + .background(RavenBorder) + .padding(start = 13.dp) + ) + } + StepRow(step, currentStep) + } + } +} + +@Composable +private fun ConfirmationProgressRow(confirmations: Int) { + val s = LocalStrings.current + Row( + modifier = Modifier.padding(start = 36.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (confirmations >= 6) Icons.Default.CheckCircle else Icons.Default.Schedule, + contentDescription = null, + tint = if (confirmations >= 6) AuthenticGreen else AmberWarning, + modifier = Modifier.size(14.dp) + ) + Text( + if (confirmations >= 6) s.confirmComplete else s.confirmProgress.replace("%1\$d", confirmations.toString()), + color = if (confirmations >= 6) AuthenticGreen else AmberWarning, + style = MaterialTheme.typography.bodySmall + ) + } +} + +/** Amber warning color token. */ +private val AmberWarning = Color(0xFFF59E0B) + /** * Full-width submit button shared across all form modes. * @@ -693,8 +882,15 @@ private fun RavenSwitch(label: String, checked: Boolean, onCheckedChange: (Boole * true to prevent double submission. The background color is dimmed to 30% opacity * when disabled to preserve the visual intent of the mode's accent color. * + * Implements the Button Loading Spinner pattern from 20-UI-SPEC.md (Phase 20 Plan 06): + * - 20.dp white CircularProgressIndicator during IPFS upload or asset issuance + * (loading = MainViewModel.issueLoading is forwarded in as the [loading] flag) + * - 2.dp stroke width + * - disabled container at 30% opacity (containerColor.copy(alpha = 0.3f)) + * - spinner replaces button label + icon while loading + * * @param text Button label shown in the normal (non-loading) state. - * @param loading Whether to show the spinner instead of the label. + * @param loading Whether to show the spinner instead of the label. Drives issueLoading. * @param enabled Whether the form is in a valid state for submission. * @param color Accent color for this mode (orange for issue, green for unique, red for revoke). * @param onClick Invoked when the button is tapped in the enabled, non-loading state. @@ -708,6 +904,8 @@ private fun SubmitButton(text: String, loading: Boolean, enabled: Boolean, color shape = RoundedCornerShape(14.dp) ) { if (loading) { + // Button Loading Spinner (per 20-UI-SPEC.md): 20.dp white spinner, + // 2.dp stroke, centered, visible while issueLoading is true. CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } else { Text(text, fontWeight = FontWeight.SemiBold) diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt index 867ad1c..557de2e 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/MnemonicBackupScreen.kt @@ -1,5 +1,8 @@ package io.raventag.app.ui.screens +import android.app.Activity +import android.content.Context +import android.view.WindowManager import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* @@ -15,67 +18,97 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity +import io.raventag.app.security.BiometricCancelledException +import io.raventag.app.security.BiometricGate +import io.raventag.app.security.MnemonicExporter import io.raventag.app.ui.theme.* +import io.raventag.app.wallet.KeystoreInvalidatedException +import io.raventag.app.wallet.WalletManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** - * Full-screen overlay shown exactly once after a new wallet is generated. - * - * Forces the user to acknowledge their BIP-39 12-word seed phrase before the wallet is - * persisted. The flow is: - * 1. The 12 words are displayed in a numbered 3-column grid. - * 2. An optional "Copy All" button copies the mnemonic to the clipboard, then erases it - * automatically after 60 seconds to limit exposure. - * 3. Three warning cards remind the user never to share the phrase and that it cannot be - * recovered if lost. - * 4. A confirmation checkbox must be ticked before the "I've saved it" button becomes active. - * 5. Tapping the button calls [onConfirmed], which finalizes the wallet in the ViewModel and - * clears [mnemonic] from memory. + * Full-screen overlay that displays the BIP-39 recovery phrase. * - * After dismissal this screen is never shown again automatically; the phrase can be revealed - * later from the Wallet screen. + * Two operating modes: + * 1. Fresh-wallet setup: `mnemonic` is provided by the caller (the wallet has just been + * generated but not yet persisted). The words render directly after a biometric cover + * gate (the OS prompt may be skipped in this path: the mnemonic is already in-memory). + * 2. Later reveal (`mnemonic` is null/blank): the user must authenticate via + * [BiometricGate] bound to the Keystore decrypt operation (D-15). The resulting + * plaintext arrives as a [CharArray] that is zero-filled on dispose (D-16). * - * @param mnemonic Space-separated 12-word BIP-39 mnemonic phrase generated for the new wallet. - * @param onConfirmed Callback invoked when the user confirms they have saved the phrase. - * Should finalize wallet persistence and clear the mnemonic from ViewModel state. + * Security measures applied in both modes: + * - `FLAG_SECURE` is set while the screen is composed, blocking screenshots and screen + * recording (RESEARCH Security Domain recommendation). + * - A confirmation gate ("I've saved it") flips the `backup_completed` SharedPreferences + * flag so that restore-over-wallet is unblocked (D-14). */ @Composable fun MnemonicBackupScreen( - mnemonic: String, - onConfirmed: () -> Unit + mnemonic: String? = null, + wm: WalletManager? = null, + onConfirmed: () -> Unit, + onKeystoreInvalidated: () -> Unit = {} ) { val s = LocalStrings.current val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val view = LocalView.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } - // Split the single mnemonic string into individual words for grid rendering. - val words = mnemonic.trim().split(" ") + // FLAG_SECURE: prevent OS screenshot/screen-recording of the words grid. + DisposableEffect(Unit) { + val window = (view.context as? Activity)?.window + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + } - // True while the mnemonic has been copied to clipboard (drives icon and label change). - var copied by remember { mutableStateOf(false) } + // Revealed mnemonic characters. In setup-flow, populated synchronously from `mnemonic` + // after the user taps "Reveal phrase". In reveal-flow, populated by BiometricGate. + var revealed by remember { mutableStateOf(null) } - // True when the user has ticked the confirmation checkbox, enabling the continue button. - var confirmed by remember { mutableStateOf(false) } + // D-16: zero-fill the decrypted buffer when the screen is disposed. + // Capture the buffer ref at effect launch so onDispose wipes the SAME buffer + // this effect was keyed to, not whatever `revealed` points to at dispose time. + DisposableEffect(revealed) { + val captured = revealed + onDispose { captured?.let { java.util.Arrays.fill(it, ' ') } } + } - val scope = rememberCoroutineScope() + var copied by remember { mutableStateOf(false) } + var confirmed by remember { mutableStateOf(false) } + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Black + ) { padding -> Column( modifier = Modifier + .padding(padding) .fillMaxSize() - .background(Color.Black) // Pure black to match the logo background + .background(Color.Black) .statusBarsPadding() .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(32.dp)) - // Warning icon in an amber-tinted rounded square container. Box( modifier = Modifier .size(64.dp) @@ -108,183 +141,280 @@ fun MnemonicBackupScreen( Spacer(modifier = Modifier.height(24.dp)) - // ---------------------------------------------------------------- - // 12-word grid: words are chunked into rows of 3. - // Each cell shows a sequential number label and the word in monospace. - // ---------------------------------------------------------------- - Column( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - .background(Color(0xFF0A0A0A), RoundedCornerShape(16.dp)) - .border(1.dp, Color(0xFFF59E0B).copy(alpha = 0.4f), RoundedCornerShape(16.dp)) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - words.chunked(3).forEachIndexed { rowIdx, row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - row.forEachIndexed { colIdx, word -> - // Word ordinal number (1-based) for user-readable labeling. - val n = rowIdx * 3 + colIdx + 1 - Row( - modifier = Modifier - .weight(1f) - .background(RavenCard, RoundedCornerShape(8.dp)) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - // Ordinal number in muted color with a fixed width to align words. - Text( - "$n.", - style = MaterialTheme.typography.labelSmall, - color = RavenMuted, - modifier = Modifier.width(18.dp) - ) - // The word itself in monospace for legibility and copy-paste accuracy. - Text( - word, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = Color.White, - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp + if (revealed == null) { + // ---------------------------------------------------------------- + // Biometric cover card (D-15). Words grid is hidden until auth + // succeeds. In setup-flow we still require the user tap Reveal so + // the screen is never passively rendered with the phrase visible. + // ---------------------------------------------------------------- + Column( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .background(RavenCard, RoundedCornerShape(12.dp)) + .border(1.dp, RavenBorder, RoundedCornerShape(12.dp)) + .padding(16.dp) + .semantics { contentDescription = s.biometricCoverDesc }, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon( + Icons.Default.Fingerprint, + contentDescription = null, + tint = RavenOrange, + modifier = Modifier.size(24.dp) + ) + Text( + s.mnemonicBiometricCoverTitle, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + } + Text( + s.mnemonicBiometricCoverBody, + style = MaterialTheme.typography.bodyMedium, + color = RavenMuted + ) + Button( + onClick = { + scope.launch { + revealWithBiometric( + context = context, + wm = wm, + prefillMnemonic = mnemonic, + strings = s, + snackbarHostState = snackbarHostState, + onKeystoreInvalidated = onKeystoreInvalidated, + onRevealed = { chars -> revealed = chars } ) } + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = s.revealMnemonicButtonDesc }, + colors = ButtonDefaults.buttonColors(containerColor = RavenOrange), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(s.mnemonicRevealCta, fontWeight = FontWeight.SemiBold) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } else { + val raw = String(revealed!!).trim() + val words = if (raw.isEmpty()) emptyList() else raw.split(Regex("\\s+")) + + // ---------------------------------------------------------------- + // Words grid: rows of 3. Skip render if buffer already zero-filled + // (confirm-in-flight) to avoid a phantom "1." cell. + // ---------------------------------------------------------------- + if (words.isNotEmpty()) + Column( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .background(Color(0xFF0A0A0A), RoundedCornerShape(16.dp)) + .border(1.dp, Color(0xFFF59E0B).copy(alpha = 0.4f), RoundedCornerShape(16.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + words.chunked(3).forEachIndexed { rowIdx, row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + row.forEachIndexed { colIdx, word -> + val n = rowIdx * 3 + colIdx + 1 + Row( + modifier = Modifier + .weight(1f) + .background(RavenCard, RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + "$n.", + style = MaterialTheme.typography.labelSmall, + color = RavenMuted, + modifier = Modifier.width(18.dp) + ) + Text( + word, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp + ) + } + } } } } - } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // ---------------------------------------------------------------- - // Copy All button: copies the full mnemonic string to the clipboard. - // A coroutine erases the clipboard after 60 seconds as a security precaution, - // since the mnemonic grants full wallet access. - // ---------------------------------------------------------------- - OutlinedButton( - onClick = { - clipboard.setText(AnnotatedString(mnemonic)) - copied = true - scope.launch { - delay(60_000) - // Clear clipboard after 60 seconds for security - clipboard.setText(AnnotatedString("")) - copied = false - } - }, - modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(), - border = androidx.compose.foundation.BorderStroke(1.dp, if (copied) AuthenticGreen else RavenBorder), - colors = ButtonDefaults.outlinedButtonColors(contentColor = if (copied) AuthenticGreen else RavenMuted) - ) { - // Icon and label switch between "Copy" and "Copied" states. - Icon( - if (copied) Icons.Default.CheckCircle else Icons.Default.ContentCopy, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - if (copied) s.backupCopied else s.backupCopyAll, - style = MaterialTheme.typography.bodySmall - ) - } + // Copy All: briefly copies to clipboard; cleared after 60s. + OutlinedButton( + onClick = { + val asString = String(revealed!!) + clipboard.setText(AnnotatedString(asString)) + copied = true + scope.launch { + delay(60_000) + clipboard.setText(AnnotatedString("")) + copied = false + } + }, + modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(), + border = androidx.compose.foundation.BorderStroke(1.dp, if (copied) AuthenticGreen else RavenBorder), + colors = ButtonDefaults.outlinedButtonColors(contentColor = if (copied) AuthenticGreen else RavenMuted) + ) { + Icon( + if (copied) Icons.Default.CheckCircle else Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + if (copied) s.backupCopied else s.mnemonicCopyAll, + style = MaterialTheme.typography.bodySmall + ) + } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // ---------------------------------------------------------------- - // Warning cards: three short reminders about seed phrase security. - // Rendered from the localized strings list so they are translated automatically. - // ---------------------------------------------------------------- - Column( - modifier = Modifier.padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - listOf(s.backupWarning1, s.backupWarning2, s.backupWarning3).forEach { warning -> - Row( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF1A0A00), RoundedCornerShape(10.dp)) - .border(1.dp, Color(0xFFF59E0B).copy(alpha = 0.25f), RoundedCornerShape(10.dp)) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - warning, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFFF59E0B).copy(alpha = 0.9f), - lineHeight = 18.sp - ) + Column( + modifier = Modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + listOf(s.backupWarning1, s.backupWarning2, s.backupWarning3).forEach { warning -> + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF1A0A00), RoundedCornerShape(10.dp)) + .border(1.dp, Color(0xFFF59E0B).copy(alpha = 0.25f), RoundedCornerShape(10.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + warning, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFF59E0B).copy(alpha = 0.9f), + lineHeight = 18.sp + ) + } } } - } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // ---------------------------------------------------------------- - // Confirmation checkbox: the user must explicitly check this box, - // acknowledging that they have written down the phrase. - // The card border turns green when the checkbox is ticked. - // ---------------------------------------------------------------- - Row( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - .background(RavenCard, RoundedCornerShape(12.dp)) - .border( - 1.dp, - if (confirmed) AuthenticGreen.copy(alpha = 0.5f) else RavenBorder, - RoundedCornerShape(12.dp) + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .background(RavenCard, RoundedCornerShape(12.dp)) + .border( + 1.dp, + if (confirmed) AuthenticGreen.copy(alpha = 0.5f) else RavenBorder, + RoundedCornerShape(12.dp) + ) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = confirmed, + onCheckedChange = { confirmed = it }, + colors = CheckboxDefaults.colors( + checkedColor = AuthenticGreen, + uncheckedColor = RavenMuted + ) ) - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = confirmed, - onCheckedChange = { confirmed = it }, - colors = CheckboxDefaults.colors( - checkedColor = AuthenticGreen, - uncheckedColor = RavenMuted + Text( + s.backupConfirmCheck, + style = MaterialTheme.typography.bodySmall, + color = if (confirmed) Color.White else RavenMuted, + lineHeight = 18.sp ) - ) - Text( - s.backupConfirmCheck, - style = MaterialTheme.typography.bodySmall, - // Text color brightens when the checkbox is ticked for additional feedback. - color = if (confirmed) Color.White else RavenMuted, - lineHeight = 18.sp - ) - } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // ---------------------------------------------------------------- - // Continue button: only enabled after the user ticks the checkbox. - // Remains a dimmed card color while disabled so it is clearly unavailable. - // Calling onConfirmed triggers wallet persistence and dismisses this screen. - // ---------------------------------------------------------------- - Button( - onClick = onConfirmed, - enabled = confirmed, - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - .height(52.dp), - colors = ButtonDefaults.buttonColors( - containerColor = AuthenticGreen, - disabledContainerColor = RavenCard - ), - shape = RoundedCornerShape(14.dp) - ) { - Icon(Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text(s.backupConfirmBtn, fontWeight = FontWeight.Bold) + Button( + onClick = { + // D-14: flip the backup-completed gate so restore-over-wallet is allowed. + context.getSharedPreferences("raventag_wallet", Context.MODE_PRIVATE) + .edit().putBoolean("backup_completed", true).apply() + // Zero-fill before handing control back so the buffer cannot linger. + revealed?.let { java.util.Arrays.fill(it, ' ') } + revealed = null + onConfirmed() + }, + enabled = confirmed, + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .height(52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AuthenticGreen, + disabledContainerColor = RavenCard + ), + shape = RoundedCornerShape(14.dp) + ) { + Icon(Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(s.mnemonicSavedIt, fontWeight = FontWeight.Bold) + } } Spacer(modifier = Modifier.height(32.dp)) } + } +} + +/** + * Runs the biometric reveal flow. In fresh-setup mode (`prefillMnemonic` non-null/blank) + * we skip the Keystore round-trip because the mnemonic is already in memory and no + * ciphertext exists yet. In later-reveal mode we delegate to [MnemonicExporter]. + */ +private suspend fun revealWithBiometric( + context: Context, + wm: WalletManager?, + prefillMnemonic: String?, + strings: AppStrings, + snackbarHostState: SnackbarHostState, + onKeystoreInvalidated: () -> Unit, + onRevealed: (CharArray) -> Unit +) { + if (!prefillMnemonic.isNullOrBlank()) { + // Setup flow: the wallet has been generated but not yet persisted; the biometric + // cover card acts as a tap-through confirmation (D-15 CryptoObject cannot bind + // to a ciphertext that does not yet exist). + onRevealed(prefillMnemonic.toCharArray()) + return + } + if (wm == null) { + snackbarHostState.showSnackbar(strings.mnemonicRevealFailed) + return + } + val activity = context as? FragmentActivity + if (activity == null) { + snackbarHostState.showSnackbar(strings.mnemonicRevealFailed) + return + } + val gate = BiometricGate(activity) + val result = MnemonicExporter.revealMnemonic(gate, wm) + result.onSuccess { chars -> onRevealed(chars) } + result.onFailure { t -> + when (t) { + is BiometricCancelledException -> + snackbarHostState.showSnackbar(strings.authCanceledSnackbar) + is KeystoreInvalidatedException -> + onKeystoreInvalidated() + else -> snackbarHostState.showSnackbar(strings.mnemonicRevealFailed) + } + } } diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/ReceiveScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/ReceiveScreen.kt index 160a423..777fdff 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/ReceiveScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/ReceiveScreen.kt @@ -1,6 +1,11 @@ package io.raventag.app.ui.screens import android.graphics.Bitmap +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -109,7 +114,26 @@ fun ReceiveScreen( } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(24.dp)) + + // D-18: main label + sub-label per UI-SPEC Copywriting Contract. + Text( + text = s.receiveCurrentAddressLabel, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = s.receiveCurrentAddressSubLabel, + style = MaterialTheme.typography.bodySmall, + color = RavenMuted, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) // Address display row with an inline copy button. Text("RVN", style = MaterialTheme.typography.labelSmall, color = RavenMuted, modifier = Modifier.fillMaxWidth()) @@ -123,13 +147,19 @@ fun ReceiveScreen( .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - // Address in monospace for readability and easy manual comparison. - Text( - address, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = Color.White, + // D-18: 200ms cross-fade when currentIndex-derived address advances. + AnimatedContent( + targetState = address, + transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, + label = "receiveAddressCrossFade", modifier = Modifier.weight(1f) - ) + ) { shown -> + Text( + shown, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = Color.White + ) + } // Copy button: turns green when the address has been copied. IconButton( onClick = { diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/RegisterChipScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/RegisterChipScreen.kt index d2ffb2e..8b8e33f 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/RegisterChipScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/RegisterChipScreen.kt @@ -60,6 +60,7 @@ fun RegisterChipScreen( onBack: () -> Unit, onRegister: (assetName: String, tagUid: String, adminKey: String) -> Unit ) { + val s = LocalStrings.current // ---- Local form state ---- var assetName by remember { mutableStateOf("") } var tagUid by remember { mutableStateOf("") } @@ -87,8 +88,8 @@ fun RegisterChipScreen( Icon(Icons.Default.ArrowBack, contentDescription = "Back", tint = Color.White) } Column { - Text("Register NFC Chip", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = Color.White) - Text("Link chip UID to a Ravencoin asset", style = MaterialTheme.typography.bodySmall, color = RavenMuted) + Text(s.regChipTitle, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = Color.White) + Text(s.regChipSubtitle, style = MaterialTheme.typography.bodySmall, color = RavenMuted) } } @@ -106,7 +107,7 @@ fun RegisterChipScreen( Row(modifier = Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { Icon(Icons.Default.Info, contentDescription = null, tint = RavenOrange, modifier = Modifier.size(16.dp).padding(top = 2.dp)) Text( - "The server uses BRAND_SALT to compute nfc_pub_id = SHA-256(uid ∥ salt). The raw UID stays private; only nfc_pub_id is published in IPFS metadata.", + s.regChipInfo, style = MaterialTheme.typography.bodySmall, color = RavenMuted ) @@ -138,7 +139,7 @@ fun RegisterChipScreen( // The operator can cross-check this against their write-step records. if (success && nfcPubId != null) { Spacer(modifier = Modifier.height(10.dp)) - Text("NFC PUBLIC ID", style = MaterialTheme.typography.labelSmall, color = RavenMuted) + Text(s.verifyNfcPubId, style = MaterialTheme.typography.labelSmall, color = RavenMuted) Spacer(modifier = Modifier.height(4.dp)) // Monospace makes the 64-char hex string easier to compare visually. Text(nfcPubId, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), color = AuthenticGreen) @@ -150,7 +151,7 @@ fun RegisterChipScreen( // Asset name field: accepts any Ravencoin asset depth (ROOT, SUB, or unique token). // Input is uppercased on every keystroke to match Ravencoin naming rules. - RavenFormField(label = "Asset Name", hint = "e.g. FASHIONX/BAG001#SN0001") { + RavenFormField(label = s.fieldAssetName, hint = "e.g. FASHIONX/BAG001#SN0001") { OutlinedTextField( value = assetName, onValueChange = { assetName = it.uppercase() }, @@ -168,10 +169,10 @@ fun RegisterChipScreen( // then capped at 14 characters to match the 7-byte NTAG 424 DNA UID length. // A live validation hint shows the current character count when the input is invalid. RavenFormField( - label = "Tag UID", + label = s.regChipTagUid, hint = if (tagUid.isNotEmpty() && !isValidUid) - "Must be exactly 14 hex characters (7 bytes). Current: ${tagUid.length}" - else "14 hex characters = 7 bytes, e.g. 04A1B2C3D4E5F6" + s.regChipUidError + " ${tagUid.length}" + else s.regChipUidHint ) { OutlinedTextField( value = tagUid, @@ -190,7 +191,7 @@ fun RegisterChipScreen( // Admin API key field: rendered as a password field so the key is not visible on screen. // The hint makes clear the key is not saved anywhere in the app. - RavenFormField(label = "Admin API Key", hint = "X-Admin-Key, never stored in app") { + RavenFormField(label = s.adminKey, hint = s.adminKeyHint) { OutlinedTextField( value = adminKey, onValueChange = { adminKey = it }, @@ -220,7 +221,7 @@ fun RegisterChipScreen( } else { Icon(Icons.Default.Memory, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) - Text("Register Chip", fontWeight = FontWeight.SemiBold) + Text(s.regChipBtn, fontWeight = FontWeight.SemiBold) } } diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt index 070a58e..d6c366f 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/SendRvnScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.raventag.app.ui.theme.* +import io.raventag.app.wallet.fee.FeeEstimator /** * Screen for sending RVN (Ravencoin) to a recipient address. @@ -50,6 +51,8 @@ fun SendRvnScreen( resultMessage: String?, resultSuccess: Boolean?, feeUnavailable: Boolean = false, + estimatedFee: Double = 0.0, + feeEstimator: FeeEstimator? = null, prefillAddress: String = "", donateMode: Boolean = false, walletBalance: Double = 0.0, @@ -71,6 +74,12 @@ fun SendRvnScreen( // Controls whether the QR scanner overlay replaces this screen temporarily. var showScanner by remember { mutableStateOf(false) } + // Fee estimation state: fetched lazily when the confirm dialog opens. + var feeSatPerKb by remember { mutableStateOf(null) } + var feeUsedFallback by remember { mutableStateOf(false) } + var feeOverrideText by remember { mutableStateOf("") } + var feeEditOpen by remember { mutableStateOf(false) } + // Normalize the decimal separator (comma -> dot) to handle locales that use a comma. val parsedAmount = amount.replace(',', '.').toDoubleOrNull() ?: 0.0 @@ -104,36 +113,81 @@ fun SendRvnScreen( // ---------------------------------------------------------------- // Pre-send confirmation dialog: shown after the user taps "Send". - // Summarizes the amount and recipient; warns that the action is irreversible. + // Summarizes the amount and recipient; shows dynamic fee with override. // ---------------------------------------------------------------- if (showConfirm) { + // Fetch fee estimate lazily when the dialog opens + LaunchedEffect(showConfirm) { + if (showConfirm && feeEstimator != null && feeSatPerKb == null) { + val result = feeEstimator.estimateSatPerKbWithSource(6) + feeSatPerKb = result.satPerKb + feeUsedFallback = result.usedFallback + } + } + + val effectiveFeeSatPerKb = feeOverrideText.toDoubleOrNull() + ?.let { (it * 100_000_000.0).toLong() } + ?: feeSatPerKb + ?: FeeEstimator.FALLBACK_SAT_PER_KB + AlertDialog( - onDismissRequest = { showConfirm = false }, + onDismissRequest = { + showConfirm = false + feeSatPerKb = null + feeUsedFallback = false + feeOverrideText = "" + feeEditOpen = false + }, containerColor = Color(0xFF101020), title = { Text(s.walletSendDialogTitle, color = Color.White, fontWeight = FontWeight.Bold) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - // Replace %1 with the formatted amount and %2 with the address. - Text( - s.walletSendDialogMsg - .replace("%1", "%.8f".format(parsedAmount)) - .replace("%2", toAddress), - color = RavenMuted, - style = MaterialTheme.typography.bodyMedium + // Amount row + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Amount:", color = RavenMuted, style = MaterialTheme.typography.bodyMedium) + Text("%.8f RVN".format(parsedAmount), color = Color.White, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } + // Recipient address row + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("To:", color = RavenMuted, style = MaterialTheme.typography.bodyMedium) + Text(toAddress.take(16) + if (toAddress.length > 16) "..." else "", color = Color.White, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } + // Dynamic fee section (D-22) + FeeSection( + feeSatPerKb = feeSatPerKb, + usedFallback = feeUsedFallback, + overrideText = feeOverrideText, + onOverrideChange = { feeOverrideText = it }, + onEditToggle = { feeEditOpen = !feeEditOpen }, + editOpen = feeEditOpen ) - // Irreversibility warning in red. + Spacer(modifier = Modifier.height(8.dp)) + // Irreversibility warning in red Text(s.walletSendWarning, color = NotAuthenticRed.copy(alpha = 0.8f), style = MaterialTheme.typography.bodySmall) } }, confirmButton = { Button( - onClick = { showConfirm = false; onSend(toAddress, parsedAmount) }, + onClick = { + showConfirm = false + onSend(toAddress, parsedAmount) + feeSatPerKb = null + feeUsedFallback = false + feeOverrideText = "" + feeEditOpen = false + }, colors = ButtonDefaults.buttonColors(containerColor = RavenOrange) ) { Text(s.walletSendConfirm, fontWeight = FontWeight.Bold) } }, dismissButton = { OutlinedButton( - onClick = { showConfirm = false }, + onClick = { + showConfirm = false + feeSatPerKb = null + feeUsedFallback = false + feeOverrideText = "" + feeEditOpen = false + }, border = androidx.compose.foundation.BorderStroke(1.dp, RavenBorder), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) ) { Text(s.walletCancelBtn) } @@ -240,10 +294,17 @@ fun SendRvnScreen( // "RVN" suffix displayed inside the field to clarify the currency. suffix = { Text("RVN", color = RavenOrange, style = MaterialTheme.typography.bodySmall) } ) - // MAX button: fills in the full wallet balance formatted to 8 decimal places. - // Disabled when balance is zero to avoid setting 0.00000000 accidentally. + // MAX button: fills in walletBalance MINUS estimated network fee so the + // tx actually broadcasts (sending the full balance always fails because + // there are no satoshis left to cover the fee). + // Estimate uses ~300 bytes for a typical 1-in / 2-out P2PKH transaction. OutlinedButton( - onClick = { amount = "%.8f".format(walletBalance) }, + onClick = { + // MAX = full balance. WalletManager.sendRvnLocal detects sweep mode + // (amountSat + fee > totalIn) and lets the tx builder subtract the + // exact fee from the recipient amount, so the wallet ends at 0 RVN. + amount = "%.8f".format(walletBalance) + }, enabled = walletBalance > 0.0, modifier = Modifier.height(56.dp), shape = RoundedCornerShape(12.dp), @@ -340,3 +401,56 @@ private fun sndFieldColors() = OutlinedTextFieldDefaults.colors( focusedTextColor = Color.White, unfocusedTextColor = Color.White, cursorColor = RavenOrange, focusedContainerColor = RavenCard, unfocusedContainerColor = RavenCard ) + +/** + * D-22 fee section composable for the send/transfer confirmation dialog. + * + * Displays the estimated fee with a middle-dot separator, an edit icon to + * override the fee, and a fallback warning when the estimate was unavailable. + */ +@Composable +private fun FeeSection( + feeSatPerKb: Long?, + usedFallback: Boolean, + overrideText: String, + onOverrideChange: (String) -> Unit, + onEditToggle: () -> Unit, + editOpen: Boolean +) { + val s = LocalStrings.current + Column { + // Fallback warning line (amber/orange bodySmall) + if (usedFallback) { + Text( + text = s.sendFeeEstimateUnavailable, + style = MaterialTheme.typography.bodySmall, + color = RavenOrange, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + // Fee row: label + value + edit icon + Row(verticalAlignment = Alignment.CenterVertically) { + val feeRvn = (feeSatPerKb ?: FeeEstimator.FALLBACK_SAT_PER_KB) / 1e8 + Text( + text = "${s.sendFeeLabel}: %.8f RVN · ${s.sendFeeTarget}".format(feeRvn), + style = MaterialTheme.typography.bodySmall, + color = RavenMuted, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onEditToggle, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Edit, contentDescription = s.sendFeeEditLabel, tint = RavenOrange) + } + } + // Inline override field (expanded on edit icon tap) + if (editOpen) { + OutlinedTextField( + value = overrideText, + onValueChange = onOverrideChange, + label = { Text(s.sendFeeOverrideHint) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/SettingsScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/SettingsScreen.kt index 200aa88..2b8c6d1 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/SettingsScreen.kt @@ -86,6 +86,9 @@ fun SettingsScreen( onAllowScreenshotsChange: (Boolean) -> Unit = {}, notificationsEnabled: Boolean = true, onNotificationsEnabledChange: (Boolean) -> Unit = {}, + currentAdminKey: String = "", + onAdminKeySave: (String) -> Unit = {}, + adminKeyStatus: MainViewModel.AdminKeyStatus = MainViewModel.AdminKeyStatus.UNKNOWN, modifier: Modifier = Modifier ) { val s = LocalStrings.current @@ -113,6 +116,9 @@ fun SettingsScreen( var kuboNodeUrlInput by remember(currentKuboNodeUrl) { mutableStateOf(currentKuboNodeUrl) } var kuboNodeUrlSaved by remember { mutableStateOf(false) } + var adminKeyInput by remember(currentAdminKey) { mutableStateOf(currentAdminKey) } + var adminKeySaved by remember { mutableStateOf(false) } + Column( modifier = modifier .fillMaxSize() @@ -211,6 +217,35 @@ fun SettingsScreen( } } Spacer(modifier = Modifier.height(24.dp)) + + // Admin API Key: required for brand operations (issue, revoke, program tags). + // Validated against the backend server. Status chip shows verification result. + SectionLabelWithAdminStatus( + label = s.adminKey, + status = adminKeyStatus, + serverOnline = true, + s = s, + validLabel = s.settingsAdminKeyValid, + invalidLabel = s.settingsAdminKeyInvalid, + checkingLabel = s.settingsAdminKeyChecking, + wrongTypeLabel = s.settingsAdminKeyWrongType + ) + Spacer(modifier = Modifier.height(10.dp)) + SettingsCard { + SettingsTextField( + s.adminKey, + s.adminKeyHint, + adminKeyInput, + { adminKeyInput = it; adminKeySaved = false }, + placeholder = "", + password = true + ) + SettingsSaveButton(adminKeySaved, s) { + onAdminKeySave(adminKeyInput.trim()) + adminKeySaved = true + } + } + Spacer(modifier = Modifier.height(24.dp)) } // Language picker: renders languages in a 3-column grid of tappable chips. diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/SplashScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/SplashScreen.kt index cec9d61..d5f2568 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/SplashScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/SplashScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.material3.Icon import io.raventag.app.R +import io.raventag.app.ui.theme.LocalStrings import io.raventag.app.ui.theme.RavenMuted import io.raventag.app.ui.theme.RavenOrange import kotlinx.coroutines.delay @@ -22,6 +23,7 @@ import kotlinx.coroutines.delay @Composable fun SplashScreen(onFinished: () -> Unit) { var phase by remember { mutableStateOf(0) } // 0=invisible, 1=visible, 2=fading out + val s = LocalStrings.current LaunchedEffect(Unit) { delay(100) @@ -63,7 +65,7 @@ fun SplashScreen(onFinished: () -> Unit) { ) Spacer(modifier = Modifier.height(20.dp)) Text( - text = "Protocol RTP-1", + text = s.protocolRtpBadge, style = MaterialTheme.typography.labelSmall, color = RavenOrange.copy(alpha = 0.7f), letterSpacing = 3.sp diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/TransactionDetailsScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/TransactionDetailsScreen.kt new file mode 100644 index 0000000..56dfe1f --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/ui/screens/TransactionDetailsScreen.kt @@ -0,0 +1,394 @@ +package io.raventag.app.ui.screens + +import android.util.Log +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.CallMade +import androidx.compose.material.icons.filled.CallReceived +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Payments +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.raventag.app.config.AppConfig +import io.raventag.app.ui.theme.* +import io.raventag.app.wallet.cache.TxHistoryDao + +/** + * Transaction details screen overlay showing txid, amount, confirmations, and status. + * + * Implements D-04 from CONTEXT.md: Tapping send notification opens to transaction details screen. + * + * @param txid Transaction ID to display details for + * @param onClose Callback when user taps close button + */ +@Composable +fun TransactionDetailsScreen( + txid: String, + onClose: () -> Unit +) { + val strings = LocalStrings.current + val context = LocalContext.current + var transaction by remember { mutableStateOf(null) } + var txRow by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(txid) { + isLoading = true + errorMessage = null + try { + // D-19 primary source: local TxHistoryDao with three-value breakdown. + val row = try { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + TxHistoryDao.findByTxid(txid) + } + } catch (_: Exception) { null } + txRow = row + transaction = Transaction( + txid = txid, + amount = (row?.amountSat ?: 0L) / 1e8, + fee = (row?.feeSat ?: 0L) / 1e8, + confirmations = row?.confirms ?: 0, + blockHeight = (row?.height ?: 0).toLong(), + from = "", + to = "", + timestamp = row?.timestamp ?: 0L + ) + } catch (e: Exception) { + Log.e("TransactionDetailsScreen", "Failed to fetch transaction", e) + errorMessage = e.message ?: "Unknown error" + } finally { + isLoading = false + } + } + + // Full-screen overlay with semi-transparent background + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.8f)) + ) { + // Card with transaction details + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .align(Alignment.Center), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = RavenCard) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Close button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = RavenMuted + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Loading state + if (isLoading) { + CircularProgressIndicator( + color = RavenOrange, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Loading transaction details...", + color = RavenMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + // Error state + else if (errorMessage != null) { + Text( + text = "Failed to load transaction", + color = NotAuthenticRed, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage ?: "", + color = RavenMuted, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + // Transaction details + else if (transaction != null) { + Text( + text = "Transaction Details", + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Status badge + val statusColor = when { + transaction!!.confirmations > 0 -> Color(0xFF4ADE80) // Green + else -> RavenOrange + } + val statusText = when { + transaction!!.confirmations > 0 -> "Confirmed" + else -> "Pending" + } + + Surface( + color = statusColor.copy(alpha = 0.2f), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = statusText, + color = statusColor, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Transaction details in scrollable column + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Transaction ID + DetailRow(label = "Transaction ID", value = transaction!!.txid) + + // Confirmations + DetailRow( + label = "Confirmations", + value = "${transaction!!.confirmations}", + valueColor = if (transaction!!.confirmations > 0) RavenOrange else RavenMuted + ) + + // Block height (if confirmed) + if (transaction!!.blockHeight > 0) { + DetailRow(label = "Block Height", value = "${transaction!!.blockHeight}") + } + + // D-19 three-value breakdown for outgoing transactions. + val row = txRow + if (row != null && !row.isIncoming) { + val sentStr = formatRvn(row.sentSat) + val cycledStr = formatRvn(row.cycledSat) + val feeStr = formatRvn(row.feeSat) + if (!row.isSelf) { + ThreeValueRow( + icon = Icons.Default.CallMade, + tint = NotAuthenticRed, + label = strings.txHistorySentPrefix, + amount = "-$sentStr RVN", + amountColor = NotAuthenticRed, + bold = true + ) + } + ThreeValueRow( + icon = Icons.Default.Autorenew, + tint = AuthenticGreen, + label = strings.txHistoryCycledPrefix, + amount = "$cycledStr RVN", + amountColor = AuthenticGreen, + bold = false + ) + ThreeValueRow( + icon = Icons.Default.Payments, + tint = RavenMuted, + label = strings.txHistoryFeePrefix, + amount = "$feeStr RVN", + amountColor = RavenMuted, + bold = false + ) + } else { + // Incoming or legacy fallback: keep prior single-amount layout. + if (transaction!!.amount > 0) { + DetailRow( + label = "Amount", + value = "${transaction!!.amount} RVN", + valueColor = RavenOrange, + valueBold = true + ) + } + if (transaction!!.fee > 0) { + DetailRow( + label = strings.txHistoryFeePrefix, + value = "${transaction!!.fee} RVN" + ) + } + } + + // From address (truncated) + if (transaction!!.from.isNotEmpty()) { + DetailRow( + label = "From", + value = transaction!!.from.take(20) + "..." + ) + } + + // To address (truncated) + if (transaction!!.to.isNotEmpty()) { + DetailRow( + label = "To", + value = transaction!!.to.take(20) + "..." + ) + } + + // Timestamp (if available) + if (transaction!!.timestamp > 0) { + val date = java.util.Date(transaction!!.timestamp * 1000) + DetailRow( + label = "Timestamp", + value = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(date) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // D-19 View on explorer: opens Intent.ACTION_VIEW at AppConfig.EXPLORER_URL + txid. + OutlinedButton( + onClick = { + val uri = android.net.Uri.parse(AppConfig.EXPLORER_URL + txid) + try { + context.startActivity( + android.content.Intent(android.content.Intent.ACTION_VIEW, uri) + ) + } catch (_: android.content.ActivityNotFoundException) { + // No browser available; silent (ASVS V7 error handling). + } + }, + border = BorderStroke(1.dp, RavenOrange), + colors = ButtonDefaults.outlinedButtonColors(contentColor = RavenOrange), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = null, + tint = RavenOrange, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(strings.txDetailsViewOnExplorer, fontWeight = FontWeight.SemiBold) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +/** D-19 three-value breakdown row (icon + label + amount) for outgoing tx details. */ +@Composable +private fun ThreeValueRow( + icon: ImageVector, + tint: Color, + label: String, + amount: String, + amountColor: Color, + bold: Boolean +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(16.dp)) + Text( + text = label, + color = RavenMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + Text( + text = amount, + color = amountColor, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.End + ) + } +} + +private fun formatRvn(sat: Long): String { + if (sat <= 0L) return "0" + return String.format(java.util.Locale.US, "%.8f", sat / 1e8).trimEnd('0').trimEnd('.') +} + +@Composable +private fun DetailRow( + label: String, + value: String, + valueColor: Color = Color.White, + valueBold: Boolean = false +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = RavenMuted, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Text( + text = value, + color = valueColor, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (valueBold) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.End, + modifier = Modifier.weight(2f) + ) + } +} + +/** + * Data class representing a blockchain transaction. + */ +data class Transaction( + val txid: String, + val amount: Double, + val fee: Double, + val confirmations: Int, + val blockHeight: Long, + val from: String, + val to: String, + val timestamp: Long +) diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/TransferScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/TransferScreen.kt index 6c3bcf6..739e0bb 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/TransferScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/TransferScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import io.raventag.app.ui.theme.* +import io.raventag.app.wallet.fee.FeeEstimator /** * Screen for transferring a Ravencoin asset (unique token, sub-asset, or root asset) to a @@ -60,6 +61,7 @@ fun TransferScreen( mode: IssueMode = IssueMode.TRANSFER, prefilledAssetName: String? = null, showLowRvnWarning: Boolean = false, + feeEstimator: FeeEstimator? = null, onBack: () -> Unit, onTransfer: (assetName: String, toAddress: String, qty: Long) -> Unit ) { @@ -73,6 +75,15 @@ fun TransferScreen( // Controls whether the QR scanner overlay replaces this screen temporarily. var showScanner by remember { mutableStateOf(false) } + // Controls whether the pre-transfer confirmation dialog is visible. + var showConfirm by remember { mutableStateOf(false) } + + // Fee estimation state: fetched lazily when the confirm dialog opens. + var feeSatPerKb by remember { mutableStateOf(null) } + var feeUsedFallback by remember { mutableStateOf(false) } + var feeOverrideText by remember { mutableStateOf("") } + var feeEditOpen by remember { mutableStateOf(false) } + // QR scanner overlay: takes over the full screen while active. if (showScanner) { QrScannerScreen( @@ -114,6 +125,85 @@ fun TransferScreen( else -> "FASHIONX/BAG001#SN0001" } + // ---------------------------------------------------------------- + // Pre-transfer confirmation dialog: shown after the user taps the transfer button. + // Summarizes asset, recipient, quantity, and shows dynamic fee with override. + // ---------------------------------------------------------------- + if (showConfirm) { + LaunchedEffect(showConfirm) { + if (showConfirm && feeEstimator != null && feeSatPerKb == null) { + val result = feeEstimator.estimateSatPerKbWithSource(6) + feeSatPerKb = result.satPerKb + feeUsedFallback = result.usedFallback + } + } + + AlertDialog( + onDismissRequest = { + showConfirm = false + feeSatPerKb = null + feeUsedFallback = false + feeOverrideText = "" + feeEditOpen = false + }, + containerColor = Color(0xFF101020), + title = { Text(title, color = Color.White, fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Asset row + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${s.fieldAssetName}:", color = RavenMuted, style = MaterialTheme.typography.bodyMedium) + Text(assetName, color = Color.White, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } + // Recipient row + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("To:", color = RavenMuted, style = MaterialTheme.typography.bodyMedium) + Text(toAddress.take(16) + if (toAddress.length > 16) "..." else "", color = Color.White, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } + // Dynamic fee section (D-22) + TransferFeeSection( + feeSatPerKb = feeSatPerKb, + usedFallback = feeUsedFallback, + overrideText = feeOverrideText, + onOverrideChange = { feeOverrideText = it }, + onEditToggle = { feeEditOpen = !feeEditOpen }, + editOpen = feeEditOpen + ) + Spacer(modifier = Modifier.height(8.dp)) + if (isOwnershipTransfer) { + Text(s.transferOwnershipWarning, color = RavenOrange.copy(alpha = 0.8f), style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { + Button( + onClick = { + showConfirm = false + onTransfer(assetName, toAddress, qty.toLongOrNull() ?: 1L) + feeSatPerKb = null + feeUsedFallback = false + feeOverrideText = "" + feeEditOpen = false + }, + colors = ButtonDefaults.buttonColors(containerColor = RavenOrange) + ) { Text(title, fontWeight = FontWeight.Bold) } + }, + dismissButton = { + OutlinedButton( + onClick = { + showConfirm = false + feeSatPerKb = null + feeUsedFallback = false + feeOverrideText = "" + feeEditOpen = false + }, + border = BorderStroke(1.dp, RavenBorder), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + ) { Text(s.walletCancelBtn) } + } + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -256,9 +346,9 @@ fun TransferScreen( Spacer(modifier = Modifier.height(24.dp)) // Submit button: disabled while loading or when form validation fails. - // Falls back to qty = 1 if the quantity field cannot be parsed (e.g. empty string). + // Opens a confirmation dialog instead of calling onTransfer directly. Button( - onClick = { onTransfer(assetName, toAddress, qty.toLongOrNull() ?: 1L) }, + onClick = { showConfirm = true }, enabled = isValid && !isLoading, modifier = Modifier.fillMaxWidth().height(52.dp), colors = ButtonDefaults.buttonColors(containerColor = RavenOrange, disabledContainerColor = RavenOrange.copy(alpha = 0.3f)), @@ -303,3 +393,53 @@ private fun transferFieldColors() = OutlinedTextFieldDefaults.colors( focusedContainerColor = RavenCard, unfocusedContainerColor = RavenCard ) + +/** + * D-22 fee section composable for the transfer confirmation dialog. + * + * Same layout as SendRvnScreen.FeeSection: fallback warning, fee row with + * edit icon, and inline override field. + */ +@Composable +private fun TransferFeeSection( + feeSatPerKb: Long?, + usedFallback: Boolean, + overrideText: String, + onOverrideChange: (String) -> Unit, + onEditToggle: () -> Unit, + editOpen: Boolean +) { + val s = LocalStrings.current + Column { + if (usedFallback) { + Text( + text = s.sendFeeEstimateUnavailable, + style = MaterialTheme.typography.bodySmall, + color = RavenOrange, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + val feeRvn = (feeSatPerKb ?: FeeEstimator.FALLBACK_SAT_PER_KB) / 1e8 + Text( + text = "${s.sendFeeLabel}: %.8f RVN · ${s.sendFeeTarget}".format(feeRvn), + style = MaterialTheme.typography.bodySmall, + color = RavenMuted, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onEditToggle, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Edit, contentDescription = s.sendFeeEditLabel, tint = RavenOrange) + } + } + if (editOpen) { + OutlinedTextField( + value = overrideText, + onValueChange = onOverrideChange, + label = { Text(s.sendFeeOverrideHint) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/VerifyScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/VerifyScreen.kt index dc177dc..75c5d40 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/VerifyScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/VerifyScreen.kt @@ -20,12 +20,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import coil.compose.SubcomposeAsyncImage import io.raventag.app.BuildConfig import io.raventag.app.ipfs.IpfsResolver -import io.raventag.app.network.NetworkModule import io.raventag.app.ravencoin.RaventagMetadata import io.raventag.app.ui.theme.* @@ -214,10 +210,8 @@ private fun AssetInfoCard(result: VerifyResult, s: AppStrings) { val meta = result.metadata ?: return val hierarchy = listOfNotNull(meta.parentAsset, meta.subAsset, meta.variantAsset).joinToString(" / ") - val imageUrl = meta.image?.let { IpfsResolver.primaryUrl(it) } + val imageCandidates = meta.image?.let { IpfsResolver.candidateUrls(it) } ?: emptyList() val description = meta.description ?: meta.brandInfo?.description - val context = LocalContext.current - val imageLoader = remember(context) { NetworkModule.getImageLoader(context) } Card( colors = CardDefaults.cardColors(containerColor = RavenCard), @@ -231,16 +225,16 @@ private fun AssetInfoCard(result: VerifyResult, s: AppStrings) { Text(s.verifyAssetInfo, fontWeight = FontWeight.SemiBold, color = Color.White) } - if (imageUrl != null) { - SubcomposeAsyncImage( - model = imageUrl, - imageLoader = imageLoader, + if (imageCandidates.isNotEmpty()) { + IpfsPreviewImage( + urls = imageCandidates, contentDescription = "Token image", - contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() .heightIn(max = 220.dp) .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop, + fallback = { /* silent: no image shown on load error */ }, loading = { Box( modifier = Modifier @@ -251,8 +245,7 @@ private fun AssetInfoCard(result: VerifyResult, s: AppStrings) { ) { CircularProgressIndicator(color = RavenOrange, modifier = Modifier.size(28.dp), strokeWidth = 2.dp) } - }, - error = { /* silent: no image shown on load error */ } + } ) } diff --git a/android/app/src/main/java/io/raventag/app/ui/screens/WalletScreen.kt b/android/app/src/main/java/io/raventag/app/ui/screens/WalletScreen.kt index 8053010..f4be445 100644 --- a/android/app/src/main/java/io/raventag/app/ui/screens/WalletScreen.kt +++ b/android/app/src/main/java/io/raventag/app/ui/screens/WalletScreen.kt @@ -9,9 +9,9 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -25,6 +25,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -54,14 +56,26 @@ import io.raventag.app.ravencoin.AssetType import io.raventag.app.ravencoin.OwnedAsset import io.raventag.app.ui.theme.* import io.raventag.app.wallet.TxHistoryEntry +import io.raventag.app.wallet.cache.TxHistoryDao import okhttp3.Request import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import io.raventag.app.network.NetworkModule +import io.raventag.app.wallet.cache.WalletCacheDao +import io.raventag.app.wallet.health.ConnectionHealth +import io.raventag.app.wallet.health.NodeHealthMonitor +import io.raventag.app.wallet.subscription.ScripthashEvent +import io.raventag.app.wallet.subscription.SubscriptionManager +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.togetherWith +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.collect data class WalletInfo( val address: String, - val balanceRvn: Double, + val balanceRvn: Double?, // null = not yet loaded / fetch failed; non-null = confirmed balance val mnemonic: String? = null, val isLoading: Boolean = false, val error: String? = null @@ -80,9 +94,13 @@ fun WalletScreen( walletInfo: WalletInfo?, hasWallet: Boolean, isGenerating: Boolean = false, + restoreError: String? = null, ownedAssets: List?, assetsLoading: Boolean, assetsLoadError: Boolean = false, + needsConsolidation: Boolean = false, + consolidationInProgress: Boolean = false, + onConsolidateFunds: (() -> Unit)? = null, electrumStatus: MainViewModel.ElectrumStatus = MainViewModel.ElectrumStatus.UNKNOWN, blockHeight: Int? = null, rvnPrice: Double? = null, @@ -94,7 +112,7 @@ fun WalletScreen( onReceive: () -> Unit, onSend: () -> Unit, onTransferAsset: ((asset: OwnedAsset) -> Unit)? = null, - walletBalance: Double = 0.0, + walletBalance: Double? = null, txHistory: List = emptyList(), txHistoryLoading: Boolean = false, txHistoryTotal: Int = 0, @@ -105,21 +123,109 @@ fun WalletScreen( controlKeyValidating: Boolean = false, controlKeyError: String? = null, onRestoreModeChange: (Boolean) -> Unit = {}, + onNavigateToMnemonicBackup: () -> Unit = {}, modifier: Modifier = Modifier ) { val s = LocalStrings.current + val context = LocalContext.current var pendingTransferAsset by remember { mutableStateOf(null) } var showMnemonic by remember { mutableStateOf(false) } + // D-23 extra paged rows appended locally via TxHistoryDao.getPage / getHistoryPaged + // in addition to txHistory provided by MainViewModel. + var extraTxHistory by remember { mutableStateOf>(emptyList()) } + // Reset local pagination when the active wallet address changes. + LaunchedEffect(walletInfo?.address) { extraTxHistory = emptyList() } var showRestore by remember { mutableStateOf(false) } var restoreWords by remember { mutableStateOf(List(12) { "" }) } var controlKey by remember { mutableStateOf("") } var showDeleteDialog by remember { mutableStateOf(false) } + var showRestoreConfirmDialog by remember { mutableStateOf(false) } + var pendingRestoreArgs by remember { mutableStateOf?>(null) } var assetFilter by remember { mutableStateOf(null) } // null = All var previewAsset by remember { mutableStateOf(null) } var showOwnerTokens by remember { mutableStateOf(false) } val clipboard = LocalClipboardManager.current val isOperator = walletRole == "operator" + // D-12: NodeHealthMonitor.stateFlow drives the pill and Send/Receive enabled state. + val health by NodeHealthMonitor.stateFlow.collectAsState(initial = ConnectionHealth.GREEN) + var showConnectionSheet by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + // D-04: cached banner state; flipped false once a successful refresh has been observed. + var cachedBannerVisible by remember { mutableStateOf(true) } + var cachedLastRefreshedAt by remember { mutableStateOf(0L) } + var isRefreshing by remember { mutableStateOf(false) } + + // Keep last non-null walletInfo so periodic refresh (which may briefly emit null) + // does not wipe the already-rendered balance/assets/tx sections. + var cachedWalletInfo by remember { mutableStateOf(walletInfo) } + LaunchedEffect(walletInfo) { if (walletInfo != null) cachedWalletInfo = walletInfo } + @Suppress("NAME_SHADOWING") + val walletInfo = walletInfo ?: cachedWalletInfo + + LaunchedEffect(Unit) { + cachedLastRefreshedAt = WalletCacheDao.getLastRefreshedAt() + } + + // D-28: battery-saver chip visibility. + val isPowerSave = remember { + val pm = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager + pm?.isPowerSaveMode == true + } + + // D-02, D-26: 30-second periodic refresh while foreground and not power-save. + // Background refresh must be INVISIBLE: do NOT toggle isRefreshing — that flag + // is reserved for the manual Refresh icon so the linear progress bar / asset and + // tx-history header spinners only appear when the user explicitly asked for it. + LaunchedEffect(Unit) { + while (true) { + kotlinx.coroutines.delay(30_000L) + val pm = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager + if (pm?.isPowerSaveMode != true) { + onRefreshBalance() + cachedLastRefreshedAt = WalletCacheDao.getLastRefreshedAt() + cachedBannerVisible = false + } + } + } + + // D-05, D-07: SubscriptionManager scripthash events -> re-fetch + incoming snackbar on positive delta. + val subscriptionManager = remember { SubscriptionManager(context) } + val strings = s + LaunchedEffect(walletInfo?.address) { + val addr = walletInfo?.address + if (!addr.isNullOrBlank()) { + try { subscriptionManager.start(listOf(addr)) } catch (_: Exception) {} + } + subscriptionManager.eventsFlow().collect { ev -> + when (ev) { + is ScripthashEvent.StatusChanged -> { + val beforeSat = WalletCacheDao.readState()?.balanceSat ?: 0L + onRefreshBalance() + val afterSat = WalletCacheDao.readState()?.balanceSat ?: 0L + val deltaSat = afterSat - beforeSat + if (deltaSat > 0L) { + val rvn = String.format(java.util.Locale.ROOT, "%.8f", deltaSat / 1e8) + scope.launch { + snackbarHostState.showSnackbar( + String.format(strings.incomingTxSnackbar, rvn) + ) + } + } + cachedLastRefreshedAt = WalletCacheDao.getLastRefreshedAt() + cachedBannerVisible = false + } + else -> {} + } + } + } + + if (showConnectionSheet) { + ConnectionPillSheet(onDismiss = { showConnectionSheet = false }) + } + previewAsset?.let { asset -> AssetPreviewDialog(asset = asset, onDismiss = { previewAsset = null }) } @@ -146,6 +252,32 @@ fun WalletScreen( ) } + if (showRestoreConfirmDialog) { + val prefs = context.getSharedPreferences("raventag_wallet", Context.MODE_PRIVATE) + val hasBackedUp = prefs.getBoolean("backup_completed", false) + val assetsCount = ownedAssets?.size ?: 0 + RestoreWalletConfirmDialog( + hasBackedUp = hasBackedUp, + rvnAmount = walletBalance ?: 0.0, + assetsCount = assetsCount, + onDismiss = { + showRestoreConfirmDialog = false + pendingRestoreArgs = null + }, + onBackupFirst = { + showRestoreConfirmDialog = false + pendingRestoreArgs = null + onNavigateToMnemonicBackup() + }, + onReplace = { + val args = pendingRestoreArgs + showRestoreConfirmDialog = false + pendingRestoreArgs = null + if (args != null) onRestoreWallet(args.first, args.second) + } + ) + } + if (pendingTransferAsset != null && pendingTransferAsset!!.type != AssetType.UNIQUE) { AlertDialog( onDismissRequest = { pendingTransferAsset = null }, @@ -168,203 +300,523 @@ fun WalletScreen( ) } - Column( - modifier = modifier - .fillMaxSize() - .background(RavenBg) - .padding(horizontal = 20.dp) - .verticalScroll(rememberScrollState()), + val filteredAssets = remember(ownedAssets, assetFilter, showOwnerTokens) { + ownedAssets.orEmpty().filter { asset -> + val typeMatch = assetFilter == null || asset.type == assetFilter + val ownerTokenMatch = showOwnerTokens || !asset.name.endsWith("!") + typeMatch && ownerTokenMatch + } + } + + // Full-screen loading ONLY on first-ever load. Periodic refresh uses the + // inline LinearProgressIndicator (isRefreshing) below so the UI doesn't + // flash to a black spinner on every tick. + var everLoaded by remember { mutableStateOf(false) } + LaunchedEffect(walletInfo?.isLoading, walletInfo?.balanceRvn, ownedAssets) { + if (walletInfo != null && walletInfo.isLoading == false) everLoaded = true + if ((walletInfo?.balanceRvn ?: 0.0) > 0.0 || !ownedAssets.isNullOrEmpty()) everLoaded = true + } + if (hasWallet && !everLoaded && walletInfo?.isLoading == true && (walletInfo.balanceRvn == null || walletInfo.balanceRvn == 0.0) && ownedAssets.isNullOrEmpty()) { + Box( + modifier = modifier.fillMaxSize().background(RavenBg), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = RavenOrange, + strokeWidth = 3.dp + ) + Text( + text = s.walletLoading, + color = RavenMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + } + return + } + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize().background(RavenBg), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(24.dp)) + item(key = "top_spacer") { Spacer(modifier = Modifier.height(24.dp)) } - // Header - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Column { - Text(if (isBrandApp) s.walletTitle else s.navWallet, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = Color.White) - if (isBrandApp) Text(s.walletSubtitle, style = MaterialTheme.typography.bodySmall, color = RavenMuted) - if (hasWallet) { - Row(modifier = Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Security, contentDescription = null, tint = AuthenticGreen, modifier = Modifier.size(12.dp)) - Text("Android Keystore \u00b7 AES-256-GCM", style = MaterialTheme.typography.labelSmall, color = AuthenticGreen.copy(alpha = 0.8f)) - } - if (isBrandApp && walletRole.isNotEmpty()) { - val roleColor = if (isOperator) Color(0xFF60A5FA) else RavenOrange - val roleLabel = if (isOperator) s.walletRoleOperator else s.walletRoleAdmin - Row(modifier = Modifier.padding(top = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(if (isOperator) Icons.Default.ManageAccounts else Icons.Default.AdminPanelSettings, contentDescription = null, tint = roleColor, modifier = Modifier.size(11.dp)) - Text(roleLabel, style = MaterialTheme.typography.labelSmall, color = roleColor) + // Error banner for wallet restore errors (per UI-SPEC.md transient error pattern). + // Shown near the top so the user sees it immediately on return to the wallet tab. + // The existing WalletSetupCard also displays the error when no wallet exists yet; + // this banner covers the case where the wallet is present but a restore/refresh failed. + if (hasWallet && restoreError != null) { + item(key = "restore_error_banner") { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + colors = CardDefaults.cardColors(containerColor = NotAuthenticRedBg), + border = BorderStroke(1.dp, NotAuthenticRed.copy(alpha = 0.4f)), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = NotAuthenticRed, + modifier = Modifier.size(20.dp) + ) + Text( + text = restoreError, + color = NotAuthenticRed, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + Button( + onClick = onRefreshBalance, + colors = ButtonDefaults.buttonColors(containerColor = RavenOrange), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + modifier = Modifier.height(32.dp) + ) { + Text( + text = s.retry, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White + ) } } - // ElectrumX status badge - ElectrumStatusBadge(electrumStatus, s) + } + } + } - // Block height counter (Always occupy space to avoid layout shift) - val showBlockHeight = blockHeight != null && electrumStatus == MainViewModel.ElectrumStatus.ONLINE - Box(modifier = Modifier.alpha(if (showBlockHeight) 1f else 0f)) { - BlockHeightBadge(blockHeight ?: 0) - } + // Header + item(key = "header") { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Column { + Text(if (isBrandApp) s.walletTitle else s.navWallet, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = Color.White) + if (isBrandApp) Text(s.walletSubtitle, style = MaterialTheme.typography.bodySmall, color = RavenMuted) + if (hasWallet) { + Row(modifier = Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Security, contentDescription = null, tint = AuthenticGreen, modifier = Modifier.size(12.dp)) + Text(s.walletKeystoreLabel, style = MaterialTheme.typography.labelSmall, color = AuthenticGreen.copy(alpha = 0.8f)) + } + if (isBrandApp && walletRole.isNotEmpty()) { + val roleColor = if (isOperator) Color(0xFF60A5FA) else RavenOrange + val roleLabel = if (isOperator) s.walletRoleOperator else s.walletRoleAdmin + Row(modifier = Modifier.padding(top = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(if (isOperator) Icons.Default.ManageAccounts else Icons.Default.AdminPanelSettings, contentDescription = null, tint = roleColor, modifier = Modifier.size(11.dp)) + Text(roleLabel, style = MaterialTheme.typography.labelSmall, color = roleColor) + } + } + // D-12: NodeHealthMonitor-driven pill (replaces legacy ElectrumStatusBadge) + ConnectionHealthPill(health = health, onTap = { showConnectionSheet = true }) - // Network hashrate (Always occupy space to avoid layout shift) - val showHashrate = networkHashrate != null && electrumStatus == MainViewModel.ElectrumStatus.ONLINE - Box(modifier = Modifier.alpha(if (showHashrate) 1f else 0f)) { - HashrateRow(networkHashrate ?: 0.0) + // D-28: battery-saver informational chip. + if (isPowerSave) { BatterySaverChip() } + + // Block height counter: show last known value even during refresh. + Box(modifier = Modifier.alpha(if (blockHeight != null) 1f else 0f)) { + BlockHeightBadge(blockHeight ?: 0) + } + + // Network hashrate: show last known value even during refresh. + Box(modifier = Modifier.alpha(if (networkHashrate != null) 1f else 0f)) { + HashrateRow(networkHashrate ?: 0.0) + } } } - } - Row { - if (hasWallet) { - IconButton(onClick = onRefreshBalance) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = RavenOrange) - } - IconButton(onClick = { showDeleteDialog = true }) { - Icon(Icons.Default.DeleteForever, contentDescription = "Delete wallet", tint = NotAuthenticRed) + Row { + if (hasWallet) { + IconButton(onClick = { + // Manual refresh: surface the linear progress bar and header + // spinners. Cleared by the next walletInfo.isLoading == false. + isRefreshing = true + onRefreshBalance() + scope.launch { + kotlinx.coroutines.delay(2500) + isRefreshing = false + } + }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = RavenOrange) + } + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.DeleteForever, contentDescription = "Delete wallet", tint = NotAuthenticRed) + } } } } } - Spacer(modifier = Modifier.height(24.dp)) + // D-04: sync-in-background 2dp LinearProgressIndicator under header. + // Always reserve the 2dp slot to avoid layout shift when refresh toggles. + item(key = "sync_indicator") { + Box(modifier = Modifier.fillMaxWidth().height(2.dp)) { + if (isRefreshing) { + LinearProgressIndicator( + color = RavenOrange, + trackColor = RavenBorder, + modifier = Modifier.fillMaxWidth().height(2.dp) + ) + } + } + } + + item(key = "header_spacer") { Spacer(modifier = Modifier.height(24.dp)) } if (!hasWallet) { - WalletSetupCard( - strings = s, - showRestore = showRestore, - restoreWords = restoreWords, - isGenerating = isGenerating, - isBrandApp = isBrandApp, - controlKey = controlKey, - controlKeyValidating = controlKeyValidating, - controlKeyError = controlKeyError, - onControlKeyChange = { controlKey = it }, - onWordChange = { idx, word -> - restoreWords = restoreWords.toMutableList().also { it[idx] = word } - }, - onGenerate = { showRestore = false; restoreWords = List(12) { "" }; onRestoreModeChange(false); onGenerateWallet(controlKey) }, - onToggleRestore = { val next = !showRestore; showRestore = next; restoreWords = List(12) { "" }; onRestoreModeChange(next) }, - onRestore = { onRestoreWallet(restoreWords.joinToString(" "), controlKey) } - ) + item(key = "setup") { + WalletSetupCard( + strings = s, + showRestore = showRestore, + restoreWords = restoreWords, + isGenerating = isGenerating, + isBrandApp = isBrandApp, + controlKey = controlKey, + controlKeyValidating = controlKeyValidating, + controlKeyError = controlKeyError, + restoreError = restoreError, + onControlKeyChange = { controlKey = it }, + onWordChange = { idx, word -> + restoreWords = restoreWords.toMutableList().also { it[idx] = word } + }, + onGenerate = { showRestore = false; restoreWords = List(12) { "" }; onRestoreModeChange(false); onGenerateWallet(controlKey) }, + onToggleRestore = { val next = !showRestore; showRestore = next; restoreWords = List(12) { "" }; onRestoreModeChange(next) }, + onRestore = { + // D-14: if the current wallet holds funds or assets, gate the + // restore with a destructive-confirm dialog and a forced-backup + // variant when `backup_completed` is false. + val phrase = restoreWords.joinToString(" ") + val assetsCount = ownedAssets?.size ?: 0 + val hasFunds = (walletBalance ?: 0.0) > 0.0 || assetsCount > 0 + if (hasFunds) { + pendingRestoreArgs = phrase to controlKey + showRestoreConfirmDialog = true + } else { + onRestoreWallet(phrase, controlKey) + } + } + ) + } } else if (walletInfo != null) { - BalanceCard(s, walletInfo, rvnPrice = rvnPrice, onCopyAddress = { clipboard.setText(AnnotatedString(walletInfo.address)) }) - Spacer(modifier = Modifier.height(16.dp)) - walletInfo.mnemonic?.let { mnemonic -> - MnemonicCard(s, mnemonic, visible = showMnemonic, onToggle = { showMnemonic = !showMnemonic }) - Spacer(modifier = Modifier.height(16.dp)) + item(key = "balance") { + Column { + BalanceCard(s, walletInfo, rvnPrice = rvnPrice, onCopyAddress = { clipboard.setText(AnnotatedString(walletInfo.address)) }) + // D-24: pending mempool incoming line (reads reserved-aware cache value). + val mempoolSat = remember(walletInfo.balanceRvn) { + (WalletCacheDao.readState()?.utxos.orEmpty()) + .filter { it.height <= 0 } + .sumOf { it.satoshis } + } + PendingBalanceLine(mempoolIncomingSat = mempoolSat) + } } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = onReceive, modifier = Modifier.weight(1f).height(48.dp), colors = ButtonDefaults.buttonColors(containerColor = RavenCard), border = BorderStroke(1.dp, AuthenticGreen.copy(alpha = 0.4f)), shape = RoundedCornerShape(12.dp)) { - Icon(Icons.Default.CallReceived, contentDescription = null, tint = AuthenticGreen, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(6.dp)) - Text(s.walletReceiveBtn, color = AuthenticGreen, fontWeight = FontWeight.SemiBold) + item(key = "balance_spacer") { Spacer(modifier = Modifier.height(16.dp)) } + if (walletInfo.mnemonic != null) { + item(key = "mnemonic") { MnemonicCard(s, walletInfo.mnemonic, visible = showMnemonic, onToggle = { showMnemonic = !showMnemonic }) } + item(key = "mnemonic_spacer") { Spacer(modifier = Modifier.height(16.dp)) } + } + item(key = "actions") { + // D-12: ConnectionHealth.RED disables Send/Receive with a Snackbar on tap. + val offline = health == ConnectionHealth.RED + val alphaMod = if (offline) 0.3f else 1f + val offlineTapMod = if (offline) { + Modifier.clickable { + scope.launch { snackbarHostState.showSnackbar(s.offlineAllNodesUnreachable) } + } + } else Modifier + Row(modifier = Modifier.fillMaxWidth().then(offlineTapMod), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = onReceive, + enabled = !offline, + modifier = Modifier.weight(1f).height(48.dp).alpha(alphaMod), + colors = ButtonDefaults.buttonColors( + containerColor = RavenCard, + disabledContainerColor = RavenCard, + disabledContentColor = RavenMuted + ), + border = BorderStroke(1.dp, if (offline) RavenMuted.copy(alpha = 0.4f) else AuthenticGreen.copy(alpha = 0.4f)), + shape = RoundedCornerShape(12.dp) + ) { + Icon(Icons.Default.CallReceived, contentDescription = null, tint = if (offline) RavenMuted else AuthenticGreen, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(s.walletReceiveBtn, color = if (offline) RavenMuted else AuthenticGreen, fontWeight = FontWeight.SemiBold) + } + Button( + onClick = { if (!isOperator) onSend() }, + enabled = !offline, + modifier = Modifier.weight(1f).height(48.dp).alpha(alphaMod), + colors = ButtonDefaults.buttonColors( + containerColor = RavenCard, + disabledContainerColor = RavenCard, + disabledContentColor = RavenMuted + ), + border = BorderStroke(1.dp, (if (offline || isOperator) RavenMuted else NotAuthenticRed).copy(alpha = 0.4f)), + shape = RoundedCornerShape(12.dp) + ) { + Icon(if (isOperator) Icons.Default.Lock else Icons.Default.Send, contentDescription = null, tint = if (offline || isOperator) RavenMuted else NotAuthenticRed, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(s.walletSendBtn, color = if (offline || isOperator) RavenMuted else NotAuthenticRed, fontWeight = FontWeight.SemiBold) + } } - Button(onClick = { if (!isOperator) onSend() }, modifier = Modifier.weight(1f).height(48.dp), colors = ButtonDefaults.buttonColors(containerColor = RavenCard), border = BorderStroke(1.dp, (if (isOperator) RavenMuted else NotAuthenticRed).copy(alpha = 0.4f)), shape = RoundedCornerShape(12.dp)) { - Icon(if (isOperator) Icons.Default.Lock else Icons.Default.Send, contentDescription = null, tint = if (isOperator) RavenMuted else NotAuthenticRed, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(6.dp)) - Text(s.walletSendBtn, color = if (isOperator) RavenMuted else NotAuthenticRed, fontWeight = FontWeight.SemiBold) + } + if (walletInfo.error != null) { + item(key = "error_spacer") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "error") { + Card(colors = CardDefaults.cardColors(containerColor = NotAuthenticRedBg), border = BorderStroke(1.dp, NotAuthenticRed.copy(alpha = 0.3f)), shape = RoundedCornerShape(12.dp)) { + Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Error, contentDescription = null, tint = NotAuthenticRed, modifier = Modifier.size(18.dp)) + Text(walletInfo.error, style = MaterialTheme.typography.bodySmall, color = NotAuthenticRed) + } + } } } - walletInfo.error?.let { err -> - Spacer(modifier = Modifier.height(16.dp)) - Card(colors = CardDefaults.cardColors(containerColor = NotAuthenticRedBg), border = BorderStroke(1.dp, NotAuthenticRed.copy(alpha = 0.3f)), shape = RoundedCornerShape(12.dp)) { - Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Error, contentDescription = null, tint = NotAuthenticRed, modifier = Modifier.size(18.dp)) - Text(err, style = MaterialTheme.typography.bodySmall, color = NotAuthenticRed) + item(key = "after_actions_spacer") { Spacer(modifier = Modifier.height(16.dp)) } + if (!isBrandApp && (walletBalance ?: 0.0) < 0.01 && hasWallet && !assetsLoading && !ownedAssets.isNullOrEmpty() && walletInfo?.isLoading != true) { + item(key = "low_rvn") { + Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF2D1A00)), border = BorderStroke(1.dp, RavenOrange.copy(alpha = 0.5f)), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)) { + Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Warning, contentDescription = null, tint = RavenOrange, modifier = Modifier.size(16.dp)) + Text(s.assetsLowRvnWarning, style = MaterialTheme.typography.bodySmall, color = RavenOrange.copy(alpha = 0.9f)) + } } } } - Spacer(modifier = Modifier.height(16.dp)) - if (!isBrandApp && walletBalance < 0.01 && hasWallet && !assetsLoading && !ownedAssets.isNullOrEmpty()) { - Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF2D1A00)), border = BorderStroke(1.dp, RavenOrange.copy(alpha = 0.5f)), shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)) { - Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Warning, contentDescription = null, tint = RavenOrange, modifier = Modifier.size(16.dp)) - Text(s.assetsLowRvnWarning, style = MaterialTheme.typography.bodySmall, color = RavenOrange.copy(alpha = 0.9f)) + // Consolidation banner: shown when funds are detected on old addresses + if (needsConsolidation && onConsolidateFunds != null && !assetsLoading && !consolidationInProgress) { + item(key = "consolidation_banner") { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFF1A2D00)), + border = BorderStroke(1.dp, AuthenticGreen.copy(alpha = 0.5f)), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.SyncProblem, contentDescription = null, tint = AuthenticGreen, modifier = Modifier.size(16.dp)) + Text( + "Funds detected on old addresses", + style = MaterialTheme.typography.bodySmall, + color = AuthenticGreen.copy(alpha = 0.9f), + fontWeight = FontWeight.SemiBold + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + "Consolidate all RVN and assets to a fresh, secure address", + style = MaterialTheme.typography.bodySmall, + color = RavenMuted + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onConsolidateFunds, + colors = ButtonDefaults.buttonColors(containerColor = AuthenticGreen), + modifier = Modifier.fillMaxWidth().height(36.dp) + ) { + Icon(Icons.Default.Sync, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(s.walletConsolidateBtn, color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) + } + } } } } - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(s.walletMyAssets, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = RavenMuted) - if (assetsLoading) CircularProgressIndicator(color = RavenOrange, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + // Consolidation in progress banner + if (consolidationInProgress) { + item(key = "consolidation_progress") { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFF1A2D00)), + border = BorderStroke(1.dp, AuthenticGreen.copy(alpha = 0.5f)), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp) + ) { + Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(color = AuthenticGreen, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + "Consolidating funds to fresh address...", + style = MaterialTheme.typography.bodySmall, + color = AuthenticGreen.copy(alpha = 0.9f), + fontWeight = FontWeight.SemiBold + ) + } + } + } } - Spacer(modifier = Modifier.height(10.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(null to s.walletFilterAll, AssetType.ROOT to s.walletAssetRoot, AssetType.SUB to s.walletAssetSub, AssetType.UNIQUE to s.walletAssetUnique).forEach { (type, label) -> - val selected = assetFilter == type - val typeColor = when(type) { AssetType.ROOT -> RavenOrange; AssetType.SUB -> Color(0xFF60A5FA); AssetType.UNIQUE -> AuthenticGreen; else -> RavenMuted } - FilterChip(selected = selected, onClick = { assetFilter = type }, label = { Text(label, style = MaterialTheme.typography.labelSmall) }, colors = FilterChipDefaults.filterChipColors(selectedContainerColor = typeColor.copy(alpha = 0.15f), selectedLabelColor = typeColor, containerColor = RavenCard, labelColor = RavenMuted), border = FilterChipDefaults.filterChipBorder(enabled = true, selected = selected, selectedBorderColor = typeColor.copy(alpha = 0.4f), borderColor = RavenBorder)) + item(key = "assets_header") { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(s.walletMyAssets, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = RavenMuted) + if (isRefreshing) CircularProgressIndicator(color = RavenOrange, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) } } - Spacer(modifier = Modifier.height(4.dp)) - if (!assetsLoading && assetsLoadError) { - Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF1A0D00)), border = BorderStroke(1.dp, RavenOrange.copy(alpha = 0.3f)), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { - Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.CloudOff, contentDescription = null, tint = RavenOrange, modifier = Modifier.size(18.dp)) - Text(s.walletAssetsNotVerifiable, style = MaterialTheme.typography.bodySmall, color = RavenOrange) + item(key = "assets_header_spacer") { Spacer(modifier = Modifier.height(10.dp)) } + item(key = "asset_filters") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(null to s.walletFilterAll, AssetType.ROOT to s.walletAssetRoot, AssetType.SUB to s.walletAssetSub, AssetType.UNIQUE to s.walletAssetUnique).forEach { (type, label) -> + val selected = assetFilter == type + val typeColor = when(type) { AssetType.ROOT -> RavenOrange; AssetType.SUB -> Color(0xFF60A5FA); AssetType.UNIQUE -> AuthenticGreen; else -> RavenMuted } + FilterChip(selected = selected, onClick = { assetFilter = type }, label = { Text(label, style = MaterialTheme.typography.labelSmall) }, colors = FilterChipDefaults.filterChipColors(selectedContainerColor = typeColor.copy(alpha = 0.15f), selectedLabelColor = typeColor, containerColor = RavenCard, labelColor = RavenMuted), border = FilterChipDefaults.filterChipBorder(enabled = true, selected = selected, selectedBorderColor = typeColor.copy(alpha = 0.4f), borderColor = RavenBorder)) } } - } else { - val filteredAssets = ownedAssets.orEmpty().filter { asset -> - val typeMatch = assetFilter == null || asset.type == assetFilter - val ownerTokenMatch = showOwnerTokens || !asset.name.endsWith("!") - typeMatch && ownerTokenMatch + } + item(key = "asset_filters_spacer") { Spacer(modifier = Modifier.height(4.dp)) } + // Error card: only when no cached assets are available to show + if (!assetsLoading && assetsLoadError && filteredAssets.isEmpty()) { + item(key = "assets_load_error") { + Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF1A0D00)), border = BorderStroke(1.dp, RavenOrange.copy(alpha = 0.3f)), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.CloudOff, contentDescription = null, tint = RavenOrange, modifier = Modifier.size(18.dp)) + Text(s.walletAssetsNotVerifiable, style = MaterialTheme.typography.bodySmall, color = RavenOrange) + } + } } - if (!assetsLoading && filteredAssets.isEmpty()) { + } else if (filteredAssets.isEmpty()) { + item(key = "assets_empty") { Card(colors = CardDefaults.cardColors(containerColor = RavenCard), border = BorderStroke(1.dp, RavenBorder), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.padding(20.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { Text(s.walletNoAssets, style = MaterialTheme.typography.bodySmall, color = RavenMuted, textAlign = TextAlign.Center) } } - } else { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - filteredAssets.forEach { asset -> - key(asset.name) { - // Operators can only transfer UNIQUE tokens; ROOT/SUB transfers are admin-only. - val canTransferThis = onTransferAsset != null && (!isOperator || asset.type == AssetType.UNIQUE) - AssetCard(s = s, asset = asset, onPreview = if (asset.imageUrl != null || asset.ipfsHash != null) ({ previewAsset = asset }) else null, onTransfer = if (canTransferThis) { { if (asset.type != AssetType.UNIQUE) { pendingTransferAsset = asset } else { onTransferAsset!!.invoke(asset) } } } else null) - } - } + } + } else { + // Operators can only transfer UNIQUE tokens; ROOT/SUB transfers are admin-only. + items(filteredAssets, key = { it.name }) { asset -> + val canTransferThis = onTransferAsset != null && (!isOperator || asset.type == AssetType.UNIQUE) + Box(modifier = Modifier.padding(bottom = 8.dp)) { + AssetCard(s = s, asset = asset, onPreview = if (asset.imageUrl != null || asset.ipfsHash != null) ({ previewAsset = asset }) else null, onTransfer = if (canTransferThis) { { if (asset.type != AssetType.UNIQUE) { pendingTransferAsset = asset } else { onTransferAsset!!.invoke(asset) } } } else null) } } } - Spacer(modifier = Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { - Text(text = s.walletShowOwnerTokens, style = MaterialTheme.typography.labelSmall, color = RavenMuted, modifier = Modifier.padding(end = 12.dp)) - Switch(checked = showOwnerTokens, onCheckedChange = { showOwnerTokens = it }, colors = SwitchDefaults.colors(checkedThumbColor = RavenOrange, checkedTrackColor = RavenOrange.copy(alpha = 0.3f), uncheckedThumbColor = RavenMuted, uncheckedTrackColor = RavenMuted.copy(alpha = 0.3f)), modifier = Modifier.size(width = 40.dp, height = 24.dp)) + item(key = "owner_tokens_spacer") { Spacer(modifier = Modifier.height(8.dp)) } + item(key = "owner_tokens") { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + Text(text = s.walletShowOwnerTokens, style = MaterialTheme.typography.labelSmall, color = RavenMuted, modifier = Modifier.padding(end = 12.dp)) + Switch(checked = showOwnerTokens, onCheckedChange = { showOwnerTokens = it }, colors = SwitchDefaults.colors(checkedThumbColor = RavenOrange, checkedTrackColor = RavenOrange.copy(alpha = 0.3f), uncheckedThumbColor = RavenMuted, uncheckedTrackColor = RavenMuted.copy(alpha = 0.3f)), modifier = Modifier.size(width = 40.dp, height = 24.dp)) + } } - Spacer(modifier = Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(s.walletTxHistory, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = RavenMuted) - if (txHistoryLoading) CircularProgressIndicator(color = RavenOrange, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + item(key = "tx_section_spacer") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "tx_header") { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(s.walletTxHistory, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = RavenMuted) + if (isRefreshing) CircularProgressIndicator(color = RavenOrange, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } } - Spacer(modifier = Modifier.height(10.dp)) - if (!txHistoryLoading && txHistory.isEmpty()) { - Card(colors = CardDefaults.cardColors(containerColor = RavenCard), border = BorderStroke(1.dp, RavenBorder), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { - Box(modifier = Modifier.padding(20.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { - Text(s.walletNoTxHistory, style = MaterialTheme.typography.bodySmall, color = RavenMuted, textAlign = TextAlign.Center) + item(key = "tx_header_spacer") { Spacer(modifier = Modifier.height(10.dp)) } + if (txHistory.isEmpty() && extraTxHistory.isEmpty()) { + // D-23 UI-SPEC empty state: heading + body (verbatim Copywriting Contract). + item(key = "tx_empty") { + Card(colors = CardDefaults.cardColors(containerColor = RavenCard), border = BorderStroke(1.dp, RavenBorder), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = s.txHistoryEmptyHeading, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color.White, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = s.txHistoryEmptyBody, + style = MaterialTheme.typography.bodySmall, + color = RavenMuted, + textAlign = TextAlign.Center + ) + } } } } else { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - txHistory.forEach { tx -> TxCard(s, tx) } + items(txHistory, key = { "vm_${it.txid}" }) { tx -> + Box(modifier = Modifier.padding(bottom = 6.dp)) { + TxCard(s, tx) + } } - // Show "Load More" button if there are more transactions to load - if (!txHistoryLoading && txHistoryLoadedCount < txHistoryTotal) { - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = onLoadMoreTransactions, - colors = ButtonDefaults.buttonColors(containerColor = RavenCard), - border = BorderStroke(1.dp, RavenBorder), - modifier = Modifier.fillMaxWidth().height(44.dp) - ) { - Icon(Icons.Default.MoreHoriz, contentDescription = null, tint = RavenOrange, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text(s.walletLoadMore, color = RavenOrange, fontWeight = FontWeight.SemiBold) + // D-23 locally appended rows (from TxHistoryDao.getPage / getHistoryPaged). + items(extraTxHistory, key = { "ex_${it.txid}" }) { tx -> + Box(modifier = Modifier.padding(bottom = 6.dp)) { + TxCard(s, tx) + } + } + if (txHistoryLoadedCount < txHistoryTotal || + (txHistory.isNotEmpty() && extraTxHistory.size < 200)) { + item(key = "load_more_spacer") { Spacer(modifier = Modifier.height(8.dp)) } + item(key = "load_more") { + // D-23 Load more: primary = parent VM callback (enriches via network); + // fallback = local paged read from TxHistoryDao, then RavencoinPublicNode.getHistoryPaged + // for shells when the DB is exhausted. + Button( + onClick = { + onLoadMoreTransactions() + scope.launch { + val offset = txHistory.size + extraTxHistory.size + val local: List = try { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + TxHistoryDao.getPage(offset = offset, limit = 20) + } + } catch (_: Exception) { emptyList() } + val localMapped = local.map { row -> + TxHistoryEntry( + txid = row.txid, + height = row.height, + confirmations = row.confirms, + amountSat = row.amountSat, + sentSat = row.sentSat, + isIncoming = row.isIncoming, + isSelfTransfer = row.isSelf, + timestamp = row.timestamp, + cycledSat = row.cycledSat, + feeSat = row.feeSat + ) + } + if (localMapped.isNotEmpty()) { + val existing = (txHistory + extraTxHistory).map { it.txid }.toHashSet() + extraTxHistory = extraTxHistory + localMapped.filter { it.txid !in existing } + } else { + val addr = walletInfo?.address + if (addr != null) { + val network = try { + io.raventag.app.wallet.RavencoinPublicNode(context) + .getHistoryPaged(address = addr, offset = offset, limit = 20) + } catch (_: Exception) { emptyList() } + if (network.isNotEmpty()) { + val existing = (txHistory + extraTxHistory).map { it.txid }.toHashSet() + extraTxHistory = extraTxHistory + network.filter { it.txid !in existing } + } + } + } + } + }, + colors = ButtonDefaults.buttonColors(containerColor = RavenOrange), + modifier = Modifier.fillMaxWidth().height(44.dp) + ) { + Icon(Icons.Default.MoreHoriz, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(s.txHistoryLoadMore, color = Color.White, fontWeight = FontWeight.SemiBold) + } } } } } else { - Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = RavenOrange) } + item(key = "wallet_loading") { + Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = RavenOrange) } + } } - Spacer(modifier = Modifier.height(24.dp)) + } + // D-07, D-12: snackbar overlay for incoming tx + offline-all-nodes messages. + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp) + ) } } @@ -399,7 +851,7 @@ private fun AssetCard(s: AppStrings, asset: OwnedAsset, onPreview: (() -> Unit)? } @Composable -private fun IpfsPreviewImage( +internal fun IpfsPreviewImage( urls: List, contentDescription: String, modifier: Modifier = Modifier, @@ -409,10 +861,14 @@ private fun IpfsPreviewImage( ) { val context = LocalContext.current val imageLoader = remember(context) { NetworkModule.getImageLoader(context) } - // Track which URL index we are currently trying - var urlIndex by remember(urls) { mutableStateOf(0) } - var resolvedUrl by remember(urls) { mutableStateOf(urls.firstOrNull()) } - var resolveFailed by remember(urls) { mutableStateOf(false) } + // Key state on a STABLE identifier (the first URL string) instead of the + // list reference, otherwise every recomposition that produces a new List + // object resets retry state and refires the network race condition that + // makes the preview look "intermittent" — sometimes works, sometimes not. + val key = urls.firstOrNull() ?: "" + var urlIndex by remember(key) { mutableStateOf(0) } + var resolvedUrl by remember(key) { mutableStateOf(urls.firstOrNull()) } + var resolveFailed by remember(key) { mutableStateOf(false) } if (resolvedUrl != null && !resolveFailed) { SubcomposeAsyncImage( @@ -437,18 +893,39 @@ private fun IpfsPreviewImage( return@LaunchedEffect } - // Step 2: all direct URLs failed, try JSON metadata parsing on each gateway + // Step 2: all direct URLs failed, try JSON metadata parsing on each gateway. + // The JSON may contain an image field that is either: + // - a full HTTP URL + // - a bare CID (which needs to be resolved against all gateways) val result = withContext(Dispatchers.IO) { + val client = NetworkModule.getHttpClient(context) urls.firstNotNullOfOrNull { url -> try { val req = Request.Builder().url(url).header("Accept", "application/json").get().build() - NetworkModule.getHttpClient(context).newCall(req).execute().use { resp -> + client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) return@use null val body = resp.body?.string() ?: "" val json = com.google.gson.JsonParser.parseString(body).asJsonObject val img = listOf("image", "image_url", "icon", "logo") .firstNotNullOfOrNull { k -> json[k]?.takeIf { !it.isJsonNull }?.asString } - img?.let { if (it.startsWith("http")) it else IpfsResolver.primaryUrl(it) } + img?.let { rawImg -> + when { + rawImg.startsWith("http") -> rawImg + else -> { + // rawImg is a bare CID, ipfs://..., or /ipfs/... + // Resolve it against ALL gateways and try each one + val candidates = IpfsResolver.candidateUrls(rawImg) + candidates.firstNotNullOfOrNull { candidateUrl -> + try { + val imgReq = Request.Builder().url(candidateUrl).get().build() + client.newCall(imgReq).execute().use { imgResp -> + if (imgResp.isSuccessful) candidateUrl else null + } + } catch (_: Exception) { null } + } + } + } + } } } catch (_: Exception) { null } } @@ -489,26 +966,36 @@ private fun BalanceCard(s: AppStrings, info: WalletInfo, rvnPrice: Double? = nul Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Default.AccountBalanceWallet, contentDescription = null, tint = RavenOrange, modifier = Modifier.size(18.dp)) ; Text(s.walletBalance, fontWeight = FontWeight.SemiBold, color = Color.White) } Spacer(modifier = Modifier.height(12.dp)) Text( - text = if (info.isLoading) { - AnnotatedString(s.walletLoading) - } else { - val full = String.format(java.util.Locale.US, "%.8f", info.balanceRvn) - val dotIdx = full.indexOf('.') - buildAnnotatedString { - append(full.substring(0, dotIdx)) - withStyle(SpanStyle(fontSize = 18.sp)) { - append(",${full.substring(dotIdx + 1)} RVN") + text = run { + if (info.balanceRvn == null) { + AnnotatedString(if (info.isLoading) s.walletLoading else (info.error ?: s.walletLoading)) + } else { + val full = String.format(java.util.Locale.US, "%.8f", info.balanceRvn) + val dotIdx = full.indexOf('.') + buildAnnotatedString { + append(full.substring(0, dotIdx)) + withStyle(SpanStyle(fontSize = 18.sp)) { + append(",${full.substring(dotIdx + 1)} RVN") + } } } }, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = RavenOrange, - fontSize = 28.sp + fontSize = if (info.balanceRvn == null) 18.sp else 28.sp ) - if (!info.isLoading && rvnPrice != null) { + // Always reserve the USD/price rows when rvnPrice is known, even during refresh, + // so the card height never contracts on a loading flip. + if (rvnPrice != null) { Spacer(modifier = Modifier.height(4.dp)) - if (info.balanceRvn > 0) { Text(text = "\u2248 ${"$%.2f".format(info.balanceRvn * rvnPrice)} USD", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = AuthenticGreen) } + Text( + text = if (info.balanceRvn != null) "\u2248 ${"$%.2f".format(info.balanceRvn * rvnPrice)} USD" else "", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = AuthenticGreen, + modifier = Modifier.alpha(if ((info.balanceRvn ?: 0.0) > 0) 1f else 0f) + ) Text(text = "1 RVN = ${"$%.4f".format(rvnPrice)}", style = MaterialTheme.typography.bodySmall, color = RavenMuted) } Spacer(modifier = Modifier.height(16.dp)) @@ -521,35 +1008,289 @@ private fun BalanceCard(s: AppStrings, info: WalletInfo, rvnPrice: Double? = nul @Composable private fun TxCard(s: AppStrings, tx: TxHistoryEntry) { - val isIncoming = tx.isIncoming - val dotColor = when { tx.confirmations == 0 -> NotAuthenticRed; tx.confirmations < 6 -> Color(0xFFF59E0B); else -> AuthenticGreen } - val confLabel = when { tx.confirmations == 0 -> s.walletTxUnconfirmed; tx.confirmations < 6 -> "${tx.confirmations} ${s.walletTxConfs}"; else -> s.walletTxConfirmed } - val amountRvn = if (isIncoming) tx.amountSat / 1e8 else tx.sentSat / 1e8 - val sign = if (isIncoming) "+" else "-" - val full = String.format(java.util.Locale.US, "%.8f", amountRvn) + val clipboard = LocalClipboardManager.current + val ctx = LocalContext.current + val isSelf = tx.isSelfTransfer + val isIncoming = tx.isIncoming && !isSelf + var showAssetListDialog by remember { mutableStateOf(false) } + if (showAssetListDialog) { + AlertDialog( + onDismissRequest = { showAssetListDialog = false }, + containerColor = RavenCard, + title = { Text(s.walletCycledAssetsTitle, color = Color.White, fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + tx.incomingAssetNames.forEach { name -> + Text(name, color = AuthenticGreen, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } + } + }, + confirmButton = { + TextButton(onClick = { showAssetListDialog = false }) { + Text(s.closeGeneric, color = RavenOrange) + } + } + ) + } + // D-08 dot color: red 0 conf, amber 1..5, green >=6. + val dotColor = when { + tx.confirmations == 0 -> NotAuthenticRed + tx.confirmations in 1..5 -> Color(0xFFF59E0B) + else -> AuthenticGreen + } + val confLabel = when { + tx.confirmations == 0 -> s.walletTxUnconfirmed + tx.confirmations < 6 -> "${tx.confirmations} ${s.walletTxConfs}" + else -> s.walletTxConfirmed + } + val amtColor = when { isSelf -> RavenOrange; isIncoming -> AuthenticGreen; else -> NotAuthenticRed } + val iconVec = when { isSelf -> Icons.Default.Autorenew; isIncoming -> Icons.Default.CallReceived; else -> Icons.Default.CallMade } + + // D-19 three-value amounts (outgoing only). Cycled/fee fall back to 0 for unenriched shells. + val sentSat = tx.sentSat + val cycledSat = tx.cycledSat + val feeSat = tx.feeSat + + // Pre-existing incoming/self "big amount" composite with 10sp decimals. + val amountRvn = when { + isSelf -> (if (cycledSat > 0L) cycledSat else tx.amountSat) / 1e8 + isIncoming -> tx.amountSat / 1e8 + else -> sentSat / 1e8 + } + val sign = if (isIncoming) "+" else "" + val full = String.format(java.util.Locale.US, "%.8f", amountRvn) val dotIdx = full.indexOf('.') val intPart = full.substring(0, dotIdx) val decPart = full.substring(dotIdx + 1).trimEnd('0') - val amountAnnotated = buildAnnotatedString { + val bigAmountAnnotated = buildAnnotatedString { append("$sign$intPart") if (decPart.isNotEmpty()) { - withStyle(SpanStyle(fontSize = 10.sp)) { - append(",$decPart RVN") - } + withStyle(SpanStyle(fontSize = 10.sp)) { append(",$decPart RVN") } } else { append(" RVN") } } - val dateText = if (tx.timestamp > 0) { java.text.SimpleDateFormat("dd/MM/yy HH:mm", java.util.Locale.getDefault()).apply { timeZone = java.util.TimeZone.getDefault() }.format(java.util.Date(tx.timestamp * 1000)) } else { "" } - Card(colors = CardDefaults.cardColors(containerColor = RavenCard), border = BorderStroke(1.dp, RavenBorder), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) { - Row(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - val scale = if (tx.confirmations == 0) { rememberInfiniteTransition(label = "").animateFloat(initialValue = 0.8f, targetValue = 1.2f, animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), label = "").value } else 1f + + // Plain 8-decimal RVN rendering for Sent/Cycled/Fee lines. + fun sat2Rvn(v: Long) = String.format(java.util.Locale.US, "%.8f", v / 1e8).trimEnd('0').trimEnd('.') + + val dateText = if (tx.timestamp > 0) { + java.text.SimpleDateFormat("dd/MM/yy HH:mm", java.util.Locale.getDefault()) + .apply { timeZone = java.util.TimeZone.getDefault() } + .format(java.util.Date(tx.timestamp * 1000)) + } else { "" } + + Card( + colors = CardDefaults.cardColors(containerColor = RavenCard), + border = BorderStroke(1.dp, RavenBorder), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + val scale = if (tx.confirmations == 0) { + rememberInfiniteTransition(label = "").animateFloat( + initialValue = 0.8f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), + label = "" + ).value + } else 1f Box(modifier = Modifier.size(10.dp).scale(scale).background(dotColor, androidx.compose.foundation.shape.CircleShape)) - Icon(imageVector = if (isIncoming) Icons.Default.CallReceived else Icons.Default.CallMade, contentDescription = null, tint = if (isIncoming) AuthenticGreen else NotAuthenticRed, modifier = Modifier.size(16.dp)) - Text("${tx.txid.take(8)}\u2026${tx.txid.takeLast(6)}", style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), color = RavenMuted, modifier = Modifier.weight(1f)) + Icon(imageVector = iconVec, contentDescription = null, tint = amtColor, modifier = Modifier.size(16.dp)) + Text( + "${tx.txid.take(8)}\u2026${tx.txid.takeLast(6)}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = RavenMuted, + modifier = Modifier.weight(1f).clickable { + clipboard.setText(AnnotatedString(tx.txid)) + android.widget.Toast.makeText(ctx, "TXID copiato", android.widget.Toast.LENGTH_SHORT).show() + } + ) Column(horizontalAlignment = Alignment.End) { - Text(amountAnnotated, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = if (isIncoming) AuthenticGreen else NotAuthenticRed) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { if (dateText.isNotEmpty()) { Text(dateText, style = MaterialTheme.typography.labelSmall, color = RavenMuted, fontSize = 9.sp) } ; Text("\u2022", style = MaterialTheme.typography.labelSmall, color = RavenMuted, fontSize = 9.sp) ; Text(confLabel, style = MaterialTheme.typography.labelSmall, color = dotColor, fontSize = 9.sp) } + when { + isIncoming -> { + if (tx.assetName != null) { + // Asset receive: split the hierarchical name across lines so + // long ROOT/SUB#UNIQUE chains stay readable inside the right column. + val raw = tx.assetAmount + val display = if (raw % 100_000_000L == 0L) (raw / 100_000_000L).toString() + else String.format(java.util.Locale.US, "%.8f", raw / 1e8).trimEnd('0').trimEnd('.') + val name = tx.assetName + val slashIdx = name.indexOf('/') + val hashIdx = name.indexOf('#') + val root = when { + slashIdx > 0 -> name.substring(0, slashIdx) + hashIdx > 0 -> name.substring(0, hashIdx) + else -> name + } + val sub = if (slashIdx > 0) { + if (hashIdx > slashIdx) name.substring(slashIdx, hashIdx) + else name.substring(slashIdx) + } else null + val unique = if (hashIdx > 0) name.substring(hashIdx) else null + Column(horizontalAlignment = Alignment.End) { + Text( + "+$display $root", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = amtColor + ) + if (sub != null) { + Text( + sub, + style = MaterialTheme.typography.labelSmall, + color = amtColor.copy(alpha = 0.85f) + ) + } + if (unique != null) { + Text( + unique, + style = MaterialTheme.typography.labelSmall, + color = amtColor.copy(alpha = 0.7f) + ) + } + } + } else { + Text(bigAmountAnnotated, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = amtColor) + } + } + isSelf -> { + // D-19 self-transfer variant: "Ciclato" green, "Fee" muted on new line. + // When the self-transfer carries an asset payload (e.g. cycling a + // unique token to a fresh address), surface the asset name too so + // the user can tell what was moved instead of seeing only the RVN dust. + val cycledStr = sat2Rvn(if (cycledSat > 0L) cycledSat else tx.amountSat) + val feeStr = sat2Rvn(feeSat) + Text( + text = "${s.txHistoryCycledPrefix} $cycledStr RVN", + style = MaterialTheme.typography.labelSmall, + color = AuthenticGreen + ) + if (tx.incomingAssetNames.size > 1) { + // Multi-asset cycle: show compact "N cycled assets", tap → dialog. + Spacer(Modifier.height(2.dp)) + Text( + String.format(s.walletCycledMultiAsset, tx.incomingAssetNames.size), + style = MaterialTheme.typography.labelSmall, + color = AuthenticGreen.copy(alpha = 0.85f), + modifier = Modifier.clickable { showAssetListDialog = true }, + textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline + ) + } else if (tx.assetName != null) { + Spacer(Modifier.height(2.dp)) + val raw = tx.assetAmount + val display = if (raw % 100_000_000L == 0L) (raw / 100_000_000L).toString() + else String.format(java.util.Locale.US, "%.8f", raw / 1e8).trimEnd('0').trimEnd('.') + val name = tx.assetName + val slashIdx = name.indexOf('/') + val hashIdx = name.indexOf('#') + val root = when { + slashIdx > 0 -> name.substring(0, slashIdx) + hashIdx > 0 -> name.substring(0, hashIdx) + else -> name + } + val sub = if (slashIdx > 0) { + if (hashIdx > slashIdx) name.substring(slashIdx, hashIdx) + else name.substring(slashIdx) + } else null + val unique = if (hashIdx > 0) name.substring(hashIdx) else null + Text( + "$display $root", + style = MaterialTheme.typography.labelSmall, + color = AuthenticGreen.copy(alpha = 0.85f) + ) + if (sub != null) Text(sub, style = MaterialTheme.typography.labelSmall, color = AuthenticGreen.copy(alpha = 0.7f)) + if (unique != null) Text(unique, style = MaterialTheme.typography.labelSmall, color = AuthenticGreen.copy(alpha = 0.6f)) + } + Spacer(Modifier.height(2.dp)) + Text( + text = "${s.txHistoryFeePrefix} $feeStr RVN", + style = MaterialTheme.typography.labelSmall, + color = RavenMuted + ) + } + else -> { + // D-19 outgoing three-value breakdown. + val sentStr = sat2Rvn(sentSat) + val cycledStr = sat2Rvn(cycledSat) + val feeStr = sat2Rvn(feeSat) + if (tx.assetName != null) { + // Outgoing asset: show "-N ASSET" instead of plain RVN amount. + val raw = tx.assetAmount + val display = if (raw % 100_000_000L == 0L) (raw / 100_000_000L).toString() + else String.format(java.util.Locale.US, "%.8f", raw / 1e8).trimEnd('0').trimEnd('.') + val name = tx.assetName + val slashIdx = name.indexOf('/') + val hashIdx = name.indexOf('#') + val root = when { + slashIdx > 0 -> name.substring(0, slashIdx) + hashIdx > 0 -> name.substring(0, hashIdx) + else -> name + } + val sub = if (slashIdx > 0) { + if (hashIdx > slashIdx) name.substring(slashIdx, hashIdx) + else name.substring(slashIdx) + } else null + val unique = if (hashIdx > 0) name.substring(hashIdx) else null + Text( + "${s.txHistorySentPrefix} -$display $root", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = NotAuthenticRed + ) + if (sub != null) { + Text(sub, style = MaterialTheme.typography.labelSmall, color = NotAuthenticRed.copy(alpha = 0.85f)) + } + if (unique != null) { + Text(unique, style = MaterialTheme.typography.labelSmall, color = NotAuthenticRed.copy(alpha = 0.7f)) + } + } else { + Text( + text = "${s.txHistorySentPrefix} -$sentStr RVN", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = NotAuthenticRed + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = "${s.txHistoryCycledPrefix} $cycledStr RVN", + style = MaterialTheme.typography.labelSmall, + color = AuthenticGreen + ) + // Outgoing tx that also cycles assets back to wallet: + // show count + tap-to-list dialog (kept compact). + if (tx.incomingAssetNames.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + val n = tx.incomingAssetNames.size + Text( + String.format(s.walletCycledMultiAsset, n), + style = MaterialTheme.typography.labelSmall, + color = AuthenticGreen.copy(alpha = 0.85f), + modifier = Modifier.clickable { showAssetListDialog = true }, + textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = "${s.txHistoryFeePrefix} $feeStr RVN", + style = MaterialTheme.typography.labelSmall, + color = RavenMuted + ) + } + } + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + if (dateText.isNotEmpty()) { + Text(dateText, style = MaterialTheme.typography.labelSmall, color = RavenMuted, fontSize = 9.sp) + } + Text("\u00b7", style = MaterialTheme.typography.labelSmall, color = RavenMuted, fontSize = 9.sp) + Text(confLabel, style = MaterialTheme.typography.labelSmall, color = dotColor, fontSize = 9.sp) + } } } } @@ -649,6 +1390,7 @@ private fun WalletSetupCard( controlKey: String, controlKeyValidating: Boolean, controlKeyError: String?, + restoreError: String? = null, onControlKeyChange: (String) -> Unit, onWordChange: (Int, String) -> Unit, onGenerate: () -> Unit, @@ -700,7 +1442,18 @@ private fun WalletSetupCard( } Spacer(modifier = Modifier.height(16.dp)) } - if (isGenerating || controlKeyValidating) { CircularProgressIndicator(color = RavenOrange) } else { + if (isGenerating || controlKeyValidating) { + CircularProgressIndicator(color = RavenOrange) + if (isGenerating && !controlKeyValidating) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + strings.walletScanningBlockchain, + style = MaterialTheme.typography.bodySmall, + color = RavenMuted, + textAlign = TextAlign.Center + ) + } + } else { Button( onClick = if (showRestore) onRestore else onGenerate, modifier = Modifier.fillMaxWidth().height(50.dp), @@ -733,6 +1486,16 @@ private fun WalletSetupCard( MnemonicInputGrid(strings = strings, words = restoreWords, onWordChange = onWordChange) } } + if (restoreError != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + restoreError, + color = NotAuthenticRed, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } } } } @@ -839,3 +1602,316 @@ private fun MnemonicCard(s: AppStrings, mnemonic: String, visible: Boolean, onTo } } } + +/** + * D-14: destructive-confirm dialog shown when the user initiates a restore-over-wallet + * with funds or assets in the current wallet. + * + * Two variants: + * - `hasBackedUp == true` : body describes the replacement, primary button "Replace wallet" + * (NotAuthenticRed), Cancel outlined. + * - `hasBackedUp == false` : body tells the user to back up first, primary button + * "Back up phrase first" (RavenOrange) routes to MnemonicBackupScreen, + * Cancel still available per UI-SPEC. + */ +// ============================================================ +// Phase 30 plan 30-08 composables (D-04, D-12, D-18, D-24, D-28) +// ============================================================ + +private fun formatHhMm(ms: Long): String { + if (ms <= 0L) return "--:--" + return java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) + .format(java.util.Date(ms)) +} + +/** D-04: cached-state banner shown while awaiting a successful refresh. */ +@Composable +private fun CachedStateBanner( + lastRefreshedAt: Long, + isReconnecting: Boolean, + visible: Boolean +) { + if (!visible || lastRefreshedAt <= 0L) return + val strings = LocalStrings.current + val label = if (isReconnecting) { + String.format(strings.cachedStateReconnecting, formatHhMm(lastRefreshedAt)) + } else { + String.format(strings.cachedStateBanner, formatHhMm(lastRefreshedAt)) + } + Card( + colors = CardDefaults.cardColors(containerColor = RavenCard), + border = BorderStroke(1.dp, RavenBorder), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.History, + contentDescription = null, + tint = RavenMuted, + modifier = Modifier.size(16.dp) + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = RavenMuted + ) + } + } +} + +/** D-24: pending mempool-incoming line displayed under the balance. */ +@Composable +private fun PendingBalanceLine(mempoolIncomingSat: Long) { + if (mempoolIncomingSat <= 0L) return + val strings = LocalStrings.current + val amber = Color(0xFFF59E0B) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 4.dp) + ) { + Icon( + Icons.Default.Schedule, + contentDescription = "Pending", + tint = RavenMuted, + modifier = Modifier.size(12.dp) + ) + Text( + text = strings.pendingBalanceLabel, + style = MaterialTheme.typography.bodySmall, + color = RavenMuted + ) + Spacer(Modifier.width(4.dp)) + Text( + text = String.format(java.util.Locale.US, "+%.8f RVN", mempoolIncomingSat / 1e8), + style = MaterialTheme.typography.bodySmall, + color = amber + ) + } +} + +/** D-28: battery-saver informational chip. */ +@Composable +private fun BatterySaverChip() { + val strings = LocalStrings.current + val amber = Color(0xFFF59E0B) + Card( + colors = CardDefaults.cardColors(containerColor = RavenCard), + border = BorderStroke(1.dp, amber.copy(alpha = 0.25f)), + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .padding(top = 4.dp) + .semantics { contentDescription = strings.batterySaverChipDesc } + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Default.BatterySaver, + contentDescription = null, + tint = amber, + modifier = Modifier.size(10.dp) + ) + Text( + text = strings.batterySaverChip, + style = MaterialTheme.typography.labelSmall, + color = amber + ) + } + } +} + +/** D-12: pill (GREEN/YELLOW/RED) driven by NodeHealthMonitor.stateFlow, tap opens sheet. */ +@Composable +private fun ConnectionHealthPill( + health: ConnectionHealth, + onTap: () -> Unit +) { + val strings = LocalStrings.current + val (color, label, pulse) = when (health) { + ConnectionHealth.GREEN -> Triple(AuthenticGreen, strings.connectionPillOnline, true) + ConnectionHealth.YELLOW -> Triple(Color(0xFFF59E0B), strings.connectionPillReconnecting, true) + ConnectionHealth.RED -> Triple(NotAuthenticRed, strings.connectionPillOffline, false) + } + val scale = if (pulse) { + val inf = rememberInfiniteTransition(label = "pill_pulse") + inf.animateFloat( + initialValue = 0.8f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), + label = "pill_dot" + ).value + } else 1f + Row( + modifier = Modifier + .clickable { onTap() } + .padding(top = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(6.dp) + .scale(scale) + .background(color, androidx.compose.foundation.shape.CircleShape) + .semantics { contentDescription = "${strings.connectionStatusDotDesc}: $label" } + ) + Text( + text = "ElectrumX · $label", + style = MaterialTheme.typography.labelSmall, + color = color.copy(alpha = 0.8f) + ) + } +} + +/** D-12: tap sheet listing current node + fallback nodes with quarantine status. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConnectionPillSheet(onDismiss: () -> Unit) { + val strings = LocalStrings.current + val currentNode = NodeHealthMonitor.currentNode() ?: strings.connectionPillNoNode + val diagnostics = NodeHealthMonitor.diagnostics() + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = RavenCard + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = strings.connectionPillSheetTitle, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(strings.connectionPillCurrentNode, style = MaterialTheme.typography.labelSmall, color = RavenMuted) + Text( + text = currentNode, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = Color.White + ) + } + val lastSuccess = diagnostics.firstOrNull { it.host == currentNode }?.lastSuccessAt + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(strings.connectionPillLastSuccess, style = MaterialTheme.typography.labelSmall, color = RavenMuted) + Text( + text = if (lastSuccess != null) formatHhMm(lastSuccess) else strings.connectionPillNoNode, + style = MaterialTheme.typography.bodySmall, + color = RavenMuted + ) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(strings.connectionPillFallbackNodes, style = MaterialTheme.typography.labelSmall, color = RavenMuted) + diagnostics.forEach { diag -> + val quarantined = diag.quarantinedUntil != null && diag.quarantinedUntil!! > System.currentTimeMillis() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + modifier = Modifier + .size(8.dp) + .background( + if (quarantined) NotAuthenticRed else AuthenticGreen, + androidx.compose.foundation.shape.CircleShape + ) + ) + Text( + text = diag.host, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = Color.White, + modifier = Modifier.weight(1f) + ) + if (quarantined) { + Text( + text = String.format( + strings.connectionPillQuarantined, + formatHhMm(diag.quarantinedUntil!!) + ), + style = MaterialTheme.typography.labelSmall, + color = NotAuthenticRed + ) + } + } + } + } + OutlinedButton( + onClick = onDismiss, + border = BorderStroke(1.dp, RavenBorder), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), + modifier = Modifier.align(Alignment.End) + ) { Text(strings.connectionPillClose) } + } + } +} + +@Composable +private fun RestoreWalletConfirmDialog( + hasBackedUp: Boolean, + rvnAmount: Double, + assetsCount: Int, + onDismiss: () -> Unit, + onBackupFirst: () -> Unit, + onReplace: () -> Unit +) { + val strings = LocalStrings.current + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Color(0xFF1A0000), + shape = RoundedCornerShape(16.dp), + title = { + Text( + text = strings.restoreReplaceWalletTitle, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White + ) + }, + text = { + val body = if (hasBackedUp) { + String.format( + strings.restoreReplaceWalletBody, + String.format("%.8f", rvnAmount), + assetsCount.toString() + ) + } else { + strings.restoreBackupFirstBody + } + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = RavenMuted + ) + }, + confirmButton = { + if (hasBackedUp) { + Button( + onClick = onReplace, + colors = ButtonDefaults.buttonColors(containerColor = NotAuthenticRed) + ) { Text(strings.restoreReplaceCta, fontWeight = FontWeight.Bold) } + } else { + Button( + onClick = onBackupFirst, + colors = ButtonDefaults.buttonColors(containerColor = RavenOrange) + ) { Text(strings.restoreBackupFirstCta, fontWeight = FontWeight.Bold) } + } + }, + dismissButton = { + OutlinedButton( + onClick = onDismiss, + border = BorderStroke(1.dp, RavenBorder), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + ) { Text(strings.cancel) } + } + ) +} diff --git a/android/app/src/main/java/io/raventag/app/ui/theme/AppStrings.kt b/android/app/src/main/java/io/raventag/app/ui/theme/AppStrings.kt index 52bec6a..380526f 100644 --- a/android/app/src/main/java/io/raventag/app/ui/theme/AppStrings.kt +++ b/android/app/src/main/java/io/raventag/app/ui/theme/AppStrings.kt @@ -84,6 +84,7 @@ class AppStrings { var walletMnemonicPlaceholder: String = "" var walletRestoreBtn: String = "" var mnemonicSpaceError: String = "" + var walletScanningBlockchain: String = "" var walletBalance: String = "" var walletLoading: String = "" var walletReceiveAddr: String = "" @@ -244,10 +245,17 @@ class AppStrings { var walletSendSuccess: String = "" var walletSendFailed: String = "" var walletTransferFailed: String = "" + var walletSendError: String = "" + var walletTransferError: String = "" var walletSendResult: String = "" var walletTransferResult: String = "" var walletSendWarning: String = "" var walletSendFeeUnavailable: String = "" + var sendFeeLabel: String = "" + var sendFeeTarget: String = "" + var sendFeeEditLabel: String = "" + var sendFeeOverrideHint: String = "" + var sendFeeEstimateUnavailable: String = "" var walletSendDialogTitle: String = "" var walletSendDialogMsg: String = "" // Asset filters @@ -352,6 +360,44 @@ class AppStrings { var issueSubSuccess: String = "" var issueUniqueSuccess: String = "" var issueFailed: String = "" + // Phase 40: Error classification + var issueErrorInsufficientFunds: String = "" + var issueErrorDuplicateName: String = "" + var issueErrorNodeUnreachable: String = "" + var issueErrorTimeout: String = "" + var issueErrorFeeEstimation: String = "" + var issueErrorIpfsAuth: String = "" + var issueErrorIpfsFailed: String = "" + var issueErrorInvalidAddress: String = "" + var issueErrorNoWallet: String = "" + // Phase 40: Error suggestions + var issueErrorSuggestionInsufficientFunds: String = "" + var issueErrorSuggestionDuplicate: String = "" + var issueErrorSuggestionNodeUnreachable: String = "" + var issueErrorSuggestionTimeout: String = "" + var issueErrorSuggestionFeeEstimation: String = "" + var issueErrorSuggestionIpfs: String = "" + var issueErrorSuggestionIpfsAuth: String = "" + var issueErrorSuggestionInvalidAddress: String = "" + // Phase 40: Multi-step progress step labels + var stepIpfsUpload: String = "" + var stepBalanceCheck: String = "" + var stepNameCheck: String = "" + var stepIssuing: String = "" + var stepNfcProgramming: String = "" + var stepConfirming: String = "" + var stepComplete: String = "" + // Phase 40: Confirmation progress + var confirmPending: String = "" + var confirmProgress: String = "" + var confirmComplete: String = "" + // Phase 40: Balance warnings + var balanceWarningRoot: String = "" + var balanceWarningSub: String = "" + var balanceWarningUnique: String = "" + // Phase 40: Revoke result + var revokeSuccess: String = "" + var revokeFailed: String = "" // Shared var adminKey: String = "" var adminKeyHint: String = "" @@ -381,6 +427,82 @@ class AppStrings { var walletNoTxHistory: String = "" var walletTxConfs: String = "" var walletLoadMore: String = "" + // Plan 30-06: mnemonic safety (biometric reveal + restore confirm + device-security-changed) + var mnemonicBiometricCoverTitle: String = "" + var mnemonicBiometricCoverBody: String = "" + var mnemonicRevealCta: String = "" + var mnemonicCopyAll: String = "" + var mnemonicSavedIt: String = "" + var authCanceledSnackbar: String = "" + var mnemonicRevealFailed: String = "" + var deviceSecurityChangedTitle: String = "" + var deviceSecurityChangedBody: String = "" + var deviceSecurityChangedCta: String = "" + var restoreReplaceWalletTitle: String = "" + var restoreReplaceWalletBody: String = "" + var restoreBackupFirstBody: String = "" + var restoreReplaceCta: String = "" + var restoreBackupFirstCta: String = "" + var restoreInvalidPhrase: String = "" + var cancel: String = "" + + // Phase 30 plan 30-08 additions + var cachedStateBanner: String = "Showing cached state · Last updated %1\$s" + var cachedStateReconnecting: String = "Last updated %1\$s · reconnecting…" + var pendingBalanceLabel: String = "Pending" + var batterySaverChip: String = "Battery saver · manual refresh" + var connectionPillOnline: String = "Online" + var connectionPillReconnecting: String = "Reconnecting…" + var connectionPillOffline: String = "Offline" + var connectionPillSheetTitle: String = "Ravencoin network" + var connectionPillCurrentNode: String = "Current node" + var connectionPillLastSuccess: String = "Last successful RPC" + var connectionPillFallbackNodes: String = "Fallback nodes" + var connectionPillQuarantined: String = "Quarantined until %1\$s" + var connectionPillClose: String = "Close" + var connectionPillNoNode: String = "(none)" + var reconnectingToast: String = "Reconnecting to Ravencoin network…" + var offlineAllNodesUnreachable: String = "Offline · all nodes unreachable" + var incomingTxSnackbar: String = "+%1\$s RVN received" + var receiveCurrentAddressLabel: String = "Your current address" + var receiveCurrentAddressSubLabel: String = "Changes after your next send or consolidation." + var walletOfflineHeading: String = "Wallet offline" + var walletOfflineBody: String = "Cannot reach any Ravencoin node. Check your internet connection, then tap Refresh." + + // Phase 30-09: D-19 tx history three-value breakdown + D-23 pagination. + var txHistorySentPrefix: String = "Sent" + var txHistoryCycledPrefix: String = "Cycled" + var txHistoryFeePrefix: String = "Fee" + var txHistoryLoadMore: String = "Load more" + var txHistoryEmptyHeading: String = "No transactions yet" + var txHistoryEmptyBody: String = "Your first sent or received transaction will appear here." + var txDetailsViewOnExplorer: String = "View on explorer" + var txHistoryConfirmations: String = "%1\$d/6 confirmations" + + // Phase 30-10: accessibility contentDescription labels + var connectionStatusDotDesc: String = "Connection status" + var batterySaverChipDesc: String = "Battery saver mode active" + var biometricCoverDesc: String = "Biometric authentication cover" + var revealMnemonicButtonDesc: String = "Reveal recovery phrase" + // Hardcoded UI string replacements + var walletKeystoreLabel: String = "" + var walletConsolidateBtn: String = "" + var walletCycledAssetsTitle: String = "" + var walletCycledMultiAsset: String = "" + var closeGeneric: String = "" + var protocolRtpBadge: String = "" + var maxCapsLabel: String = "" + var amountColon: String = "" + var toColon: String = "" + var failedToLoadTx: String = "" + var txDetailsTitle: String = "" + var txIdLabel: String = "" + var blockHeightLabel: String = "" + var fromLabel: String = "" + var timestampLabel: String = "" + var confirmationsLabel: String = "" + var amountShortLabel: String = "" + var insufficientBalanceAssetType: String = "" } private fun cloneStrings(base: AppStrings): AppStrings = @@ -434,6 +556,7 @@ val stringsEn = AppStrings().apply { walletGenerate = "Generate New Wallet"; walletRestore = "Restore from Mnemonic" walletMnemonicPlaceholder = "Enter 12-word mnemonic…"; walletRestoreBtn = "Restore Wallet" mnemonicSpaceError = "Spaces are not allowed, enter one word per field" + walletScanningBlockchain = "Scanning blockchain for wallet address…" walletBalance = "Ravencoin Balance"; walletLoading = "Loading…" walletReceiveAddr = "Receive Address" walletRecoveryPhrase = "Recovery Phrase"; walletNeverShare = "Never share your recovery phrase. Anyone with it can access your funds."; walletTapReveal = "Tap the eye icon to reveal your recovery phrase." @@ -511,8 +634,10 @@ val stringsEn = AppStrings().apply { walletReceiveTitle = "Receive RVN"; walletReceiveDesc = "Scan this QR code or copy the address below to receive Ravencoin." walletCopyDone = "Address copied!" walletSendTitle = "Send RVN"; walletSendAmountLabel = "Amount (RVN)"; walletSendAddrLabel = "Recipient Address" - walletSendConfirm = "Send"; walletSendSuccess = "Sent successfully!"; walletSendFailed = "Send failed"; walletTransferFailed = "Transfer failed"; walletSendResult = "Sent %1 RVN (fee: %2 RVN) · tx: %3..."; walletTransferResult = "Transferred %1 · tx: %2..."; walletSendWarning = "This action cannot be undone. Confirm the address carefully." + walletSendConfirm = "Send"; walletSendSuccess = "Sent successfully!"; walletSendFailed = "Send failed"; walletTransferFailed = "Transfer failed"; walletSendError = "Send failed: %1"; walletTransferError = "Transfer failed: %1"; walletSendResult = "Sent %1 RVN (fee: %2 RVN) · tx: %3..."; walletTransferResult = "Transferred %1 · tx: %2..."; walletSendWarning = "This action cannot be undone. Confirm the address carefully." walletSendFeeUnavailable = "Network fee rate unavailable. All nodes are unreachable, try again later." + sendFeeLabel = "Fee"; sendFeeTarget = "~6 blocks"; sendFeeEditLabel = "Edit fee"; sendFeeOverrideHint = "Custom fee (RVN/kB)" + sendFeeEstimateUnavailable = "Fee estimate unavailable. Using 0.01 RVN/kB fallback." walletSendDialogTitle = "Confirm Send"; walletSendDialogMsg = "Send %1 RVN to %2?" walletFilterAll = "All" brandProgramTag = "Program NFC Tag"; brandProgramTagDesc = "Write AES keys and SUN URL to an NTAG 424 DNA chip. Automatically registers the chip on the backend." @@ -598,6 +723,117 @@ val stringsEn = AppStrings().apply { walletTxConfs = "confirmations" walletLoadMore = "Load More" issueRootSuccess = "Asset %1 issued (tx: %2)"; issueSubSuccess = "Sub-asset %1 issued (tx: %2)"; issueUniqueSuccess = "Token %1 issued (tx: %2)"; issueFailed = "Issuance failed" + // Phase 40: Error classification + issueErrorInsufficientFunds = "Insufficient funds. Send RVN to your brand wallet and try again." + issueErrorDuplicateName = "Asset name already exists. Choose a different name." + issueErrorNodeUnreachable = "RPC node unreachable. Check your internet connection and try again." + issueErrorTimeout = "Request timed out. The transaction may have been broadcast. Check your wallet." + issueErrorFeeEstimation = "Fee estimation failed. The network may be congested." + issueErrorIpfsAuth = "IPFS authentication expired. Update your Pinata JWT in Settings." + issueErrorIpfsFailed = "IPFS upload failed. Check your connection and retry." + issueErrorInvalidAddress = "Invalid Ravencoin address format." + issueErrorNoWallet = "No Ravencoin wallet found. Create or restore a wallet first." + // Phase 40: Error suggestions + issueErrorSuggestionInsufficientFunds = "Send RVN to your brand wallet and try again." + issueErrorSuggestionDuplicate = "Change the asset name and try again." + issueErrorSuggestionNodeUnreachable = "Check your connection and try again." + issueErrorSuggestionTimeout = "Check the asset status on the explorer." + issueErrorSuggestionFeeEstimation = "Try again later." + issueErrorSuggestionIpfs = "Check IPFS settings and retry." + issueErrorSuggestionIpfsAuth = "Go to Settings and update your IPFS credentials." + issueErrorSuggestionInvalidAddress = "Correct the address and try again." + // Phase 40: Multi-step progress step labels + stepIpfsUpload = "Uploading to IPFS..." + stepBalanceCheck = "Checking balance..." + stepNameCheck = "Checking name availability..." + stepIssuing = "Issuing on Ravencoin..." + stepNfcProgramming = "Programming NFC tag..." + stepConfirming = "Confirming..." + stepComplete = "Complete" + // Phase 40: Confirmation progress + confirmPending = "Pending..." + confirmProgress = "%1\$d/6 confirmations" + confirmComplete = "Confirmed" + // Phase 40: Balance warnings + balanceWarningRoot = "Insufficient balance. Your wallet has %1 RVN. Requires ~500 RVN (burn fee) + ~0.01 RVN (network fee). Send RVN to this wallet and retry." + balanceWarningSub = "Insufficient balance. Your wallet has %1 RVN. Requires ~100 RVN (burn fee) + ~0.01 RVN (network fee). Send RVN to this wallet and retry." + balanceWarningUnique = "Insufficient balance. Your wallet has %1 RVN. Requires ~5 RVN (burn fee) + ~0.01 RVN (network fee). Send RVN to this wallet and retry." + // Phase 40: Revoke result + revokeSuccess = "Asset revoked" + revokeFailed = "Revocation failed" + // Plan 30-06 mnemonic safety copy (UI-SPEC Copywriting Contract, EN) + mnemonicBiometricCoverTitle = "Authenticate to reveal phrase" + mnemonicBiometricCoverBody = "Use your fingerprint, face, or PIN to display the recovery phrase. Anyone who sees it can steal your funds." + mnemonicRevealCta = "Reveal phrase" + mnemonicCopyAll = "Copy all" + mnemonicSavedIt = "I've saved it" + authCanceledSnackbar = "Authentication canceled" + mnemonicRevealFailed = "Could not reveal phrase. Try again." + deviceSecurityChangedTitle = "Device security changed" + deviceSecurityChangedBody = "Device security changed. Restore your wallet from the recovery phrase to continue." + deviceSecurityChangedCta = "Restore from recovery phrase" + restoreReplaceWalletTitle = "Replace current wallet?" + restoreReplaceWalletBody = "This will replace your current wallet (%1\$s RVN, %2\$s assets). You must back up the recovery phrase first. This action cannot be undone." + restoreBackupFirstBody = "Back up your recovery phrase first. You can't undo this." + restoreReplaceCta = "Replace wallet" + restoreBackupFirstCta = "Back up phrase first" + restoreInvalidPhrase = "Invalid recovery phrase. Check spelling and word order." + cancel = "Cancel" + + // Phase 30 plan 30-08 + cachedStateBanner = "Showing cached state · Last updated %1\$s" + cachedStateReconnecting = "Last updated %1\$s · reconnecting…" + pendingBalanceLabel = "Pending" + batterySaverChip = "Battery saver · manual refresh" + connectionPillOnline = "Online" + connectionPillReconnecting = "Reconnecting…" + connectionPillOffline = "Offline" + connectionPillSheetTitle = "Ravencoin network" + connectionPillCurrentNode = "Current node" + connectionPillLastSuccess = "Last successful RPC" + connectionPillFallbackNodes = "Fallback nodes" + connectionPillQuarantined = "Quarantined until %1\$s" + connectionPillClose = "Close" + connectionPillNoNode = "(none)" + reconnectingToast = "Reconnecting to Ravencoin network…" + offlineAllNodesUnreachable = "Offline · all nodes unreachable" + incomingTxSnackbar = "+%1\$s RVN received" + receiveCurrentAddressLabel = "Your current address" + receiveCurrentAddressSubLabel = "Changes after your next send or consolidation." + walletOfflineHeading = "Wallet offline" + walletOfflineBody = "Cannot reach any Ravencoin node. Check your internet connection, then tap Refresh." + txHistorySentPrefix = "Sent" + txHistoryCycledPrefix = "Cycled" + txHistoryFeePrefix = "Fee" + txHistoryLoadMore = "Load more" + txHistoryEmptyHeading = "No transactions yet" + txHistoryEmptyBody = "Your first sent or received transaction will appear here." + txDetailsViewOnExplorer = "View on explorer" + txHistoryConfirmations = "%1\$d/6 confirmations" + // Phase 30-10: accessibility contentDescription labels (EN) + connectionStatusDotDesc = "Connection status" + batterySaverChipDesc = "Battery saver mode active" + biometricCoverDesc = "Biometric authentication cover" + revealMnemonicButtonDesc = "Reveal recovery phrase" + // Hardcoded UI string replacements + walletKeystoreLabel = "Android Keystore · AES-256-GCM" + walletConsolidateBtn = "Consolidate to Fresh Address" + walletCycledAssetsTitle = "Cycled Assets" + walletCycledMultiAsset = "%1\$d cycled assets" + closeGeneric = "Close" + protocolRtpBadge = "Protocol RTP-1" + maxCapsLabel = "MAX" + amountColon = "Amount:" + toColon = "To:" + failedToLoadTx = "Failed to load transaction" + txDetailsTitle = "Transaction Details" + txIdLabel = "Transaction ID" + blockHeightLabel = "Block Height" + fromLabel = "From" + timestampLabel = "Timestamp" + confirmationsLabel = "Confirmations" + amountShortLabel = "Amount" + insufficientBalanceAssetType = "Insufficient balance for this asset type." } /** Italian strings. */ @@ -648,6 +884,7 @@ val stringsIt = AppStrings().apply { walletGenerate = "Genera nuovo portafoglio"; walletRestore = "Ripristina da mnemonica" walletMnemonicPlaceholder = "Inserisci la mnemonica di 12 parole…"; walletRestoreBtn = "Ripristina portafoglio" mnemonicSpaceError = "Gli spazi non sono consentiti, inserisci una parola per campo" + walletScanningBlockchain = "Scansione blockchain in corso per trovare l'indirizzo del portafoglio…" walletBalance = "Saldo Ravencoin"; walletLoading = "Caricamento…" walletReceiveAddr = "Indirizzo di ricezione" walletRecoveryPhrase = "Frase di recupero"; walletNeverShare = "Non condividere mai la frase di recupero. Chiunque la possieda può accedere ai tuoi fondi."; walletTapReveal = "Tocca l'icona occhio per mostrare la frase di recupero." @@ -725,8 +962,10 @@ val stringsIt = AppStrings().apply { walletReceiveTitle = "Ricevi RVN"; walletReceiveDesc = "Scansiona il QR code o copia l'indirizzo per ricevere Ravencoin." walletCopyDone = "Indirizzo copiato!" walletSendTitle = "Invia RVN"; walletSendAmountLabel = "Importo (RVN)"; walletSendAddrLabel = "Indirizzo destinatario" - walletSendConfirm = "Invia"; walletSendSuccess = "Inviato con successo!"; walletSendFailed = "Invio fallito"; walletTransferFailed = "Trasferimento fallito"; walletSendResult = "Inviato %1 RVN (commissione: %2 RVN) · tx: %3..."; walletTransferResult = "Trasferito %1 · tx: %2..."; walletSendWarning = "Questa operazione non può essere annullata. Controlla attentamente l'indirizzo." + walletSendConfirm = "Invia"; walletSendSuccess = "Inviato con successo!"; walletSendFailed = "Invio fallito"; walletTransferFailed = "Trasferimento fallito"; walletSendError = "Invio fallito: %1"; walletTransferError = "Trasferimento fallito: %1"; walletSendResult = "Inviato %1 RVN (commissione: %2 RVN) · tx: %3..."; walletTransferResult = "Trasferito %1 · tx: %2..."; walletSendWarning = "Questa operazione non può essere annullata. Controlla attentamente l'indirizzo." walletSendFeeUnavailable = "Commissione di rete non disponibile. Tutti i nodi sono irraggiungibili, riprova più tardi." + sendFeeLabel = "Commissione"; sendFeeTarget = "~6 blocchi"; sendFeeEditLabel = "Modifica commissione"; sendFeeOverrideHint = "Commissione custom (RVN/kB)" + sendFeeEstimateUnavailable = "Stima commissione non disponibile. Uso il valore minimo 0.01 RVN/kB." walletSendDialogTitle = "Conferma invio"; walletSendDialogMsg = "Inviare %1 RVN a %2?" walletFilterAll = "Tutti" brandProgramTag = "Programma tag NFC"; brandProgramTagDesc = "Scrivi chiavi AES e URL SUN su un chip NTAG 424 DNA. Registra automaticamente il chip sul backend." @@ -812,10 +1051,121 @@ val stringsIt = AppStrings().apply { walletTxConfs = "conferme" walletLoadMore = "Carica altre" issueRootSuccess = "Asset %1 emesso (tx: %2)"; issueSubSuccess = "Sub-asset %1 emesso (tx: %2)"; issueUniqueSuccess = "Token %1 emesso (tx: %2)"; issueFailed = "Emissione fallita" + // Phase 40: Error classification + issueErrorInsufficientFunds = "Fondi insufficienti. Invia RVN al wallet brand e riprova." + issueErrorDuplicateName = "Nome asset gia' esistente. Scegli un nome diverso." + issueErrorNodeUnreachable = "Nodo RPC irraggiungibile. Controlla la connessione e riprova." + issueErrorTimeout = "Richiesta scaduta. La transazione potrebbe essere stata emessa. Controlla il wallet." + issueErrorFeeEstimation = "Stima commissione fallita. La rete potrebbe essere congestionata." + issueErrorIpfsAuth = "Autenticazione IPFS scaduta. Aggiorna il JWT Pinata in Impostazioni." + issueErrorIpfsFailed = "Caricamento IPFS fallito. Controlla la connessione e riprova." + issueErrorInvalidAddress = "Formato indirizzo Ravencoin non valido." + issueErrorNoWallet = "Nessun wallet Ravencoin trovato. Crea o ripristina un wallet prima." + // Phase 40: Error suggestions + issueErrorSuggestionInsufficientFunds = "Invia RVN al wallet brand e riprova." + issueErrorSuggestionDuplicate = "Cambia il nome dell'asset e riprova." + issueErrorSuggestionNodeUnreachable = "Controlla la connessione e riprova." + issueErrorSuggestionTimeout = "Controlla lo stato dell'asset sull'esplorer." + issueErrorSuggestionFeeEstimation = "Riprova piu' tardi." + issueErrorSuggestionIpfs = "Controlla le impostazioni IPFS e riprova." + issueErrorSuggestionIpfsAuth = "Vai in Impostazioni e aggiorna le credenziali IPFS." + issueErrorSuggestionInvalidAddress = "Correggi l'indirizzo e riprova." + // Phase 40: Multi-step progress step labels + stepIpfsUpload = "Caricamento IPFS..." + stepBalanceCheck = "Verifica disponibilita'..." + stepNameCheck = "Verifica disponibilita'..." + stepIssuing = "Emissione in corso..." + stepNfcProgramming = "Programmazione tag NFC..." + stepConfirming = "Conferma in corso..." + stepComplete = "Completato" + // Phase 40: Confirmation progress + confirmPending = "In attesa..." + confirmProgress = "%1\$d/6 conferme" + confirmComplete = "Confermato" + // Phase 40: Balance warnings + balanceWarningRoot = "Saldo insufficiente. Il wallet ha %1 RVN. Servono ~500 RVN (burn fee) + ~0.01 RVN (network fee). Invia RVN a questo wallet e riprova." + balanceWarningSub = "Saldo insufficiente. Il wallet ha %1 RVN. Servono ~100 RVN (burn fee) + ~0.01 RVN (network fee). Invia RVN a questo wallet e riprova." + balanceWarningUnique = "Saldo insufficiente. Il wallet ha %1 RVN. Servono ~5 RVN (burn fee) + ~0.01 RVN (network fee). Invia RVN a questo wallet e riprova." + // Phase 40: Revoke result + revokeSuccess = "Asset revocato" + revokeFailed = "Revoca fallita" + // Plan 30-06 mnemonic safety copy (UI-SPEC Copywriting Contract, IT) + mnemonicBiometricCoverTitle = "Autenticati per mostrare la frase" + mnemonicBiometricCoverBody = "Usa impronta, volto o PIN per visualizzare la frase di recupero. Chi la vede può rubare i tuoi fondi." + mnemonicRevealCta = "Mostra frase" + mnemonicCopyAll = "Copia tutte" + mnemonicSavedIt = "L'ho salvata" + authCanceledSnackbar = "Autenticazione annullata" + mnemonicRevealFailed = "Impossibile mostrare la frase. Riprova." + deviceSecurityChangedTitle = "La sicurezza del dispositivo è cambiata" + deviceSecurityChangedBody = "La sicurezza del dispositivo è cambiata. Ripristina il wallet dalla frase di recupero per continuare." + deviceSecurityChangedCta = "Ripristina dalla frase di recupero" + restoreReplaceWalletTitle = "Sostituire il wallet attuale?" + restoreReplaceWalletBody = "Questa operazione sostituirà il wallet attuale (%1\$s RVN, %2\$s asset). Fai prima il backup della frase di recupero. Questa azione non può essere annullata." + restoreBackupFirstBody = "Fai prima il backup della frase di recupero. Non puoi annullare questa azione." + restoreReplaceCta = "Sostituisci wallet" + restoreBackupFirstCta = "Fai prima il backup" + restoreInvalidPhrase = "Frase di recupero non valida. Controlla ortografia e ordine." + cancel = "Annulla" + + // Phase 30 plan 30-08 + cachedStateBanner = "Stato in cache · Ultimo aggiornamento %1\$s" + cachedStateReconnecting = "Ultimo aggiornamento %1\$s · riconnessione…" + pendingBalanceLabel = "In attesa" + batterySaverChip = "Risparmio energetico · aggiorna a mano" + connectionPillOnline = "Online" + connectionPillReconnecting = "Riconnessione…" + connectionPillOffline = "Offline" + connectionPillSheetTitle = "Rete Ravencoin" + connectionPillCurrentNode = "Nodo attuale" + connectionPillLastSuccess = "Ultima RPC riuscita" + connectionPillFallbackNodes = "Nodi di riserva" + connectionPillQuarantined = "In quarantena fino a %1\$s" + connectionPillClose = "Chiudi" + connectionPillNoNode = "(nessuno)" + reconnectingToast = "Riconnessione alla rete Ravencoin…" + offlineAllNodesUnreachable = "Offline · nessun nodo raggiungibile" + incomingTxSnackbar = "+%1\$s RVN ricevuti" + receiveCurrentAddressLabel = "Il tuo indirizzo attuale" + receiveCurrentAddressSubLabel = "Cambia dopo il prossimo invio o consolidamento." + walletOfflineHeading = "Wallet offline" + walletOfflineBody = "Nessun nodo Ravencoin raggiungibile. Controlla la connessione e tocca Aggiorna." + txHistorySentPrefix = "Inviato" + txHistoryCycledPrefix = "Ciclato" + txHistoryFeePrefix = "Fee" + txHistoryLoadMore = "Carica altre" + txHistoryEmptyHeading = "Nessuna transazione" + txHistoryEmptyBody = "La prima transazione inviata o ricevuta comparirà qui." + txDetailsViewOnExplorer = "Apri su explorer" + txHistoryConfirmations = "%1\$d/6 conferme" + // Phase 30-10: accessibility contentDescription labels (IT) + connectionStatusDotDesc = "Stato connessione" + batterySaverChipDesc = "Modalità risparmio energetico attiva" + biometricCoverDesc = "Copertura autenticazione biometrica" + revealMnemonicButtonDesc = "Mostra frase di recupero" + // Hardcoded UI string replacements + walletKeystoreLabel = "Android Keystore · AES-256-GCM" + walletConsolidateBtn = "Consolida su nuovo indirizzo" + walletCycledAssetsTitle = "Asset ciclati" + walletCycledMultiAsset = "%1\$d asset ciclati" + closeGeneric = "Chiudi" + protocolRtpBadge = "Protocollo RTP-1" + maxCapsLabel = "MAX" + amountColon = "Importo:" + toColon = "A:" + failedToLoadTx = "Caricamento transazione fallito" + txDetailsTitle = "Dettagli transazione" + txIdLabel = "ID transazione" + blockHeightLabel = "Altezza blocco" + fromLabel = "Da" + timestampLabel = "Data e ora" + confirmationsLabel = "Conferme" + amountShortLabel = "Importo" + insufficientBalanceAssetType = "Saldo insufficiente per questo tipo di asset." } /** French strings. */ -val stringsFr = AppStrings().apply { +val stringsFr = cloneStrings(stringsEn).apply { onboardingBadge = "Protocole RTP-1 · Open Source" onboardingTitle = "Authentification NFC sans confiance pour les produits physiques" onboardingDesc = "RavenTag lie des puces NTAG 424 DNA aux actifs Ravencoin. Les marques contrôlent leurs clés AES, aucune autorité centrale, aucun point de défaillance." @@ -862,6 +1212,7 @@ val stringsFr = AppStrings().apply { walletGenerate = "Générer un nouveau portefeuille"; walletRestore = "Restaurer depuis mnémonique" walletMnemonicPlaceholder = "Entrez la mnémonique de 12 mots…"; walletRestoreBtn = "Restaurer le portefeuille" mnemonicSpaceError = "Les espaces ne sont pas autorisés, entrez un mot par champ" + walletScanningBlockchain = "Analyse de la blockchain pour retrouver l'adresse du portefeuille…" walletBalance = "Solde Ravencoin"; walletLoading = "Chargement…" walletReceiveAddr = "Adresse de réception" walletRecoveryPhrase = "Phrase de récupération"; walletNeverShare = "Ne partagez jamais votre phrase de récupération."; walletTapReveal = "Appuyez sur l'icône œil pour révéler la phrase." @@ -938,7 +1289,7 @@ val stringsFr = AppStrings().apply { walletReceiveTitle = "Recevoir RVN"; walletReceiveDesc = "Scannez ce QR ou copiez l'adresse ci-dessous pour recevoir Ravencoin." walletCopyDone = "Adresse copiée !" walletSendTitle = "Envoyer RVN"; walletSendAmountLabel = "Montant (RVN)"; walletSendAddrLabel = "Adresse du destinataire" - walletSendConfirm = "Envoyer"; walletSendSuccess = "Envoyé avec succès !"; walletSendFailed = "Envoi échoué"; walletTransferFailed = "Transfert échoué"; walletSendResult = "Envoyé %1 RVN (frais : %2 RVN) · tx : %3..."; walletTransferResult = "Transferred %1 · tx: %2..."; walletSendWarning = "Cette action est irréversible. Vérifiez l'adresse attentivement." + walletSendConfirm = "Envoyer"; walletSendSuccess = "Envoyé avec succès !"; walletSendFailed = "Envoi échoué"; walletTransferFailed = "Transfert échoué"; walletSendError = "Envoi échoué : %1"; walletTransferError = "Transfert échoué : %1"; walletSendResult = "Envoyé %1 RVN (frais : %2 RVN) · tx : %3..."; walletTransferResult = "Transferred %1 · tx: %2..."; walletSendWarning = "Cette action est irréversible. Vérifiez l'adresse attentivement." walletSendFeeUnavailable = "Taux de frais réseau indisponible. Tous les nœuds sont inaccessibles, réessayez plus tard." walletSendDialogTitle = "Confirmer l'envoi"; walletSendDialogMsg = "Envoyer %1 RVN à %2 ?" walletFilterAll = "Tous" @@ -1025,10 +1376,94 @@ val stringsFr = AppStrings().apply { walletTxConfs = "confirmations" walletLoadMore = "Charger plus" issueRootSuccess = "Actif %1 émis (tx: %2)"; issueSubSuccess = "Sous-actif %1 émis (tx: %2)"; issueUniqueSuccess = "Token %1 émis (tx: %2)"; issueFailed = "Émission échouée" + // Phase 40: Error classification + issueErrorInsufficientFunds = "Fonds insuffisants. Envoyez des RVN à votre portefeuille de marque et réessayez." + issueErrorDuplicateName = "Ce nom d'actif existe déjà. Choisissez un autre nom." + issueErrorNodeUnreachable = "Nœud RPC inaccessible. Vérifiez votre connexion internet et réessayez." + issueErrorTimeout = "La requête a expiré. La transaction a peut-être été diffusée. Vérifiez votre portefeuille." + issueErrorFeeEstimation = "L'estimation des frais a échoué. Le réseau est peut-être congestionné." + issueErrorIpfsAuth = "Authentification IPFS expirée. Mettez à jour votre JWT Pinata dans les Paramètres." + issueErrorIpfsFailed = "Échec du téléversement IPFS. Vérifiez votre connexion et réessayez." + issueErrorInvalidAddress = "Format d'adresse Ravencoin invalide." + issueErrorNoWallet = "Aucun portefeuille Ravencoin trouvé. Créez ou restaurez d'abord un portefeuille." + issueErrorSuggestionInsufficientFunds = "Envoyez des RVN à votre portefeuille de marque et réessayez." + issueErrorSuggestionDuplicate = "Changez le nom de l'actif et réessayez." + issueErrorSuggestionNodeUnreachable = "Vérifiez votre connexion et réessayez." + issueErrorSuggestionTimeout = "Vérifiez le statut de l'actif sur l'explorateur." + issueErrorSuggestionFeeEstimation = "Réessayez plus tard." + issueErrorSuggestionIpfs = "Vérifiez les paramètres IPFS et réessayez." + issueErrorSuggestionIpfsAuth = "Allez dans les Paramètres et mettez à jour vos identifiants IPFS." + issueErrorSuggestionInvalidAddress = "Corrigez l'adresse et réessayez." + // Phase 40: Multi-step progress + stepIpfsUpload = "Téléversement vers IPFS..."; stepBalanceCheck = "Vérification du solde..." + stepNameCheck = "Vérification du nom..."; stepIssuing = "Émission sur Ravencoin..." + stepNfcProgramming = "Programmation de la puce NFC..."; stepConfirming = "Confirmation..."; stepComplete = "Terminé" + // Phase 40: Confirmation + confirmPending = "En attente..."; confirmProgress = "%1\$d/6 confirmations"; confirmComplete = "Confirmé" + // Phase 40: Balance warnings + balanceWarningRoot = "Solde insuffisant. Votre portefeuille a %1 RVN. Nécessite ~500 RVN (frais de gravure) + ~0.01 RVN (frais réseau). Envoyez des RVN à ce portefeuille et réessayez." + balanceWarningSub = "Solde insuffisant. Votre portefeuille a %1 RVN. Nécessite ~100 RVN (frais de gravure) + ~0.01 RVN (frais réseau). Envoyez des RVN à ce portefeuille et réessayez." + balanceWarningUnique = "Solde insuffisant. Votre portefeuille a %1 RVN. Nécessite ~5 RVN (frais de gravure) + ~0.01 RVN (frais réseau). Envoyez des RVN à ce portefeuille et réessayez." + // Phase 40: Revoke result + revokeSuccess = "Actif révoqué"; revokeFailed = "Révocation échouée" + // Phase 30-06: mnemonic safety + mnemonicBiometricCoverTitle = "S'authentifier pour révéler la phrase" + mnemonicBiometricCoverBody = "Utilisez votre empreinte, visage ou code PIN pour afficher la phrase de récupération. Toute personne qui la voit peut voler vos fonds." + mnemonicRevealCta = "Révéler la phrase"; mnemonicCopyAll = "Tout copier" + mnemonicSavedIt = "Je l'ai sauvegardée"; authCanceledSnackbar = "Authentification annulée" + mnemonicRevealFailed = "Impossible de révéler la phrase. Réessayez." + deviceSecurityChangedTitle = "Sécurité de l'appareil modifiée" + deviceSecurityChangedBody = "La sécurité de l'appareil a changé. Restaurez votre portefeuille avec la phrase de récupération pour continuer." + deviceSecurityChangedCta = "Restaurer depuis la phrase de récupération" + restoreReplaceWalletTitle = "Remplacer le portefeuille actuel ?" + restoreReplaceWalletBody = "Cela remplacera votre portefeuille actuel (%1\$s RVN, %2\$s actifs). Sauvegardez d'abord la phrase de récupération. Cette action est irréversible." + restoreBackupFirstBody = "Sauvegardez d'abord votre phrase de récupération. Vous ne pouvez pas annuler." + restoreReplaceCta = "Remplacer le portefeuille"; restoreBackupFirstCta = "Sauvegarder la phrase d'abord" + restoreInvalidPhrase = "Phrase de récupération invalide. Vérifiez l'orthographe et l'ordre des mots." + cancel = "Annuler"; scanQr = "Scanner QR" + // Phase 30-08: Connection + cachedStateBanner = "Affichage en cache · Dernière mise à jour %1\$s" + cachedStateReconnecting = "Dernière mise à jour %1\$s · reconnexion…" + pendingBalanceLabel = "En attente" + batterySaverChip = "Économie batterie · actualisation manuelle" + batterySaverChipDesc = "Mode économie batterie actif" + connectionPillOnline = "En ligne"; connectionPillReconnecting = "Reconnexion…" + connectionPillOffline = "Hors ligne" + connectionPillSheetTitle = "Réseau Ravencoin" + connectionPillCurrentNode = "Nœud actuel" + connectionPillLastSuccess = "Dernier RPC réussi" + connectionPillFallbackNodes = "Nœuds de secours" + connectionPillQuarantined = "En quarantaine jusqu'à %1\$s" + connectionPillClose = "Fermer"; connectionPillNoNode = "(aucun)" + connectionStatusDotDesc = "État de la connexion" + reconnectingToast = "Reconnexion au réseau Ravencoin…" + offlineAllNodesUnreachable = "Hors ligne · tous les nœuds inaccessibles" + incomingTxSnackbar = "+%1\$s RVN reçu" + receiveCurrentAddressLabel = "Votre adresse actuelle" + receiveCurrentAddressSubLabel = "Change après votre prochain envoi ou consolidation." + walletOfflineHeading = "Portefeuille hors ligne" + walletOfflineBody = "Impossible d'atteindre un nœud Ravencoin. Vérifiez votre connexion internet, puis appuyez sur Actualiser." + // Phase 30-09: tx history + txHistorySentPrefix = "Envoyé"; txHistoryCycledPrefix = "Recyclé" + walletCycledMultiAsset = "%1\$d assets recyclés" + walletCycledAssetsTitle = "Assets recyclés" + txHistoryFeePrefix = "Frais"; txHistoryLoadMore = "Afficher plus" + txHistoryEmptyHeading = "Aucune transaction" + txHistoryEmptyBody = "Votre première transaction envoyée ou reçue apparaîtra ici." + txDetailsViewOnExplorer = "Voir sur l'explorateur" + txHistoryConfirmations = "%1\$d/6 confirmations" + // Phase 30-10: accessibility + biometricCoverDesc = "Écran d'authentification biométrique" + revealMnemonicButtonDesc = "Révéler la phrase de récupération" + // Fee + sendFeeLabel = "Frais"; sendFeeEditLabel = "Modifier les frais" + sendFeeOverrideHint = "Frais personnalisés (RVN/kB)" + sendFeeTarget = "~6 blocs" + sendFeeEstimateUnavailable = "Estimation des frais indisponible. Utilisation de 0.01 RVN/kB par défaut." } /** German strings. */ -val stringsDe = AppStrings().apply { +val stringsDe = cloneStrings(stringsEn).apply { onboardingBadge = "Protokoll RTP-1 · Open Source" onboardingTitle = "Vertrauenslose NFC-Authentifizierung für physische Produkte" onboardingDesc = "RavenTag verbindet NTAG 424 DNA-Chips mit Ravencoin-Blockchain-Assets. Marken kontrollieren ihre AES-Schlüssel, keine zentrale Autorität." @@ -1075,6 +1510,7 @@ val stringsDe = AppStrings().apply { walletGenerate = "Neues Wallet generieren"; walletRestore = "Aus Mnemonic wiederherstellen" walletMnemonicPlaceholder = "12-Wort-Mnemonic eingeben…"; walletRestoreBtn = "Wallet wiederherstellen" mnemonicSpaceError = "Leerzeichen sind nicht erlaubt, gib ein Wort pro Feld ein" + walletScanningBlockchain = "Blockchain wird nach Wallet-Adresse durchsucht…" walletBalance = "Ravencoin-Guthaben"; walletLoading = "Lädt…" walletReceiveAddr = "Empfangsadresse" walletRecoveryPhrase = "Wiederherstellungsphrase"; walletNeverShare = "Teilen Sie niemals Ihre Wiederherstellungsphrase."; walletTapReveal = "Tippen Sie auf das Augensymbol, um die Phrase anzuzeigen." @@ -1151,7 +1587,7 @@ val stringsDe = AppStrings().apply { walletReceiveTitle = "RVN empfangen"; walletReceiveDesc = "Scannen Sie diesen QR-Code oder kopieren Sie die Adresse, um Ravencoin zu empfangen." walletCopyDone = "Adresse kopiert!" walletSendTitle = "RVN senden"; walletSendAmountLabel = "Betrag (RVN)"; walletSendAddrLabel = "Empfängeradresse" - walletSendConfirm = "Senden"; walletSendSuccess = "Erfolgreich gesendet!"; walletSendFailed = "Senden fehlgeschlagen"; walletTransferFailed = "Übertragung fehlgeschlagen"; walletSendResult = "%1 RVN gesendet (Gebühr: %2 RVN) · tx: %3..."; walletTransferResult = "%1 übertragen · tx: %2..."; walletSendWarning = "Diese Aktion kann nicht rückgängig gemacht werden. Prüfen Sie die Adresse sorgfältig." + walletSendConfirm = "Senden"; walletSendSuccess = "Erfolgreich gesendet!"; walletSendFailed = "Senden fehlgeschlagen"; walletTransferFailed = "Übertragung fehlgeschlagen"; walletSendError = "Senden fehlgeschlagen: %1"; walletTransferError = "Übertragung fehlgeschlagen: %1"; walletSendResult = "%1 RVN gesendet (Gebühr: %2 RVN) · tx: %3..."; walletTransferResult = "%1 übertragen · tx: %2..."; walletSendWarning = "Diese Aktion kann nicht rückgängig gemacht werden. Prüfen Sie die Adresse sorgfältig." walletSendFeeUnavailable = "Netzwerk-Gebührenrate nicht verfügbar. Alle Knoten sind nicht erreichbar, versuchen Sie es später erneut." walletSendDialogTitle = "Senden bestätigen"; walletSendDialogMsg = "%1 RVN an %2 senden?" walletFilterAll = "Alle" @@ -1238,10 +1674,94 @@ val stringsDe = AppStrings().apply { walletTxConfs = "Bestätigungen" walletLoadMore = "Mehr laden" issueRootSuccess = "Asset %1 ausgestellt (tx: %2)"; issueSubSuccess = "Sub-Asset %1 ausgestellt (tx: %2)"; issueUniqueSuccess = "Token %1 ausgestellt (tx: %2)"; issueFailed = "Ausstellung fehlgeschlagen" + // Phase 40: Error classification + issueErrorInsufficientFunds = "Unzureichende Deckung. Senden Sie RVN an Ihr Marken-Wallet und versuchen Sie es erneut." + issueErrorDuplicateName = "Asset-Name existiert bereits. Wählen Sie einen anderen Namen." + issueErrorNodeUnreachable = "RPC-Knoten nicht erreichbar. Überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut." + issueErrorTimeout = "Anfrage-Zeitüberschreitung. Die Transaktion könnte bereits übertragen worden sein. Prüfen Sie Ihr Wallet." + issueErrorFeeEstimation = "Gebührenschätzung fehlgeschlagen. Das Netzwerk könnte überlastet sein." + issueErrorIpfsAuth = "IPFS-Authentifizierung abgelaufen. Aktualisieren Sie Ihr Pinata JWT in den Einstellungen." + issueErrorIpfsFailed = "IPFS-Upload fehlgeschlagen. Überprüfen Sie Ihre Verbindung und wiederholen Sie es." + issueErrorInvalidAddress = "Ungültiges Ravencoin-Adressformat." + issueErrorNoWallet = "Kein Ravencoin-Wallet gefunden. Erstellen oder stellen Sie zuerst ein Wallet wieder her." + issueErrorSuggestionInsufficientFunds = "Senden Sie RVN an Ihr Marken-Wallet und versuchen Sie es erneut." + issueErrorSuggestionDuplicate = "Ändern Sie den Asset-Namen und versuchen Sie es erneut." + issueErrorSuggestionNodeUnreachable = "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut." + issueErrorSuggestionTimeout = "Überprüfen Sie den Asset-Status im Explorer." + issueErrorSuggestionFeeEstimation = "Versuchen Sie es später erneut." + issueErrorSuggestionIpfs = "Überprüfen Sie die IPFS-Einstellungen und wiederholen Sie es." + issueErrorSuggestionIpfsAuth = "Gehen Sie zu Einstellungen und aktualisieren Sie Ihre IPFS-Anmeldeinformationen." + issueErrorSuggestionInvalidAddress = "Korrigieren Sie die Adresse und versuchen Sie es erneut." + // Phase 40: Multi-step progress + stepIpfsUpload = "Upload auf IPFS..."; stepBalanceCheck = "Guthaben prüfen..." + stepNameCheck = "Namensverfügbarkeit prüfen..."; stepIssuing = "Ausstellung auf Ravencoin..." + stepNfcProgramming = "NFC-Chip programmieren..."; stepConfirming = "Bestätigung..."; stepComplete = "Abgeschlossen" + // Phase 40: Confirmation + confirmPending = "Ausstehend..."; confirmProgress = "%1\$d/6 Bestätigungen"; confirmComplete = "Bestätigt" + // Phase 40: Balance warnings + balanceWarningRoot = "Unzureichende Deckung. Ihr Wallet hat %1 RVN. Erfordert ~500 RVN (Burn-Gebühr) + ~0.01 RVN (Netzwerkgebühr). Senden Sie RVN an dieses Wallet und versuchen Sie es erneut." + balanceWarningSub = "Unzureichende Deckung. Ihr Wallet hat %1 RVN. Erfordert ~100 RVN (Burn-Gebühr) + ~0.01 RVN (Netzwerkgebühr). Senden Sie RVN an dieses Wallet und versuchen Sie es erneut." + balanceWarningUnique = "Unzureichende Deckung. Ihr Wallet hat %1 RVN. Erfordert ~5 RVN (Burn-Gebühr) + ~0.01 RVN (Netzwerkgebühr). Senden Sie RVN an dieses Wallet und versuchen Sie es erneut." + // Phase 40: Revoke result + revokeSuccess = "Asset gesperrt"; revokeFailed = "Sperrung fehlgeschlagen" + // Phase 30-06: mnemonic safety + mnemonicBiometricCoverTitle = "Authentifizieren, um Phrase anzuzeigen" + mnemonicBiometricCoverBody = "Verwenden Sie Fingerabdruck, Gesicht oder PIN, um die Wiederherstellungsphrase anzuzeigen. Jeder, der sie sieht, kann Ihre Gelder stehlen." + mnemonicRevealCta = "Phrase anzeigen"; mnemonicCopyAll = "Alle kopieren" + mnemonicSavedIt = "Ich habe sie gespeichert"; authCanceledSnackbar = "Authentifizierung abgebrochen" + mnemonicRevealFailed = "Phrase konnte nicht angezeigt werden. Versuchen Sie es erneut." + deviceSecurityChangedTitle = "Gerätesicherheit geändert" + deviceSecurityChangedBody = "Die Gerätesicherheit wurde geändert. Stellen Sie Ihr Wallet mit der Wiederherstellungsphrase wieder her, um fortzufahren." + deviceSecurityChangedCta = "Mit Wiederherstellungsphrase wiederherstellen" + restoreReplaceWalletTitle = "Aktuelles Wallet ersetzen?" + restoreReplaceWalletBody = "Dies ersetzt Ihr aktuelles Wallet (%1\$s RVN, %2\$s Assets). Sichern Sie zuerst die Wiederherstellungsphrase. Diese Aktion kann nicht rückgängig gemacht werden." + restoreBackupFirstBody = "Sichern Sie zuerst Ihre Wiederherstellungsphrase. Dies kann nicht rückgängig gemacht werden." + restoreReplaceCta = "Wallet ersetzen"; restoreBackupFirstCta = "Zuerst Phrase sichern" + restoreInvalidPhrase = "Ungültige Wiederherstellungsphrase. Überprüfen Sie Rechtschreibung und Wortreihenfolge." + cancel = "Abbrechen" + // Phase 30-08: Connection + cachedStateBanner = "Zeige zwischengespeicherten Zustand · Letzte Aktualisierung %1\$s" + cachedStateReconnecting = "Letzte Aktualisierung %1\$s · Wiederverbindung…" + pendingBalanceLabel = "Ausstehend" + batterySaverChip = "Energiesparmodus · manuelle Aktualisierung" + batterySaverChipDesc = "Energiesparmodus aktiv" + connectionPillOnline = "Online"; connectionPillReconnecting = "Wiederverbindung…" + connectionPillOffline = "Offline" + connectionPillSheetTitle = "Ravencoin-Netzwerk" + connectionPillCurrentNode = "Aktueller Knoten" + connectionPillLastSuccess = "Letzter erfolgreicher RPC" + connectionPillFallbackNodes = "Fallback-Knoten" + connectionPillQuarantined = "Quarantäne bis %1\$s" + connectionPillClose = "Schließen"; connectionPillNoNode = "(keiner)" + connectionStatusDotDesc = "Verbindungsstatus" + reconnectingToast = "Wiederverbindung zum Ravencoin-Netzwerk…" + offlineAllNodesUnreachable = "Offline · alle Knoten nicht erreichbar" + incomingTxSnackbar = "+%1\$s RVN erhalten" + receiveCurrentAddressLabel = "Ihre aktuelle Adresse" + receiveCurrentAddressSubLabel = "Ändert sich nach Ihrem nächsten Sendevorgang oder Konsolidierung." + walletOfflineHeading = "Wallet offline" + walletOfflineBody = "Kein Ravencoin-Knoten erreichbar. Überprüfen Sie Ihre Internetverbindung und tippen Sie dann auf Aktualisieren." + // Phase 30-09: tx history + txHistorySentPrefix = "Gesendet"; txHistoryCycledPrefix = "Recycelt" + walletCycledMultiAsset = "%1\$d recycelte Assets" + walletCycledAssetsTitle = "Recycelte Assets" + txHistoryFeePrefix = "Gebühr"; txHistoryLoadMore = "Mehr laden" + txHistoryEmptyHeading = "Noch keine Transaktionen" + txHistoryEmptyBody = "Ihre erste gesendete oder empfangene Transaktion erscheint hier." + txDetailsViewOnExplorer = "Im Explorer anzeigen" + txHistoryConfirmations = "%1\$d/6 Bestätigungen" + // Phase 30-10: accessibility + biometricCoverDesc = "Biometrischer Authentifizierungsbildschirm" + revealMnemonicButtonDesc = "Wiederherstellungsphrase anzeigen" + // Fee + sendFeeLabel = "Gebühr"; sendFeeEditLabel = "Gebühr bearbeiten" + sendFeeOverrideHint = "Benutzerdefinierte Gebühr (RVN/kB)" + sendFeeTarget = "~6 Blöcke" + sendFeeEstimateUnavailable = "Gebührenschätzung nicht verfügbar. Verwende 0.01 RVN/kB als Fallback." } /** Spanish strings. */ -val stringsEs = AppStrings().apply { +val stringsEs = cloneStrings(stringsEn).apply { onboardingBadge = "Protocolo RTP-1 · Open Source" onboardingTitle = "Autenticación NFC sin confianza para productos físicos" onboardingDesc = "RavenTag vincula chips NTAG 424 DNA a activos Ravencoin. Las marcas controlan sus claves AES, sin autoridad central, sin punto único de fallo." @@ -1288,6 +1808,7 @@ val stringsEs = AppStrings().apply { walletGenerate = "Generar nueva cartera"; walletRestore = "Restaurar desde mnemónica" walletMnemonicPlaceholder = "Introduce la mnemónica de 12 palabras…"; walletRestoreBtn = "Restaurar cartera" mnemonicSpaceError = "Los espacios no están permitidos, introduce una palabra por campo" + walletScanningBlockchain = "Analizando la blockchain para encontrar la dirección de la cartera…" walletBalance = "Saldo Ravencoin"; walletLoading = "Cargando…" walletReceiveAddr = "Dirección de recepción" walletRecoveryPhrase = "Frase de recuperación"; walletNeverShare = "Nunca compartas tu frase de recuperación."; walletTapReveal = "Toca el icono del ojo para revelar la frase." @@ -1365,7 +1886,7 @@ val stringsEs = AppStrings().apply { walletReceiveTitle = "Recibir RVN"; walletReceiveDesc = "Escanea este QR o copia la dirección para recibir Ravencoin." walletCopyDone = "¡Dirección copiada!" walletSendTitle = "Enviar RVN"; walletSendAmountLabel = "Cantidad (RVN)"; walletSendAddrLabel = "Dirección del destinatario" - walletSendConfirm = "Enviar"; walletSendSuccess = "¡Enviado con éxito!"; walletSendFailed = "Error al enviar"; walletTransferFailed = "Error al transferir"; walletSendResult = "Enviado %1 RVN (comisión: %2 RVN) · tx: %3..."; walletTransferResult = "Transferido %1 · tx: %2..."; walletSendWarning = "Esta action no se puede deshacer. Verifica la dirección con cuidado." + walletSendConfirm = "Enviar"; walletSendSuccess = "¡Enviado con éxito!"; walletSendFailed = "Error al enviar"; walletTransferFailed = "Error al transferir"; walletSendError = "Error al enviar: %1"; walletTransferError = "Error al transferir: %1"; walletSendResult = "Enviado %1 RVN (comisión: %2 RVN) · tx: %3..."; walletTransferResult = "Transferido %1 · tx: %2..."; walletSendWarning = "Esta action no se puede deshacer. Verifica la dirección con cuidado." walletSendFeeUnavailable = "Tasa de comisión de red no disponible. Todos los nodos son inaccesibles, inténtalo más tarde." walletSendDialogTitle = "Confirmar envío"; walletSendDialogMsg = "¿Enviar %1 RVN a %2?" walletFilterAll = "Todos" @@ -1452,6 +1973,90 @@ val stringsEs = AppStrings().apply { walletTxConfs = "confirmaciones" walletLoadMore = "Cargar más" issueRootSuccess = "Activo %1 emitido (tx: %2)"; issueSubSuccess = "Sub-activo %1 emitido (tx: %2)"; issueUniqueSuccess = "Token %1 emitido (tx: %2)"; issueFailed = "Emisión fallida" + // Phase 40: Error classification + issueErrorInsufficientFunds = "Fondos insuficientes. Envíe RVN a su monedero de marca e intente de nuevo." + issueErrorDuplicateName = "El nombre del activo ya existe. Elija un nombre diferente." + issueErrorNodeUnreachable = "Nodo RPC inaccesible. Verifique su conexión a internet e intente de nuevo." + issueErrorTimeout = "La solicitud expiró. La transacción puede haber sido transmitida. Verifique su monedero." + issueErrorFeeEstimation = "Error en la estimación de comisiones. La red puede estar congestionada." + issueErrorIpfsAuth = "Autenticación IPFS expirada. Actualice su JWT de Pinata en Configuración." + issueErrorIpfsFailed = "Error al subir a IPFS. Verifique su conexión y reintente." + issueErrorInvalidAddress = "Formato de dirección Ravencoin inválido." + issueErrorNoWallet = "No se encontró monedero Ravencoin. Primero cree o restaure uno." + issueErrorSuggestionInsufficientFunds = "Envíe RVN a su monedero de marca e intente de nuevo." + issueErrorSuggestionDuplicate = "Cambie el nombre del activo e intente de nuevo." + issueErrorSuggestionNodeUnreachable = "Verifique su conexión e intente de nuevo." + issueErrorSuggestionTimeout = "Verifique el estado del activo en el explorador." + issueErrorSuggestionFeeEstimation = "Intente más tarde." + issueErrorSuggestionIpfs = "Verifique la configuración IPFS y reintente." + issueErrorSuggestionIpfsAuth = "Vaya a Configuración y actualice sus credenciales IPFS." + issueErrorSuggestionInvalidAddress = "Corrija la dirección e intente de nuevo." + // Phase 40: Multi-step progress + stepIpfsUpload = "Subiendo a IPFS..."; stepBalanceCheck = "Verificando saldo..." + stepNameCheck = "Verificando nombre..."; stepIssuing = "Emitiendo en Ravencoin..." + stepNfcProgramming = "Programando chip NFC..."; stepConfirming = "Confirmando..."; stepComplete = "Completado" + // Phase 40: Confirmation + confirmPending = "Pendiente..."; confirmProgress = "%1\$d/6 confirmaciones"; confirmComplete = "Confirmado" + // Phase 40: Balance warnings + balanceWarningRoot = "Saldo insuficiente. Su monedero tiene %1 RVN. Requiere ~500 RVN (comisión de quema) + ~0.01 RVN (comisión de red). Envíe RVN a este monedero y reintente." + balanceWarningSub = "Saldo insuficiente. Su monedero tiene %1 RVN. Requiere ~100 RVN (comisión de quema) + ~0.01 RVN (comisión de red). Envíe RVN a este monedero y reintente." + balanceWarningUnique = "Saldo insuficiente. Su monedero tiene %1 RVN. Requiere ~5 RVN (comisión de quema) + ~0.01 RVN (comisión de red). Envíe RVN a este monedero y reintente." + // Phase 40: Revoke result + revokeSuccess = "Activo revocado"; revokeFailed = "Revocación fallida" + // Phase 30-06: mnemonic safety + mnemonicBiometricCoverTitle = "Autenticarse para revelar la frase" + mnemonicBiometricCoverBody = "Use su huella, rostro o PIN para mostrar la frase de recuperación. Cualquiera que la vea puede robar sus fondos." + mnemonicRevealCta = "Revelar frase"; mnemonicCopyAll = "Copiar todo" + mnemonicSavedIt = "La he guardado"; authCanceledSnackbar = "Autenticación cancelada" + mnemonicRevealFailed = "No se pudo revelar la frase. Intente de nuevo." + deviceSecurityChangedTitle = "Seguridad del dispositivo cambiada" + deviceSecurityChangedBody = "La seguridad del dispositivo ha cambiado. Restaure su monedero con la frase de recuperación para continuar." + deviceSecurityChangedCta = "Restaurar con frase de recuperación" + restoreReplaceWalletTitle = "¿Reemplazar monedero actual?" + restoreReplaceWalletBody = "Esto reemplazará su monedero actual (%1\$s RVN, %2\$s activos). Primero debe respaldar la frase de recuperación. Esta acción no se puede deshacer." + restoreBackupFirstBody = "Primero respalde su frase de recuperación. No puede deshacer esto." + restoreReplaceCta = "Reemplazar monedero"; restoreBackupFirstCta = "Respaldar frase primero" + restoreInvalidPhrase = "Frase de recuperación inválida. Verifique la ortografía y el orden de las palabras." + cancel = "Cancelar" + // Phase 30-08: Connection + cachedStateBanner = "Mostrando estado en caché · Última actualización %1\$s" + cachedStateReconnecting = "Última actualización %1\$s · reconectando…" + pendingBalanceLabel = "Pendiente" + batterySaverChip = "Ahorro de batería · actualización manual" + batterySaverChipDesc = "Modo ahorro de batería activo" + connectionPillOnline = "En línea"; connectionPillReconnecting = "Reconectando…" + connectionPillOffline = "Fuera de línea" + connectionPillSheetTitle = "Red Ravencoin" + connectionPillCurrentNode = "Nodo actual" + connectionPillLastSuccess = "Último RPC exitoso" + connectionPillFallbackNodes = "Nodos de respaldo" + connectionPillQuarantined = "En cuarentena hasta %1\$s" + connectionPillClose = "Cerrar"; connectionPillNoNode = "(ninguno)" + connectionStatusDotDesc = "Estado de la conexión" + reconnectingToast = "Reconectando a la red Ravencoin…" + offlineAllNodesUnreachable = "Fuera de línea · todos los nodos inaccesibles" + incomingTxSnackbar = "+%1\$s RVN recibido" + receiveCurrentAddressLabel = "Su dirección actual" + receiveCurrentAddressSubLabel = "Cambia después de su próximo envío o consolidación." + walletOfflineHeading = "Monedero fuera de línea" + walletOfflineBody = "No se puede alcanzar ningún nodo Ravencoin. Verifique su conexión a internet y toque Actualizar." + // Phase 30-09: tx history + txHistorySentPrefix = "Enviado"; txHistoryCycledPrefix = "Reciclado" + walletCycledMultiAsset = "%1\$d activos reciclados" + walletCycledAssetsTitle = "Activos reciclados" + txHistoryFeePrefix = "Comisión"; txHistoryLoadMore = "Cargar más" + txHistoryEmptyHeading = "Sin transacciones" + txHistoryEmptyBody = "Su primera transacción enviada o recibida aparecerá aquí." + txDetailsViewOnExplorer = "Ver en el explorador" + txHistoryConfirmations = "%1\$d/6 confirmaciones" + // Phase 30-10: accessibility + biometricCoverDesc = "Pantalla de autenticación biométrica" + revealMnemonicButtonDesc = "Revelar frase de recuperación" + // Fee + sendFeeLabel = "Comisión"; sendFeeEditLabel = "Editar comisión" + sendFeeOverrideHint = "Comisión personalizada (RVN/kB)" + sendFeeTarget = "~6 bloques" + sendFeeEstimateUnavailable = "Estimación de comisión no disponible. Usando 0.01 RVN/kB por defecto." } /** @@ -1490,7 +2095,7 @@ val stringsZh = cloneStrings(stringsEn).apply { walletNoWallet = "未找到钱包"; walletNoWalletDesc = "请创建新钱包或恢复已有钱包,以管理 Ravencoin 资产。" walletGenerate = "创建新钱包"; walletRestore = "通过助记词恢复"; walletMnemonicPlaceholder = "输入 12 个单词助记词…"; walletRestoreBtn = "恢复钱包" mnemonicSpaceError = "不允许输入空格,请每个输入框只填写一个单词" - walletBalance = "Ravencoin 余额"; walletLoading = "加载中…"; walletReceiveAddr = "接收地址" + walletScanningBlockchain = "正在扫描区块链以查找钱包地址…"; walletBalance = "Ravencoin 余额"; walletLoading = "加载中…"; walletReceiveAddr = "接收地址" walletRecoveryPhrase = "恢复短语"; walletNeverShare = "切勿分享恢复短语。任何拿到它的人都可以访问你的资产。"; walletTapReveal = "点击眼睛图标以显示恢复短语。" walletAssetOps = "资产操作"; walletIssueRoot = "发行主资产"; walletIssueRootDesc = "创建新的父级资产(需要 500 RVN)"; walletIssueSub = "发行子资产"; walletIssueSubDesc = "创建 PARENT/CHILD 资产"; walletRevoke = "撤销资产"; walletRevokeDesc = "将资产标记为仿冒(后端撤销)" walletDeleteTitle = "删除钱包"; walletDeleteMsg = "这将从应用中永久删除你的钱包。请确认你已经保存了恢复短语。此操作无法撤销。"; walletDeleteBtn = "删除"; walletCancelBtn = "取消" @@ -1540,6 +2145,92 @@ val stringsZh = cloneStrings(stringsEn).apply { walletControlKeyInvalid = "密钥未被识别。请输入有效的管理员或操作员密钥。" walletRoleAdmin = "管理员"; walletRoleOperator = "操作员" onboardingBadgeConsumer = "开源 · Ravencoin"; onboardingTitleConsumer = "验证您的产品真伪"; onboardingDescConsumer = "将手机靠近制造商嵌入产品的 NFC 芯片,即可立即确认产品是否为正品。"; featureNtagConsumer = "无法伪造"; featureNtagDescConsumer = "嵌入产品的芯片每次扫描都会生成唯一签名,无法被克隆或复制。"; featureSovConsumer = "无中间商"; featureSovDescConsumer = "每个品牌自行管理认证体系,无中央机构,无中间商。" + // Phase 40: Error classification + issueErrorInsufficientFunds = "余额不足。请向品牌钱包发送 RVN 后重试。" + issueErrorDuplicateName = "资产名称已存在。请选择其他名称。" + issueErrorNodeUnreachable = "RPC 节点不可达。请检查网络连接后重试。" + issueErrorTimeout = "请求超时。交易可能已被广播,请检查您的钱包。" + issueErrorFeeEstimation = "手续费估算失败。网络可能正在拥堵。" + issueErrorIpfsAuth = "IPFS 认证已过期。请在设置中更新 Pinata JWT。" + issueErrorIpfsFailed = "IPFS 上传失败。请检查连接后重试。" + issueErrorInvalidAddress = "Ravencoin 地址格式无效。" + issueErrorNoWallet = "未找到 Ravencoin 钱包。请先创建或恢复钱包。" + issueErrorSuggestionInsufficientFunds = "向品牌钱包发送 RVN 后重试。" + issueErrorSuggestionDuplicate = "更改资产名称后重试。" + issueErrorSuggestionNodeUnreachable = "检查网络连接后重试。" + issueErrorSuggestionTimeout = "在浏览器中查看资产状态。" + issueErrorSuggestionFeeEstimation = "稍后重试。" + issueErrorSuggestionIpfs = "检查 IPFS 设置后重试。" + issueErrorSuggestionIpfsAuth = "前往设置更新 IPFS 凭证。" + issueErrorSuggestionInvalidAddress = "修正地址后重试。" + // Phase 40: Multi-step progress + stepIpfsUpload = "正在上传到 IPFS..."; stepBalanceCheck = "正在检查余额..." + stepNameCheck = "正在检查名称可用性..."; stepIssuing = "正在 Ravencoin 上发行..." + stepNfcProgramming = "正在编程 NFC 标签..."; stepConfirming = "正在确认..."; stepComplete = "完成" + // Phase 40: Confirmation + confirmPending = "待处理..."; confirmProgress = "%1\$d/6 确认"; confirmComplete = "已确认" + // Phase 40: Balance warnings + balanceWarningRoot = "余额不足。您的钱包有 %1 RVN。需要约 500 RVN(销毁费)+ 约 0.01 RVN(网络费)。请向此钱包发送 RVN 后重试。" + balanceWarningSub = "余额不足。您的钱包有 %1 RVN。需要约 100 RVN(销毁费)+ 约 0.01 RVN(网络费)。请向此钱包发送 RVN 后重试。" + balanceWarningUnique = "余额不足。您的钱包有 %1 RVN。需要约 5 RVN(销毁费)+ 约 0.01 RVN(网络费)。请向此钱包发送 RVN 后重试。" + revokeSuccess = "资产已撤销"; revokeFailed = "撤销失败" + // Phase 30-06: mnemonic safety + mnemonicBiometricCoverTitle = "验证身份以显示助记词" + mnemonicBiometricCoverBody = "使用指纹、面容或 PIN 显示恢复短语。任何人看到它都可能盗取您的资金。" + mnemonicRevealCta = "显示助记词"; mnemonicCopyAll = "全部复制" + mnemonicSavedIt = "我已保存"; authCanceledSnackbar = "认证已取消" + mnemonicRevealFailed = "无法显示助记词。请重试。" + deviceSecurityChangedTitle = "设备安全已更改" + deviceSecurityChangedBody = "设备安全设置已更改。请使用恢复短语恢复钱包以继续。" + deviceSecurityChangedCta = "通过恢复短语恢复" + restoreReplaceWalletTitle = "替换当前钱包?" + restoreReplaceWalletBody = "这将替换您当前的钱包(%1\$s RVN,%2\$s 资产)。您必须先备份恢复短语。此操作无法撤销。" + restoreBackupFirstBody = "请先备份您的恢复短语。此操作无法撤销。" + restoreReplaceCta = "替换钱包"; restoreBackupFirstCta = "先备份助记词" + restoreInvalidPhrase = "恢复短语无效。请检查拼写和单词顺序。" + cancel = "取消" + // Phase 30-08: Connection + cachedStateBanner = "显示缓存状态 · 上次更新 %1\$s" + cachedStateReconnecting = "上次更新 %1\$s · 正在重连…" + pendingBalanceLabel = "待处理" + batterySaverChip = "省电模式 · 手动刷新" + batterySaverChipDesc = "省电模式已激活" + connectionPillOnline = "在线"; connectionPillReconnecting = "正在重连…" + connectionPillOffline = "离线" + connectionPillSheetTitle = "Ravencoin 网络" + connectionPillCurrentNode = "当前节点" + connectionPillLastSuccess = "上次成功的 RPC" + connectionPillFallbackNodes = "备用节点" + connectionPillQuarantined = "隔离至 %1\$s" + connectionPillClose = "关闭"; connectionPillNoNode = "(无)" + connectionStatusDotDesc = "连接状态" + reconnectingToast = "正在重新连接 Ravencoin 网络…" + offlineAllNodesUnreachable = "离线 · 所有节点不可达" + incomingTxSnackbar = "已收到 +%1\$s RVN" + receiveCurrentAddressLabel = "您当前的地址" + receiveCurrentAddressSubLabel = "在您下次发送或合并后更改。" + walletOfflineHeading = "钱包离线" + walletOfflineBody = "无法连接到任何 Ravencoin 节点。请检查网络连接,然后点击刷新。" + txHistorySentPrefix = "已发送"; txHistoryCycledPrefix = "已循环" + walletCycledMultiAsset = "已循环 %1\$d 个资产" + walletCycledAssetsTitle = "已循环资产" + txHistoryFeePrefix = "手续费"; txHistoryLoadMore = "加载更多" + txHistoryEmptyHeading = "暂无交易" + txHistoryEmptyBody = "您的第一笔发送或接收的交易将显示在这里。" + txDetailsViewOnExplorer = "在浏览器中查看" + txHistoryConfirmations = "%1\$d/6 确认" + biometricCoverDesc = "生物识别认证界面" + revealMnemonicButtonDesc = "显示恢复短语" + sendFeeLabel = "手续费"; sendFeeEditLabel = "编辑手续费" + sendFeeOverrideHint = "自定义手续费 (RVN/kB)" + sendFeeTarget = "约 6 个区块" + sendFeeEstimateUnavailable = "手续费估算不可用。正在使用 0.01 RVN/kB 作为后备。" + walletSendError = "发送失败:%1"; walletSendFailed = "发送失败" + walletSendResult = "已发送 %1 RVN(手续费:%2 RVN)· tx:%3..." + walletTransferError = "转账失败:%1"; walletTransferFailed = "转账失败" + walletTransferResult = "已转账 %1 · tx:%2..." + // also fixing scanQr + scanQr = "扫描二维码" } /** @@ -1559,7 +2250,7 @@ val stringsJa = cloneStrings(stringsEn).apply { nfcNotSupported = "NFC 非対応"; nfcNotSupportedDesc = "この端末には NFC ハードウェアがありません"; nfcDisabled = "NFC が無効です"; nfcDisabledDesc = "タグをスキャンするにはシステム設定で NFC を有効にしてください" howItWorks = "仕組み"; howStep1 = "製品の NFC チップにスマートフォンをかざします"; howStep2 = "チップの固有署名が検証されます"; howStep3 = "結果がブロックチェーンで確認されます" verifyingTitle = "検証中…"; verifyAuthentic = "正規品"; verifyNotAuthentic = "非正規"; verifyRevoked = "失効済み"; verifyUnableToVerify = "確認できません"; verifyCounterReplay = "カウンターの再利用を検出しました。クローンの可能性があります。"; verifyNfcSig = "NFC 署名を検証中"; verifyBlockchain = "Ravencoin ブロックチェーンを確認中"; verifyAssetInfo = "製品情報"; verifyAsset = "製品"; verifyDescription = "説明"; verifyWebsite = "Web サイト"; verifySecDetails = "認証詳細"; verifyTagUid = "タグ UID"; verifyScanCount = "スキャン回数"; verifyNfcPubId = "NFC 公開 ID"; verifyCrypto = "暗号"; verifyRevokedBy = "ブランドが報告済み"; verifyScanAgain = "別の製品をスキャン" - walletTitle = "ブランドウォレット"; walletSubtitle = "Ravencoin 資産管理"; walletNoWallet = "ウォレットがありません"; walletNoWalletDesc = "Ravencoin 資産を管理するには、新しいウォレットを作成するか既存ウォレットを復元してください。"; walletGenerate = "新規ウォレット作成"; walletRestore = "ニーモニックから復元"; walletMnemonicPlaceholder = "12語のニーモニックを入力…"; walletRestoreBtn = "ウォレットを復元"; mnemonicSpaceError = "スペースは使えません。各入力欄に 1 単語ずつ入力してください"; walletBalance = "Ravencoin 残高"; walletLoading = "読み込み中…"; walletReceiveAddr = "受取アドレス"; walletRecoveryPhrase = "リカバリーフレーズ"; walletNeverShare = "リカバリーフレーズは絶対に共有しないでください。これを知る人は資産にアクセスできます。"; walletTapReveal = "目のアイコンをタップしてリカバリーフレーズを表示します。" + walletTitle = "ブランドウォレット"; walletSubtitle = "Ravencoin 資産管理"; walletNoWallet = "ウォレットがありません"; walletNoWalletDesc = "Ravencoin 資産を管理するには、新しいウォレットを作成するか既存ウォレットを復元してください。"; walletGenerate = "新規ウォレット作成"; walletRestore = "ニーモニックから復元"; walletMnemonicPlaceholder = "12語のニーモニックを入力…"; walletRestoreBtn = "ウォレットを復元"; mnemonicSpaceError = "スペースは使えません。各入力欄に 1 単語ずつ入力してください"; walletScanningBlockchain = "ウォレットアドレスをブロックチェーンで検索中…"; walletBalance = "Ravencoin 残高"; walletLoading = "読み込み中…"; walletReceiveAddr = "受取アドレス"; walletRecoveryPhrase = "リカバリーフレーズ"; walletNeverShare = "リカバリーフレーズは絶対に共有しないでください。これを知る人は資産にアクセスできます。"; walletTapReveal = "目のアイコンをタップしてリカバリーフレーズを表示します。" walletAssetOps = "資産操作"; walletIssueRoot = "ルート資産を発行"; walletIssueRootDesc = "新しい親資産を作成 (500 RVN 必要)"; walletIssueSub = "サブ資産を発行"; walletIssueSubDesc = "PARENT/CHILD 資産を作成"; walletRevoke = "資産を失効"; walletRevokeDesc = "資産を偽造品としてマーク(バックエンド失効)"; walletDeleteTitle = "ウォレット削除"; walletDeleteMsg = "この操作でアプリからウォレットが完全に削除されます。リカバリーフレーズを保存済みであることを確認してください。元に戻せません。"; walletDeleteBtn = "削除"; walletCancelBtn = "キャンセル" walletMyAssets = "保有アセット"; walletAssetsLoading = "資産を読み込み中…"; walletNoAssets = "このアドレスには資産がありません。"; walletAssetsNotVerifiable = "資産を読み込めませんでした。接続を確認し、再読込してください。"; electrumOnline = "ElectrumX · オンライン"; electrumOffline = "ElectrumX · オフライン"; electrumChecking = "ElectrumX · 確認中…"; walletRvnPrice = "RVN/USDT" serverNotResponding = "バックエンドサーバーが応答していません (%1)。現時点ではタグの真正性を確認できません。" @@ -1571,7 +2262,7 @@ val stringsJa = cloneStrings(stringsEn).apply { regChipTitle = "NFC チップを登録"; regChipSubtitle = "チップ UID を Ravencoin 資産にリンク"; regChipInfo = "サーバーは BRAND_SALT を使用して nfc_pub_id = SHA-256(uid ∥ salt) を計算します。生の UID は秘匿され、nfc_pub_id のみが IPFS メタデータに公開されます。"; regChipTagUid = "タグ UID"; regChipUidHint = "14 文字の 16 進数 = 7 バイト。例: 04A1B2C3D4E5F6"; regChipUidError = "14 文字の 16 進数 (7 バイト) である必要があります。現在:"; regChipBtn = "チップを登録" transferTitle = "トークンを転送"; transferSubtitle = "購入者ウォレットへ資産を送信 · 手数料 約 0.01 RVN"; fieldRecipient = "受取人アドレス"; fieldRecipientHint = "購入者の Ravencoin ウォレットアドレス"; fieldQtyHint = "ユニークトークンの数量は通常 1"; btnTransfer = "トークンを転送" writeTitle = "NFC タグを書き込む"; writeStep1Title = "タグをかざす"; writeStep1Hint = "UID を読み取るためにスマートフォンを NFC チップに近づけてください。"; writeStep1Label = "ステップ 1 / 3"; writeIssuingTitle = "Ravencoin 上で発行中"; writeIssuingHint = "IPFS にメタデータをアップロードし、サブ資産を作成しています…"; writeStep3Title = "もう一度タグをかざす"; writeStep3Hint = "同じタグに AES 鍵と SUN URL を書き込むため、もう一度スマートフォンを近づけてください。"; writeStep3Label = "ステップ 3 / 3"; writeSuccessTitle = "タグの書き込み完了!"; writeSuccessHint = "NFC チップの設定が完了しました。\n以下の鍵は安全に保存してください。他の場所には保存されません。"; writeSaveKeys = "これらの鍵を安全な保管庫に保存してください。失うとタグの失効や検証ができなくなります。"; writeErrorTitle = "エラー"; writeCloseBtn = "閉じる" - walletReceiveBtn = "受取"; walletSendBtn = "送信"; walletReceiveTitle = "RVN を受け取る"; walletReceiveDesc = "この QR コードをスキャンするか、下のアドレスをコピーして Ravencoin を受け取ってください。"; walletCopyDone = "アドレスをコピーしました!"; walletSendTitle = "RVN を送信"; walletSendAmountLabel = "数量 (RVN)"; walletSendAddrLabel = "送付先アドレス"; walletSendConfirm = "送信"; walletSendSuccess = "送信に成功しました!"; walletSendWarning = "この操作は取り消せません。アドレスを十分確認してください。"; walletSendFeeUnavailable = "ネットワーク手数料率を取得できません。全ノードに接続できないため、後でもう一度試してください。"; walletSendDialogTitle = "送信確認"; walletSendDialogMsg = "%2 に %1 RVN を送信しますか?" + walletReceiveBtn = "受取"; walletSendBtn = "送信"; walletReceiveTitle = "RVN を受け取る"; walletReceiveDesc = "この QR コードをスキャンするか、下のアドレスをコピーして Ravencoin を受け取ってください。"; walletCopyDone = "アドレスをコピーしました!"; walletSendTitle = "RVN を送信"; walletSendAmountLabel = "数量 (RVN)"; walletSendAddrLabel = "送付先アドレス"; walletSendConfirm = "送信"; walletSendSuccess = "送信に成功しました!"; walletSendFailed = "送信失敗"; walletTransferFailed = "転送失敗"; walletSendError = "送信失敗: %1"; walletTransferError = "転送失敗: %1"; walletSendWarning = "この操作は取り消せません。アドレスを十分確認してください。"; walletSendFeeUnavailable = "ネットワーク手数料率を取得できません。全ノードに接続できないため、後でもう一度試してください。"; walletSendDialogTitle = "送信確認"; walletSendDialogMsg = "%2 に %1 RVN を送信しますか?" walletFilterAll = "すべて"; brandProgramTag = "NFC タグを書き込む"; brandProgramTagDesc = "AES 鍵と SUN URL を NTAG 424 DNA チップに書き込みます。バックエンドにも自動登録されます。"; brandProgramTagAssetHint = "完全な資産名。例: FASHIONX/BAG01#SN0001"; brandProgramTagStart = "タグ書き込み開始"; brandNoWalletMsg = "Ravencoin ウォレットが見つかりません。続行するにはウォレットタブで作成または追加してください。"; brandGoToWallet = "ウォレットへ移動" settingsDonateBtn = "RavenTag に RVN を寄付"; settingsDonateTitle = "RavenTag に寄付"; settingsDonateDesc = "RavenTag オープンソースプロトコルの開発を支援します。"; settingsDonateMsg = "RavenTag はあらゆる規模のブランド向けに作られた、無料でオープンソースの NFC 認証プロトコルです。役立つと感じたら、継続的な開発、ドキュメント整備、新機能追加を支えるために少額ের RVN 寄付をご検討ください。どんな金額でも大きな助けになります。オープンソースへの支援に感謝します。"; brandNoFundsTitle = "残高不足"; brandNoFundsMsg = "ウォレットに RVN がありません。資産発行には入金が必要です。フォームの表示は継続できます。"; brandNoFundsContinue = "このまま続行" navSettings = "設定"; settingsTitle = "設定"; settingsBrandName = "ブランド名"; settingsBrandNameHint = "アプリに表示されるブランド名 (例: Fashionx)"; settingsVerifyUrl = "検証サーバー URL"; settingsVerifyUrlHint = "商品を発行したブランド host のバックエンド URL。スキャンとチップ書き込みに使用されます。"; settingsVerifyUrlConsumer = "ブランドサーバー URL"; settingsVerifyUrlHintConsumer = "確認したい製品のブランドが提供する URL を入力してください。製品パッケージまたはブランドのウェブサイトで確認できます。"; settingsSave = "保存"; settingsSaved = "保存しました!"; settingsAbout = "情報"; settingsVersion = "バージョン"; settingsRequireAuth = "起動時に認証を要求"; settingsRequireAuthDesc = "アプリ起動時に PIN または生体認証を要求します (ウォレットが必要)"; settingsRequireAuthRisk = "無効にすると安全性が低下します。端末にアクセスできる人なら誰でもアプリを開けます。"; settingsNoLockScreen = "ロック画面が設定されていません。認証はスキップされます。ウォレット保護のため、端末設定で PIN または指紋を設定してください。"; settingsAllowScreenshots = "スクリーンショットを許可"; settingsAllowScreenshotsDesc = "画面キャプチャ保護 (FLAG_SECURE) を無効にします。ウォレット鍵とニーモニックがサムネイルや録画に表示される可能性があります。"; settingsAllowScreenshotsWarning = "スクリーンショットが有効です: ウォレット鍵とニーモニックは画面キャプチャから保護されません。"; settingsAllowScreenshotsDialogTitle = "セキュリティ警告"; settingsAllowScreenshotsDialogBody = "スクリーンショットを許可すると FLAG_SECURE 保護が解除されます。ウォレット鍵や復元フレーズが画面録画、サムネイル、近くのカメラに記録される可能性があります。\n\n信頼できる個人端末でのみ有効にしてください。"; settingsAllowScreenshotsConfirm = "理解しました。スクリーンショットを有効にします"; settingsNotifications = "通知を有効にする"; settingsNotificationsDesc = "RVN またはアセットを受信したときに通知します。"; authTitle = "RavenTag"; authSubtitle = "ウォレットにアクセスするには認証してください" @@ -1588,6 +2279,92 @@ val stringsJa = cloneStrings(stringsEn).apply { walletControlKeyInvalid = "キーが認識されません。有効な管理者またはオペレーターキーを入力してください。" walletRoleAdmin = "管理者"; walletRoleOperator = "オペレーター" onboardingBadgeConsumer = "オープンソース · Ravencoin"; onboardingTitleConsumer = "製品の正規性を確認する"; onboardingDescConsumer = "メーカーが製品 in 埋め込んだ NFC チップにスマートフォンをかざして、本物かどうかをすぐに確認しましょう。"; featureNtagConsumer = "偽造不可能"; featureNtagDescConsumer = "製品に内蔵されたチップはスキャンのたびに固有の署名を生成します。複製することはできません。"; featureSovConsumer = "仲介者なし"; featureSovDescConsumer = "各ブランドが独自の認証基盤を管理します。中央機関も仲介者もありません。" + // Phase 40: Error classification + issueErrorInsufficientFunds = "残高不足です。ブランドウォレットに RVN を送金して再試行してください。" + issueErrorDuplicateName = "資産名は既に存在します。別の名前を選んでください。" + issueErrorNodeUnreachable = "RPC ノードに到達できません。インターネット接続を確認して再試行してください。" + issueErrorTimeout = "リクエストがタイムアウトしました。トランザクションは通知された可能性があります。ウォレットを確認してください。" + issueErrorFeeEstimation = "手数料の見積もりに失敗しました。ネットワークが混雑している可能性があります。" + issueErrorIpfsAuth = "IPFS 認証の有効期限が切れました。設定で Pinata JWT を更新してください。" + issueErrorIpfsFailed = "IPFS アップロードに失敗しました。接続を確認して再試行してください。" + issueErrorInvalidAddress = "Ravencoin アドレスの形式が無効です。" + issueErrorNoWallet = "Ravencoin ウォレットが見つかりません。最初にウォレットを作成または復元してください。" + issueErrorSuggestionInsufficientFunds = "ブランドウォレットに RVN を送金して再試行してください。" + issueErrorSuggestionDuplicate = "資産名を変更して再試行してください。" + issueErrorSuggestionNodeUnreachable = "接続を確認して再試行してください。" + issueErrorSuggestionTimeout = "エクスプローラーで資産ステータスを確認してください。" + issueErrorSuggestionFeeEstimation = "後でもう一度お試しください。" + issueErrorSuggestionIpfs = "IPFS 設定を確認して再試行してください。" + issueErrorSuggestionIpfsAuth = "設定に移動し、IPFS 資格情報を更新してください。" + issueErrorSuggestionInvalidAddress = "アドレスを修正して再試行してください。" + // Phase 40: Multi-step progress + stepIpfsUpload = "IPFS にアップロード中..."; stepBalanceCheck = "残高確認中..." + stepNameCheck = "名前の利用可能性確認中..."; stepIssuing = "Ravencoin で発行中..." + stepNfcProgramming = "NFC タグ書込中..."; stepConfirming = "確認中..."; stepComplete = "完了" + // Phase 40: Confirmation + confirmPending = "保留中..."; confirmProgress = "%1\$d/6 確認"; confirmComplete = "確認済み" + // Phase 40: Balance warnings + balanceWarningRoot = "残高不足です。ウォレットには %1 RVN あります。約 500 RVN(焼却手数料)+ 約 0.01 RVN(ネットワーク手数料)が必要です。このウォレットに RVN を送金して再試行してください。" + balanceWarningSub = "残高不足です。ウォレットには %1 RVN あります。約 100 RVN(焼却手数料)+ 約 0.01 RVN(ネットワーク手数料)が必要です。このウォレットに RVN を送金して再試行してください。" + balanceWarningUnique = "残高不足です。ウォレットには %1 RVN あります。約 5 RVN(焼却手数料)+ 約 0.01 RVN(ネットワーク手数料)が必要です。このウォレットに RVN を送金して再試行してください。" + revokeSuccess = "資産を失効しました"; revokeFailed = "失効に失敗しました" + // Phase 30-06: mnemonic safety + mnemonicBiometricCoverTitle = "認証してフレーズを表示" + mnemonicBiometricCoverBody = "指紋、顔認証、または PIN を使用してリカバリーフレーズを表示します。これを見た人はあなたの資金を盗むことができます。" + mnemonicRevealCta = "フレーズを表示"; mnemonicCopyAll = "すべてコピー" + mnemonicSavedIt = "保存しました"; authCanceledSnackbar = "認証がキャンセルされました" + mnemonicRevealFailed = "フレーズを表示できませんでした。再試行してください。" + deviceSecurityChangedTitle = "デバイスのセキュリティが変更されました" + deviceSecurityChangedBody = "デバイスのセキュリティが変更されました。リカバリーフレーズを使用してウォレットを復元し、続行してください。" + deviceSecurityChangedCta = "リカバリーフレーズから復元" + restoreReplaceWalletTitle = "現在のウォレットを置き換えますか?" + restoreReplaceWalletBody = "現在のウォレット(%1\$s RVN、%2\$s 資産)を置き換えます。最初にリカバリーフレーズをバックアップする必要があります。この操作は元に戻せません。" + restoreBackupFirstBody = "最初にリカバリーフレーズをバックアップしてください。この操作は元に戻せません。" + restoreReplaceCta = "ウォレットを置き換え"; restoreBackupFirstCta = "最初にフレーズをバックアップ" + restoreInvalidPhrase = "リカバリーフレーズが無効です。スペルと単語の順序を確認してください。" + cancel = "キャンセル"; scanQr = "QR をスキャン" + // Phase 30-08: Connection + cachedStateBanner = "キャッシュ状態表示 · 最終更新 %1\$s" + cachedStateReconnecting = "最終更新 %1\$s · 再接続中…" + pendingBalanceLabel = "保留中" + batterySaverChip = "バッテリー節約 · 手動更新" + batterySaverChipDesc = "バッテリー節約モード有効" + connectionPillOnline = "オンライン"; connectionPillReconnecting = "再接続中…" + connectionPillOffline = "オフライン" + connectionPillSheetTitle = "Ravencoin ネットワーク" + connectionPillCurrentNode = "現在のノード" + connectionPillLastSuccess = "最後の成功 RPC" + connectionPillFallbackNodes = "フォールバックノード" + connectionPillQuarantined = "%1\$s まで隔離中" + connectionPillClose = "閉じる"; connectionPillNoNode = "(なし)" + connectionStatusDotDesc = "接続状態" + reconnectingToast = "Ravencoin ネットワークに再接続中…" + offlineAllNodesUnreachable = "オフライン · 全ノード到達不可" + incomingTxSnackbar = "+%1\$s RVN 受信" + receiveCurrentAddressLabel = "現在のアドレス" + receiveCurrentAddressSubLabel = "次の送信または統合後に変更されます。" + walletOfflineHeading = "ウォレットオフライン" + walletOfflineBody = "Ravencoin ノードに到達できません。インターネット接続を確認し、更新をタップしてください。" + txHistorySentPrefix = "送信"; txHistoryCycledPrefix = "循環" + walletCycledMultiAsset = "循環資産 %1\$d件" + walletCycledAssetsTitle = "循環資産" + txHistoryFeePrefix = "手数料"; txHistoryLoadMore = "もっと読み込む" + txHistoryEmptyHeading = "まだ取引がありません" + txHistoryEmptyBody = "最初の送信または受信取引がここに表示されます。" + txDetailsViewOnExplorer = "エクスプローラーで表示" + txHistoryConfirmations = "%1\$d/6 確認" + biometricCoverDesc = "生体認証カバー" + revealMnemonicButtonDesc = "リカバリーフレーズを表示" + sendFeeLabel = "手数料"; sendFeeEditLabel = "手数料を編集" + sendFeeOverrideHint = "カスタム手数料 (RVN/kB)" + sendFeeTarget = "約6ブロック" + sendFeeEstimateUnavailable = "手数料の見積もりが利用できません。デフォルト値 0.01 RVN/kB を使用します。" + fieldQtyLabel = "数量" + walletSendError = "送信失敗: %1"; walletSendFailed = "送信失敗" + walletSendResult = "%1 RVN 送信完了 (手数料: %2 RVN) · tx: %3..." + walletTransferError = "転送失敗: %1"; walletTransferFailed = "転送失敗" + walletTransferResult = "%1 転送完了 · tx: %2..." + walletShowOwnerTokens = "所有者トークンを表示" } /** @@ -1607,7 +2384,7 @@ val stringsKo = cloneStrings(stringsEn).apply { nfcNotSupported = "NFC 미지원"; nfcNotSupportedDesc = "이 기기에는 NFC 하드웨어가 없습니다"; nfcDisabled = "NFC 비활성화됨"; nfcDisabledDesc = "태그를 스캔하려면 시스템 설정에서 NFC를 활성화하세요" howItWorks = "작동 방식"; howStep1 = "제품의 NFC 칩에 스마트폰을 가져다 댑니다"; howStep2 = "칩의 고유 서명이 검증됩니다"; howStep3 = "결과가 블록체인에서 확인됩니다" verifyingTitle = "검증 중…"; verifyAuthentic = "정품"; verifyNotAuthentic = "비정품"; verifyRevoked = "폐기됨"; verifyUnableToVerify = "확인할 수 없음"; verifyCounterReplay = "카운터 재사용이 감지되었습니다. 복제 시도일 수 있습니다."; verifyNfcSig = "NFC 서명 검증 중"; verifyBlockchain = "Ravencoin 블록체인 확인 중"; verifyAssetInfo = "제품 정보"; verifyAsset = "제품"; verifyDescription = "설명"; verifyWebsite = "웹사이트"; verifySecDetails = "인증 세부 정보"; verifyTagUid = "태그 UID"; verifyScanCount = "스캔 횟수"; verifyNfcPubId = "NFC 공개 ID"; verifyCrypto = "암호"; verifyRevokedBy = "브랜드에서 신고됨"; verifyScanAgain = "다른 제품 스캔" - walletTitle = "브랜드 지갑"; walletSubtitle = "Ravencoin 자산 관리"; walletNoWallet = "지갑 없음"; walletNoWalletDesc = "Ravencoin 자산을 관리하려면 새 지갑을 생성하거나 기존 지갑을 복구하세요."; walletGenerate = "새 지갑 생성"; walletRestore = "니모닉으로 복구"; walletMnemonicPlaceholder = "12단어 니모닉 입력…"; walletRestoreBtn = "지갑 복구"; mnemonicSpaceError = "공백은 허용되지 않습니다. 각 칸에 한 단어씩 입력하세요"; walletBalance = "Ravencoin 잔액"; walletLoading = "로딩 중…"; walletReceiveAddr = "수신 주소"; walletRecoveryPhrase = "복구 구문"; walletNeverShare = "복구 구문은 절대 공유하지 마세요. 이를 가진 사람은 자금에 접근할 수 있습니다."; walletTapReveal = "눈 아이콘을 눌러 복구 구문을 표시합니다." + walletTitle = "브랜드 지갑"; walletSubtitle = "Ravencoin 자산 관리"; walletNoWallet = "지갑 없음"; walletNoWalletDesc = "Ravencoin 자산을 관리하려면 새 지갑을 생성하거나 기존 지갑을 복구하세요."; walletGenerate = "새 지갑 생성"; walletRestore = "니모닉으로 복구"; walletMnemonicPlaceholder = "12단어 니모닉 입력…"; walletRestoreBtn = "지갑 복구"; mnemonicSpaceError = "공백은 허용되지 않습니다. 각 칸에 한 단어씩 입력하세요"; walletScanningBlockchain = "블록체인에서 지갑 주소 검색 중…"; walletBalance = "Ravencoin 잔액"; walletLoading = "로딩 중…"; walletReceiveAddr = "수신 주소"; walletRecoveryPhrase = "복구 구문"; walletNeverShare = "복구 구문은 절대 공유하지 마세요. 이를 가진 사람은 자금에 접근할 수 있습니다."; walletTapReveal = "눈 아이콘을 눌러 복구 구문을 표시합니다." walletAssetOps = "자산 작업"; walletIssueRoot = "루트 자산 발행"; walletIssueRootDesc = "새 부모 자산 생성 (500 RVN 필요)"; walletIssueSub = "서브 자산 발행"; walletIssueSubDesc = "PARENT/CHILD 자산 생성"; walletRevoke = "자산 폐기"; walletRevokeDesc = "자산을 위조로 표시 (백엔드 폐기)"; walletDeleteTitle = "지갑 삭제"; walletDeleteMsg = "이 작업은 앱에서 지갑을 영구히 삭제합니다. 복구 구문을 저장했는지 확인하세요. 되돌릴 수 없습니다."; walletDeleteBtn = "삭제"; walletCancelBtn = "취소"; walletMyAssets = "내 자산"; walletAssetsLoading = "자산 로딩 중…"; walletNoAssets = "이 주소에 자산이 없습니다."; walletAssetsNotVerifiable = "자산을 불러올 수 없습니다. 연결을 확인하고 새로고침하세요."; electrumOnline = "ElectrumX · 온라인"; electrumOffline = "ElectrumX · 오프라인"; electrumChecking = "ElectrumX · 확인 중…"; walletRvnPrice = "RVN/USDT" serverNotResponding = "백엔드 서버가 응답하지 않습니다 (%1). 현재 태그의 정품 여부를 확인할 수 없습니다." settingsServerOnline = "서버 · 온라인"; settingsServerOffline = "서버 · 오프라인"; settingsServerChecking = "서버 · 확인 중…"; settingsAdminKeyValid = "키 확인됨"; settingsAdminKeyInvalid = "키가 유효하지 않음"; settingsAdminKeyChecking = "확인 중…"; settingsAdminKeyLocked = "먼저 서버 URL을 저장하세요"; settingsAdminKeyWrongType = "이 키는 관리자 키가 아니라 운영자 키입니다"; operatorKey = "운영자 키"; operatorKeyHint = "선택 사항: NFC 태그 프로그래밍, 고유 토큰 발행, 토큰 전송이 가능한 제한 키입니다. 관리자가 설정합니다."; settingsOperatorKeyValid = "키 확인됨"; settingsOperatorKeyInvalid = "키가 유효하지 않음"; settingsOperatorKeyChecking = "확인 중…"; settingsOperatorKeyLocked = "먼저 서버 URL을 저장하세요"; settingsOperatorKeyWrongType = "이 키는 운영자 키가 아니라 관리자 키입니다" @@ -1618,7 +2395,7 @@ val stringsKo = cloneStrings(stringsEn).apply { regChipTitle = "NFC 칩 등록"; regChipSubtitle = "칩 UID를 Ravencoin 자산에 연결"; regChipInfo = "서버는 BRAND_SALT로 nfc_pub_id = SHA-256(uid ∥ salt)를 계산합니다. 원시 UID는 비공개로 유지되며 nfc_pub_id만 IPFS 메타데이터에 게시됩니다."; regChipTagUid = "태그 UID"; regChipUidHint = "14자리 16진수 = 7바이트, 예: 04A1B2C3D4E5F6"; regChipUidError = "정확히 14자리 16진수(7바이트)여야 합니다. 현재:"; regChipBtn = "칩 등록" transferTitle = "토큰 전송"; transferSubtitle = "자산을 구매자 지갑으로 전송 · 수수료 약 0.01 RVN"; fieldRecipient = "수신자 주소"; fieldRecipientHint = "구매자의 Ravencoin 지갑 주소"; fieldQtyHint = "고유 토큰 수량은 일반적으로 1"; btnTransfer = "토큰 전송" writeTitle = "NFC 태그 프로그래밍"; writeStep1Title = "태그를 대세요"; writeStep1Hint = "UID를 읽기 위해 휴대폰을 NFC 칩에 가까이 대세요."; writeStep1Label = "3단계 중 1단계"; writeIssuingTitle = "Ravencoin에서 발행 중"; writeIssuingHint = "메타데이터를 IPFS에 업로드하고 서브 자산을 생성하는 중…"; writeStep3Title = "태그를 다시 대세요"; writeStep3Hint = "AES 키와 SUN URL을 기록하기 위해 같은 태그에 휴대폰을 다시 대세요."; writeStep3Label = "3단계 중 3단계"; writeSuccessTitle = "태그가 프로그래밍되었습니다!"; writeSuccessHint = "NFC 칩이 성공적으로 구성되었습니다.\n아래 키를 안전하게 보관하세요. 다른 곳에는 저장되지 않습니다."; writeSaveKeys = "이 키들을 안전한 저장소에 보관하세요. 없으면 태그를 폐기하거나 검증할 수 없습니다."; writeErrorTitle = "오류"; writeCloseBtn = "닫기" - walletReceiveBtn = "받기"; walletSendBtn = "보내기"; walletReceiveTitle = "RVN 받기"; walletReceiveDesc = "이 QR 코드를 스캔하거나 아래 주소를 복사해 Ravencoin을 받으세요."; walletCopyDone = "주소가 복사되었습니다!"; walletSendTitle = "RVN 보내기"; walletSendAmountLabel = "금액 (RVN)"; walletSendAddrLabel = "받는 주소"; walletSendConfirm = "보내기"; walletSendSuccess = "성공적으로 전송되었습니다!"; walletSendWarning = "이 작업은 되돌릴 수 없습니다. 주소를 신중히 확인하세요."; walletSendFeeUnavailable = "네트워크 수수료율을 사용할 수 없습니다. 모든 노드에 연결할 수 없으니 나중에 다시 시도하세요."; walletSendDialogTitle = "전송 확인"; walletSendDialogMsg = "%2 주소로 %1 RVN을 보내시겠습니까?" + walletReceiveBtn = "받기"; walletSendBtn = "보내기"; walletReceiveTitle = "RVN 받기"; walletReceiveDesc = "이 QR 코드를 스캔하거나 아래 주소를 복사해 Ravencoin을 받으세요."; walletCopyDone = "주소가 복사되었습니다!"; walletSendTitle = "RVN 보내기"; walletSendAmountLabel = "금액 (RVN)"; walletSendAddrLabel = "받는 주소"; walletSendConfirm = "보내기"; walletSendSuccess = "성공적으로 전송되었습니다!"; walletSendFailed = "전송 실패"; walletTransferFailed = "이전 실패"; walletSendError = "전송 실패: %1"; walletTransferError = "이전 실패: %1"; walletSendWarning = "이 작업은 되돌릴 수 없습니다. 주소를 신중히 확인하세요."; walletSendFeeUnavailable = "네트워크 수수료율을 사용할 수 없습니다. 모든 노드에 연결할 수 없으니 나중에 다시 시도하세요."; walletSendDialogTitle = "전송 확인"; walletSendDialogMsg = "%2 주소로 %1 RVN을 보내시겠습니까?" walletFilterAll = "전체"; brandProgramTag = "NFC 태그 프로그래밍"; brandProgramTagDesc = "AES 키와 SUN URL을 NTAG 424 DNA 칩에 기록합니다. 백엔드에도 자동 등록됩니다."; brandProgramTagAssetHint = "전체 자산 이름, 예: FASHIONX/BAG01#SN0001"; brandProgramTagStart = "태그 프로그래밍 시작"; brandNoWalletMsg = "Ravencoin 지갑이 없습니다. 계속하려면 지갑 탭에서 지갑을 생성하거나 추가하세요."; brandGoToWallet = "지갑으로 이동" settingsDonateBtn = "RavenTag에 RVN 기부"; settingsDonateTitle = "RavenTag에 기부"; settingsDonateDesc = "RavenTag 오픈소스 프로토콜 개발을 지원하세요."; settingsDonateMsg = "RavenTag는 모든 규모의 브랜드를 위해 만들어진 무료 오픈소스 NFC 인증 프로토콜입니다. 유용하다면 지속적인 개발, 문서화, 신규 기능을 지원하기 위해 소액의 RVN 기부를 고려해 주세요. 작은 기여도 큰 도움이 됩니다. 오픈소스를 지원해 주셔서 감사합니다."; brandNoFundsTitle = "잔액 부족"; brandNoFundsMsg = "지갑에 RVN이 없습니다. 자산을 발행하려면 먼저 입금하세요. 그래도 양식은 계속 볼 수 있습니다."; brandNoFundsContinue = "계속 진행" navSettings = "설정"; settingsTitle = "설정"; settingsBrandName = "브랜드 이름"; settingsBrandNameHint = "앱에 표시될 브랜드 이름 (예: Fashionx)"; settingsVerifyUrl = "검증 서버 URL"; settingsVerifyUrlHint = "제품을 발행한 브랜드의 백엔드 URL입니다. 스캔과 칩 프로그래밍에 사용됩니다."; settingsVerifyUrlConsumer = "브랜드 서버 URL"; settingsVerifyUrlHintConsumer = "확인하려는 제품의 브랜드가 제공한 URL을 입력하세요. 제품 포장이나 브랜드 웹사이트에서 확인할 수 있습니다."; settingsSave = "저장"; settingsSaved = "저장됨!"; settingsAbout = "정보"; settingsVersion = "버전"; settingsRequireAuth = "시작 시 인증 요구"; settingsRequireAuthDesc = "앱을 열 때 PIN 또는 생체 인증을 요구합니다 (활성 지갑 필요)"; settingsRequireAuthRisk = "비활성화하면 보안이 약해집니다. 기기에 접근할 수 있는 누구나 앱을 열 수 있습니다."; settingsNoLockScreen = "잠금 화면이 설정되지 않았습니다. 인증이 건너뛰어집니다. 지갑을 보호하려면 기기 설정에서 PIN 또는 지문을 설정하세요."; settingsAllowScreenshots = "스크린샷 허용"; settingsAllowScreenshotsDesc = "화면 캡처 차단(FLAG_SECURE)을 끕니다. 지갑 키와 니모닉이 미리보기나 화면 녹화에 나타날 수 있습니다."; settingsAllowScreenshotsWarning = "스크린샷이 활성화되었습니다: 지갑 키와 니모닉이 화면 캡처로부터 보호되지 않습니다."; settingsAllowScreenshotsDialogTitle = "보안 경고"; settingsAllowScreenshotsDialogBody = "스크린샷을 허용하면 FLAG_SECURE 보호가 제거됩니다. 지갑 키와 복구 문구가 화면 녹화, 미리보기, 주변 카메라에 캡처될 수 있습니다.\n\n신뢰할 수 있는 개인 기기에서만 활성화하세요."; settingsAllowScreenshotsConfirm = "이해했습니다. 스크린샷을 허용합니다"; settingsNotifications = "알림 활성화"; settingsNotificationsDesc = "RVN 또는 자산 수신 시 알림을 표시합니다."; authTitle = "RavenTag"; authSubtitle = "지갑에 접근하려면 인증하세요" @@ -1634,6 +2411,92 @@ val stringsKo = cloneStrings(stringsEn).apply { walletControlKeyInvalid = "키를 인식할 수 없습니다. 유효한 관리자 또는 운영자 키를 입력하세요." walletRoleAdmin = "관리자"; walletRoleOperator = "운영자" onboardingBadgeConsumer = "오픈소스 · Ravencoin"; onboardingTitleConsumer = "제품의 정품 여부를 확인하세요"; onboardingDescConsumer = "제조사가 제품에 내장한 NFC 칩에 스마트폰을 가져다 대면 즉시 정품 여부를 확인할 수 있습니다."; featureNtagConsumer = "위조 불가"; featureNtagDescConsumer = "제품에 내장된 칩은 스캔할 때마다 고유한 서명을 생성합니다. 복제하거나 재현할 수 없습니다."; featureSovConsumer = "중간자 없음"; featureSovDescConsumer = "각 브랜드가 자체 인증 인프라를 관리합니다. 중앙 기관도 중간자도 없습니다." + // Phase 40: Error classification + issueErrorInsufficientFunds = "잔액 부족. 브랜드 지갑에 RVN을 보내고 다시 시도하세요." + issueErrorDuplicateName = "자산 이름이 이미 존재합니다. 다른 이름을 선택하세요." + issueErrorNodeUnreachable = "RPC 노드에 연결할 수 없습니다. 인터넷 연결을 확인하고 다시 시도하세요." + issueErrorTimeout = "요청 시간이 초과되었습니다. 트랜잭션이 이미 브로드캐스트되었을 수 있습니다. 지갑을 확인하세요." + issueErrorFeeEstimation = "수수료 추정 실패. 네트워크가 혼잡할 수 있습니다." + issueErrorIpfsAuth = "IPFS 인증이 만료되었습니다. 설정에서 Pinata JWT를 업데이트하세요." + issueErrorIpfsFailed = "IPFS 업로드 실패. 연결을 확인하고 다시 시도하세요." + issueErrorInvalidAddress = "유효하지 않은 Ravencoin 주소 형식입니다." + issueErrorNoWallet = "Ravencoin 지갑이 없습니다. 먼저 지갑을 생성하거나 복구하세요." + issueErrorSuggestionInsufficientFunds = "브랜드 지갑에 RVN을 보내고 다시 시도하세요." + issueErrorSuggestionDuplicate = "자산 이름을 변경하고 다시 시도하세요." + issueErrorSuggestionNodeUnreachable = "연결을 확인하고 다시 시도하세요." + issueErrorSuggestionTimeout = "익스플로러에서 자산 상태를 확인하세요." + issueErrorSuggestionFeeEstimation = "나중에 다시 시도하세요." + issueErrorSuggestionIpfs = "IPFS 설정을 확인하고 다시 시도하세요." + issueErrorSuggestionIpfsAuth = "설정에서 IPFS 자격 증명을 업데이트하세요." + issueErrorSuggestionInvalidAddress = "주소를 수정하고 다시 시도하세요." + // Phase 40: Multi-step progress + stepIpfsUpload = "IPFS에 업로드 중..."; stepBalanceCheck = "잔액 확인 중..." + stepNameCheck = "이름 사용 가능 확인 중..."; stepIssuing = "Ravencoin에서 발행 중..." + stepNfcProgramming = "NFC 태그 프로그래밍 중..."; stepConfirming = "확인 중..."; stepComplete = "완료" + // Phase 40: Confirmation + confirmPending = "대기 중..."; confirmProgress = "%1\$d/6 확인"; confirmComplete = "확인됨" + // Phase 40: Balance warnings + balanceWarningRoot = "잔액 부족. 지갑에 %1 RVN이 있습니다. ~500 RVN(소각 수수료) + ~0.01 RVN(네트워크 수수료) 필요. 이 지갑으로 RVN을 보내고 다시 시도하세요." + balanceWarningSub = "잔액 부족. 지갑에 %1 RVN이 있습니다. ~100 RVN(소각 수수료) + ~0.01 RVN(네트워크 수수료) 필요. 이 지갑으로 RVN을 보내고 다시 시도하세요." + balanceWarningUnique = "잔액 부족. 지갑에 %1 RVN이 있습니다. ~5 RVN(소각 수수료) + ~0.01 RVN(네트워크 수수료) 필요. 이 지갑으로 RVN을 보내고 다시 시도하세요." + revokeSuccess = "자산 폐기됨"; revokeFailed = "폐기 실패" + // Phase 30-06: mnemonic safety + mnemonicBiometricCoverTitle = "복구 구문 표시를 위한 인증" + mnemonicBiometricCoverBody = "지문, 얼굴 인증 또는 PIN으로 복구 구문을 표시하세요. 누구든 이 구문을 보면 자금을 훔칠 수 있습니다." + mnemonicRevealCta = "구문 표시"; mnemonicCopyAll = "전체 복사" + mnemonicSavedIt = "저장 완료"; authCanceledSnackbar = "인증 취소됨" + mnemonicRevealFailed = "구문을 표시할 수 없습니다. 다시 시도하세요." + deviceSecurityChangedTitle = "기기 보안 변경됨" + deviceSecurityChangedBody = "기기 보안이 변경되었습니다. 복구 구문으로 지갑을 복구하여 계속 진행하세요." + deviceSecurityChangedCta = "복구 구문으로 복원" + restoreReplaceWalletTitle = "현재 지갑을 교체하시겠습니까?" + restoreReplaceWalletBody = "현재 지갑(%1\$s RVN, %2\$s 자산)을 교체합니다. 먼저 복구 구문을 백업해야 합니다. 이 작업은 되돌릴 수 없습니다." + restoreBackupFirstBody = "먼저 복구 구문을 백업하세요. 되돌릴 수 없습니다." + restoreReplaceCta = "지갑 교체"; restoreBackupFirstCta = "먼저 구문 백업" + restoreInvalidPhrase = "유효하지 않은 복구 구문입니다. 철자와 단어 순서를 확인하세요." + cancel = "취소"; scanQr = "QR 스캔" + // Phase 30-08: Connection + cachedStateBanner = "캐시된 상태 표시 중 · 마지막 업데이트 %1\$s" + cachedStateReconnecting = "마지막 업데이트 %1\$s · 재연결 중…" + pendingBalanceLabel = "대기 중" + batterySaverChip = "배터리 절약 · 수동 새로고침" + batterySaverChipDesc = "배터리 절약 모드 활성" + connectionPillOnline = "온라인"; connectionPillReconnecting = "재연결 중…" + connectionPillOffline = "오프라인" + connectionPillSheetTitle = "Ravencoin 네트워크" + connectionPillCurrentNode = "현재 노드" + connectionPillLastSuccess = "마지막 성공한 RPC" + connectionPillFallbackNodes = "대체 노드" + connectionPillQuarantined = "%1\$s까지 격리됨" + connectionPillClose = "닫기"; connectionPillNoNode = "(없음)" + connectionStatusDotDesc = "연결 상태" + reconnectingToast = "Ravencoin 네트워크에 재연결 중…" + offlineAllNodesUnreachable = "오프라인 · 모든 노드 접근 불가" + incomingTxSnackbar = "+%1\$s RVN 수신됨" + receiveCurrentAddressLabel = "현재 주소" + receiveCurrentAddressSubLabel = "다음 전송 또는 통합 후에 변경됩니다." + walletOfflineHeading = "지갑 오프라인" + walletOfflineBody = "Ravencoin 노드에 연결할 수 없습니다. 인터넷 연결을 확인한 후 새로고침을 탭하세요." + txHistorySentPrefix = "전송"; txHistoryCycledPrefix = "순환" + walletCycledMultiAsset = "순환 자산 %1\$d개" + walletCycledAssetsTitle = "순환 자산" + txHistoryFeePrefix = "수수료"; txHistoryLoadMore = "더 보기" + txHistoryEmptyHeading = "거래 내역 없음" + txHistoryEmptyBody = "첫 번째 전송 또는 수신 거래가 여기에 표시됩니다." + txDetailsViewOnExplorer = "익스플로러에서 보기" + txHistoryConfirmations = "%1\$d/6 확인" + biometricCoverDesc = "생체 인증 커버" + revealMnemonicButtonDesc = "복구 구문 표시" + sendFeeLabel = "수수료"; sendFeeEditLabel = "수수료 편집" + sendFeeOverrideHint = "사용자 정의 수수료 (RVN/kB)" + sendFeeTarget = "~6 블록" + sendFeeEstimateUnavailable = "수수료 추정 불가. 0.01 RVN/kB를 기본값으로 사용합니다." + fieldQtyLabel = "수량" + walletSendError = "전송 실패: %1"; walletSendFailed = "전송 실패" + walletSendResult = "%1 RVN 전송 완료 (수수료: %2 RVN) · tx: %3..." + walletTransferError = "이전 실패: %1"; walletTransferFailed = "이전 실패" + walletTransferResult = "%1 이전 완료 · tx: %2..." + walletShowOwnerTokens = "소유자 토큰 표시" } /** @@ -1653,7 +2516,7 @@ val stringsRu = cloneStrings(stringsEn).apply { nfcNotSupported = "NFC не поддерживается"; nfcNotSupportedDesc = "На этом устройстве нет NFC-модуля"; nfcDisabled = "NFC отключен"; nfcDisabledDesc = "Включите NFC в настройках системы, чтобы сканировать теги" howItWorks = "Как это работает"; howStep1 = "Поднесите телефон к NFC-чипу на товаре"; howStep2 = "Уникальная подпись чипа проверяется"; howStep3 = "Результат подтверждается в блокчейне" verifyingTitle = "Проверка…"; verifyAuthentic = "Подлинный"; verifyNotAuthentic = "Неподлинный"; verifyRevoked = "Отозван"; verifyUnableToVerify = "Невозможно проверить"; verifyCounterReplay = "Обнаружено повторное использование счетчика: возможна попытка клонирования."; verifyNfcSig = "Проверка NFC-подписи"; verifyBlockchain = "Проверка блокчейна Ravencoin"; verifyAssetInfo = "Информация о товаре"; verifyAsset = "Товар"; verifyDescription = "Описание"; verifyWebsite = "Сайт"; verifySecDetails = "Детали проверки"; verifyTagUid = "UID тега"; verifyScanCount = "Количество сканирований"; verifyNfcPubId = "Публичный NFC ID"; verifyCrypto = "Криптография"; verifyRevokedBy = "СООБЩЕНО БРЕНДОМ"; verifyScanAgain = "Сканировать другой товар" - walletTitle = "Кошелек бренда"; walletSubtitle = "Управление активами Ravencoin"; walletNoWallet = "Кошелек не найден"; walletNoWalletDesc = "Создайте новый кошелек или восстановите существующий, чтобы управлять активами Ravencoin."; walletGenerate = "Создать новый кошелек"; walletRestore = "Восстановить по мнемонике"; walletMnemonicPlaceholder = "Введите мнемонику из 12 слов…"; walletRestoreBtn = "Восстановить кошелек"; mnemonicSpaceError = "Пробелы не разрешены, вводите по одному слову в каждое поле"; walletBalance = "Баланс Ravencoin"; walletLoading = "Загрузка…"; walletReceiveAddr = "Адрес получения"; walletRecoveryPhrase = "Фраза восстановления"; walletNeverShare = "Никогда не делитесь фразой восстановления. Любой, у кого она есть, может получить доступ к вашим средствам."; walletTapReveal = "Нажмите на значок глаза, чтобы показать фразу восстановления." + walletTitle = "Кошелек бренда"; walletSubtitle = "Управление активами Ravencoin"; walletNoWallet = "Кошелек не найден"; walletNoWalletDesc = "Создайте новый кошелек или восстановите существующий, чтобы управлять активами Ravencoin."; walletGenerate = "Создать новый кошелек"; walletRestore = "Восстановить по мнемонике"; walletMnemonicPlaceholder = "Введите мнемонику из 12 слов…"; walletRestoreBtn = "Восстановить кошелек"; mnemonicSpaceError = "Пробелы не разрешены, вводите по одному слову в каждое поле"; walletScanningBlockchain = "Сканирование блокчейна для поиска адреса кошелька…"; walletBalance = "Баланс Ravencoin"; walletLoading = "Загрузка…"; walletReceiveAddr = "Адрес получения"; walletRecoveryPhrase = "Фраза восстановления"; walletNeverShare = "Никогда не делитесь фразой восстановления. Любой, у кого она есть, может получить доступ к вашим средствам."; walletTapReveal = "Нажмите на значок глаза, чтобы показать фразу восстановления." walletAssetOps = "Операции с активами"; walletIssueRoot = "Выпустить root-актив"; walletIssueRootDesc = "Создать новый родительский актив (требуется 500 RVN)"; walletIssueSub = "Выпустить sub-актив"; walletIssueSubDesc = "Создать актив PARENT/CHILD"; walletRevoke = "Отозвать актив"; walletRevokeDesc = "Пометить актив как поддельный (отзыв через бэкенд)"; walletDeleteTitle = "Удалить кошелек"; walletDeleteMsg = "Это навсегда удалит кошелек из приложения. Убедитесь, что вы сохранили фразу восстановления. Действие нельзя отменить."; walletDeleteBtn = "Удалить"; walletCancelBtn = "Отмена"; walletMyAssets = "Мои активы"; walletAssetsLoading = "Загрузка активов…"; walletNoAssets = "Для этого адреса активы не найдены."; walletAssetsNotVerifiable = "Не удалось загрузить активы. Проверьте соединение и нажмите обновить."; electrumOnline = "ElectrumX · Онлайн"; electrumOffline = "ElectrumX · Офлайн"; electrumChecking = "ElectrumX · Проверка…"; walletRvnPrice = "RVN/USDT" serverNotResponding = "Сервер бэкенда не отвечает (%1). В данный момент невозможно проверить подлинность тега." settingsServerOnline = "Сервер · Онлайн"; settingsServerOffline = "Сервер · Офлайн"; settingsServerChecking = "Сервер · Проверка…"; settingsAdminKeyValid = "Ключ подтвержден"; settingsAdminKeyInvalid = "Ключ недействителен"; settingsAdminKeyChecking = "Проверка…"; settingsAdminKeyLocked = "Сначала сохраните URL сервера"; settingsAdminKeyWrongType = "Это ключ оператора, а не администратора"; operatorKey = "Ключ оператора"; operatorKeyHint = "Необязательно: ограниченный ключ для записи NFC-тегов, выпуска уникальных токенов и перевода токенов. Выдается администратором."; settingsOperatorKeyValid = "Ключ подтвержден"; settingsOperatorKeyInvalid = "Ключ недействителен"; settingsOperatorKeyChecking = "Проверка…"; settingsOperatorKeyLocked = "Сначала сохраните URL сервера"; settingsOperatorKeyWrongType = "Это ключ администратора, а не оператора" @@ -1664,10 +2527,10 @@ val stringsRu = cloneStrings(stringsEn).apply { regChipTitle = "Зарегистрировать NFC-чип"; regChipSubtitle = "Связать UID чипа с активом Ravencoin"; regChipInfo = "Сервер использует BRAND_SALT для вычисления nfc_pub_id = SHA-256(uid ∥ salt). Исходный UID остается приватным; в IPFS-метаданных публикуется только nfc_pub_id."; regChipTagUid = "UID тега"; regChipUidHint = "14 шестнадцатеричных символов = 7 байт, например 04A1B2C3D4E5F6"; regChipUidError = "Должно быть ровно 14 шестнадцатеричных символов (7 байт). Текущее значение:"; regChipBtn = "Зарегистрировать чип" transferTitle = "Передать токен"; transferSubtitle = "Отправить актив в кошелек покупателя · комиссия ~0.01 RVN"; fieldRecipient = "Адрес получателя"; fieldRecipientHint = "Адрес кошелька Ravencoin покупателя"; fieldQtyHint = "Уникальные токены обычно имеют количество 1"; btnTransfer = "Передать токен" writeTitle = "Запись NFC-тега"; writeStep1Title = "Приложите тег"; writeStep1Hint = "Поднесите телефон к NFC-чипу, чтобы прочитать UID."; writeStep1Label = "Шаг 1 из 3"; writeIssuingTitle = "Выпуск в Ravencoin"; writeIssuingHint = "Загрузка метаданных в IPFS и создание sub-актива…"; writeStep3Title = "Приложите тег снова"; writeStep3Hint = "Поднесите телефон к тому же тегу, чтобы записать AES-ключи и SUN URL."; writeStep3Label = "Шаг 3 из 3"; writeSuccessTitle = "Тег записан!"; writeSuccessHint = "NFC-чип успешно настроен.\nСохраните ключи ниже в безопасном месте, они больше нигде не хранятся."; writeSaveKeys = "Сохраните эти ключи в защищенном хранилище. Без них нельзя будет отозвать тег или проверить считывания."; writeErrorTitle = "Ошибка"; writeCloseBtn = "Закрыть" - walletReceiveBtn = "Получить"; walletSendBtn = "Отправить"; walletReceiveTitle = "Получить RVN"; walletReceiveDesc = "Сканируйте этот QR-код или скопируйте адрес ниже, чтобы получить Ravencoin."; walletCopyDone = "Адрес скопирован!"; walletSendTitle = "Отправить RVN"; walletSendAmountLabel = "Сумма (RVN)"; walletSendAddrLabel = "Адрес получателя"; walletSendConfirm = "Отправить"; walletSendSuccess = "Успешно отправлено!"; walletSendWarning = "Это действие нельзя отменить. Внимательно проверьте адрес."; walletSendFeeUnavailable = "Ставка сетевой комиссии недоступна. Все узлы недоступны, попробуйте позже."; walletSendDialogTitle = "Подтвердить отправку"; walletSendDialogMsg = "Отправить %1 RVN на %2?" + walletReceiveBtn = "Получить"; walletSendBtn = "Отправить"; walletReceiveTitle = "Получить RVN"; walletReceiveDesc = "Сканируйте этот QR-код или скопируйте адрес ниже, чтобы получить Ravencoin."; walletCopyDone = "Адрес скопирован!"; walletSendTitle = "Отправить RVN"; walletSendAmountLabel = "Сумма (RVN)"; walletSendAddrLabel = "Адрес получателя"; walletSendConfirm = "Отправить"; walletSendSuccess = "Успешно отправлено!"; walletSendFailed = "Отправка не удалась"; walletTransferFailed = "Передача не удалась"; walletSendError = "Отправка не удалась: %1"; walletTransferError = "Передача не удалась: %1"; walletSendWarning = "Это действие нельзя отменить. Внимательно проверьте адрес."; walletSendFeeUnavailable = "Ставка сетевой комиссии недоступна. Все узлы недоступны, попробуйте позже."; walletSendDialogTitle = "Подтвердить отправку"; walletSendDialogMsg = "Отправить %1 RVN на %2?" walletFilterAll = "Все"; brandProgramTag = "Записать NFC-тег"; brandProgramTagDesc = "Записать AES-ключи и SUN URL в чип NTAG 424 DNA. Чип автоматически регистрируется в бэкенде."; brandProgramTagAssetHint = "Полное имя актива, например FASHIONX/BAG01#SN0001"; brandProgramTagStart = "Начать запись тега"; brandNoWalletMsg = "Кошелек Ravencoin не найден. Создайте или добавьте кошелек во вкладке Wallet, чтобы продолжить."; brandGoToWallet = "Перейти в кошелек" - settingsDonateBtn = "Пожертвовать RVN RavenTag"; settingsDonateTitle = "Пожертвование RavenTag"; settingsDonateDesc = "Поддержите развитие открытого протокола RavenTag."; settingsDonateMsg = "RavenTag — бесплатный open-source протокол NFC-аутентификации, созданный для брендов любого масштаба. Если он вам полезен, рассмотрите небольшое пожертвование в RVN, чтобы поддержать дальнейшую разработку, документацию и новые функции. Любой вклад, даже небольшой, действительно важен. Спасибо за поддержку open-source!"; brandNoFundsTitle = "Недостаточный баланс"; brandNoFundsMsg = "В кошельке нет RVN. Пополните кошелек, чтобы выпускать активы. Вы все равно можете продолжить просмотр формы."; brandNoFundsContinue = "Продолжить в любом случае" - navSettings = "Настройки"; settingsTitle = "Настройки"; settingsBrandName = "Название бренда"; settingsBrandNameHint = "Название вашего бренда, отображаемое в приложении (например, Fashionx)"; settingsVerifyUrl = "URL сервера проверки"; settingsVerifyUrlHint = "URL бэкенда бренда, выпустившего продукт. Используется для сканирования и программирования чипов."; settingsVerifyUrlConsumer = "URL сервера бренда"; settingsVerifyUrlHintConsumer = "Введи URL, предоставленный брендом товара, который хочешь проверить. Его можно найти на упаковке или сайте бренда."; settingsSave = "Сохранить"; settingsSaved = "Сохранено!"; settingsAbout = "О приложении"; settingsVersion = "Версия"; settingsRequireAuth = "Требовать аутентификацию при запуске"; settingsRequireAuthDesc = "Запрашивать PIN или биометрию при открытии приложения (требуется активный кошелек)"; settingsRequireAuthRisk = "Отключение снижает безопасность. Любой, у кого есть доступ к устройству, сможет открыть приложение."; settingsNoLockScreen = "На устройстве не настроена блокировка экрана. Аутентификация будет пропущена. Настройте PIN или отпечаток пальца в системе для защиты кошелька."; settingsAllowScreenshots = "Разрешить скриншоты"; settingsAllowScreenshotsDesc = "Отключить защиту от захвата экрана (FLAG_SECURE). Ключи кошелька и мнемоника могут попадать в миниатюры и записи экрана."; settingsAllowScreenshotsWarning = "Скриншоты включены: ключи кошелька и мнемоника НЕ защищены от захвата экрана."; settingsAllowScreenshotsDialogTitle = "Предупреждение безопасности"; settingsAllowScreenshotsDialogBody = "Разрешение скриншотов отключает защиту FLAG_SECURE. Ключи кошелька и фраза восстановления могут быть захвачены средствами записи экрана, миниатюрами и ближайшими камерами.\n\nВключайте только на доверенных личных устройствах."; settingsAllowScreenshotsConfirm = "Понимаю, включить скриншоты"; settingsNotifications = "Включить уведомления"; settingsNotificationsDesc = "Показывать уведомление при получении RVN или активов."; authTitle = "RavenTag"; authSubtitle = "请认证以访问你的钱包" + settingsDonateBtn = "Пожертвовать RVN RavenTag"; settingsDonateTitle = "Пожертвование RavenTag"; settingsDonateDesc = "Поддержите развитие открытого протокола RavenTag."; settingsDonateMsg = "RavenTag : бесплатный open-source протокол NFC-аутентификации, созданный для брендов любого масштаба. Если он вам полезен, рассмотрите небольшое пожертвование в RVN, чтобы поддержать дальнейшую разработку, документацию и новые функции. Любой вклад, даже небольшой, действительно важен. Спасибо за поддержку open-source!"; brandNoFundsTitle = "Недостаточный баланс"; brandNoFundsMsg = "В кошельке нет RVN. Пополните кошелек, чтобы выпускать активы. Вы все равно можете продолжить просмотр формы."; brandNoFundsContinue = "Продолжить в любом случае" + navSettings = "Настройки"; settingsTitle = "Настройки"; settingsBrandName = "Название бренда"; settingsBrandNameHint = "Название вашего бренда, отображаемое в приложении (например, Fashionx)"; settingsVerifyUrl = "URL сервера проверки"; settingsVerifyUrlHint = "URL бэкенда бренда, выпустившего продукт. Используется для сканирования и программирования чипов."; settingsVerifyUrlConsumer = "URL сервера бренда"; settingsVerifyUrlHintConsumer = "Введи URL, предоставленный брендом товара, который хочешь проверить. Его можно найти на упаковке или сайте бренда."; settingsSave = "Сохранить"; settingsSaved = "Сохранено!"; settingsAbout = "О приложении"; settingsVersion = "Версия"; settingsRequireAuth = "Требовать аутентификацию при запуске"; settingsRequireAuthDesc = "Запрашивать PIN или биометрию при открытии приложения (требуется активный кошелек)"; settingsRequireAuthRisk = "Отключение снижает безопасность. Любой, у кого есть доступ к устройству, сможет открыть приложение."; settingsNoLockScreen = "На устройстве не настроена блокировка экрана. Аутентификация будет пропущена. Настройте PIN или отпечаток пальца в системе для защиты кошелька."; settingsAllowScreenshots = "Разрешить скриншоты"; settingsAllowScreenshotsDesc = "Отключить защиту от захвата экрана (FLAG_SECURE). Ключи кошелька и мнемоника могут попадать в миниатюры и записи экрана."; settingsAllowScreenshotsWarning = "Скриншоты включены: ключи кошелька и мнемоника НЕ защищены от захвата экрана."; settingsAllowScreenshotsDialogTitle = "Предупреждение безопасности"; settingsAllowScreenshotsDialogBody = "Разрешение скриншотов отключает защиту FLAG_SECURE. Ключи кошелька и фраза восстановления могут быть захвачены средствами записи экрана, миниатюрами и ближайшими камерами.\n\nВключайте только на доверенных личных устройствах."; settingsAllowScreenshotsConfirm = "Понимаю, включить скриншоты"; settingsNotifications = "Включить уведомления"; settingsNotificationsDesc = "Показывать уведомление при получении RVN или активов."; authTitle = "RavenTag"; authSubtitle = "Аутентифицируйтесь для доступа к вашему кошельку" // QR Scanner qrScannerTitle = "Сканировать QR-код" qrScannerHint = "Наведите камеру на QR-код адреса Ravencoin" @@ -1682,6 +2545,92 @@ val stringsRu = cloneStrings(stringsEn).apply { walletControlKeyInvalid = "Ключ не распознан. Введите действующий ключ администратора или оператора." walletRoleAdmin = "Администратор"; walletRoleOperator = "Оператор" onboardingBadgeConsumer = "Открытый код · Ravencoin"; onboardingTitleConsumer = "Проверьте подлинность вашего товара"; onboardingDescConsumer = "Поднесите телефон к NFC-чипу, встроенному в товар производителем, чтобы мгновенно убедиться в его подлинности."; featureNtagConsumer = "Невозможно подделать"; featureNtagDescConsumer = "Чип, встроенный в товар, генерирует уникальную подпись при каждом сканировании. Его невозможно клонировать или воспроизвести."; featureSovConsumer = "Без посредников"; featureSovDescConsumer = "Каждый бренд управляет своей инфраструктурой аутентификации. Никаких центральных органов и посредников." + // Phase 40: Error classification + issueErrorInsufficientFunds = "Недостаточно средств. Отправьте RVN на кошелек бренда и попробуйте снова." + issueErrorDuplicateName = "Имя актива уже существует. Выберите другое имя." + issueErrorNodeUnreachable = "Узел RPC недоступен. Проверьте интернет-соединение и попробуйте снова." + issueErrorTimeout = "Тайм-аут запроса. Транзакция могла быть отправлена. Проверьте кошелек." + issueErrorFeeEstimation = "Ошибка оценки комиссии. Возможно, сеть перегружена." + issueErrorIpfsAuth = "Аутентификация IPFS истекла. Обновите Pinata JWT в Настройках." + issueErrorIpfsFailed = "Ошибка загрузки в IPFS. Проверьте соединение и повторите." + issueErrorInvalidAddress = "Неверный формат адреса Ravencoin." + issueErrorNoWallet = "Кошелек Ravencoin не найден. Сначала создайте или восстановите кошелек." + issueErrorSuggestionInsufficientFunds = "Отправьте RVN на кошелек бренда и попробуйте снова." + issueErrorSuggestionDuplicate = "Измените имя актива и попробуйте снова." + issueErrorSuggestionNodeUnreachable = "Проверьте соединение и попробуйте снова." + issueErrorSuggestionTimeout = "Проверьте статус актива в обозревателе." + issueErrorSuggestionFeeEstimation = "Попробуйте позже." + issueErrorSuggestionIpfs = "Проверьте настройки IPFS и повторите." + issueErrorSuggestionIpfsAuth = "Перейдите в Настройки и обновите учетные данные IPFS." + issueErrorSuggestionInvalidAddress = "Исправьте адрес и попробуйте снова." + // Phase 40: Multi-step progress + stepIpfsUpload = "Загрузка в IPFS..."; stepBalanceCheck = "Проверка баланса..." + stepNameCheck = "Проверка доступности имени..."; stepIssuing = "Выпуск в Ravencoin..." + stepNfcProgramming = "Программирование NFC-тега..."; stepConfirming = "Подтверждение..."; stepComplete = "Завершено" + // Phase 40: Confirmation + confirmPending = "Ожидание..."; confirmProgress = "%1\$d/6 подтверждений"; confirmComplete = "Подтверждено" + // Phase 40: Balance warnings + balanceWarningRoot = "Недостаточный баланс. В кошельке %1 RVN. Требуется ~500 RVN (комиссия сжигания) + ~0.01 RVN (сетевая комиссия). Отправьте RVN на этот кошелек и повторите." + balanceWarningSub = "Недостаточный баланс. В кошельке %1 RVN. Требуется ~100 RVN (комиссия сжигания) + ~0.01 RVN (сетевая комиссия). Отправьте RVN на этот кошелек и повторите." + balanceWarningUnique = "Недостаточный баланс. В кошельке %1 RVN. Требуется ~5 RVN (комиссия сжигания) + ~0.01 RVN (сетевая комиссия). Отправьте RVN на этот кошелек и повторите." + revokeSuccess = "Актив отозван"; revokeFailed = "Отзыв не удался" + // Phase 30-06: mnemonic safety + mnemonicBiometricCoverTitle = "Аутентификация для отображения фразы" + mnemonicBiometricCoverBody = "Используйте отпечаток пальца, лицо или PIN-код для отображения фразы восстановления. Любой, кто ее увидит, может украсть ваши средства." + mnemonicRevealCta = "Показать фразу"; mnemonicCopyAll = "Скопировать все" + mnemonicSavedIt = "Сохранил(а)"; authCanceledSnackbar = "Аутентификация отменена" + mnemonicRevealFailed = "Не удалось показать фразу. Попробуйте снова." + deviceSecurityChangedTitle = "Безопасность устройства изменена" + deviceSecurityChangedBody = "Безопасность устройства изменилась. Восстановите кошелек с помощью фразы восстановления." + deviceSecurityChangedCta = "Восстановить из фразы восстановления" + restoreReplaceWalletTitle = "Заменить текущий кошелек?" + restoreReplaceWalletBody = "Это заменит ваш текущий кошелек (%1\$s RVN, %2\$s активов). Сначала создайте резервную копию фразы восстановления. Это действие нельзя отменить." + restoreBackupFirstBody = "Сначала создайте резервную копию фразы восстановления. Это нельзя отменить." + restoreReplaceCta = "Заменить кошелек"; restoreBackupFirstCta = "Сначала сохранить фразу" + restoreInvalidPhrase = "Неверная фраза восстановления. Проверьте правописание и порядок слов." + cancel = "Отмена"; scanQr = "Сканировать QR" + // Phase 30-08: Connection + cachedStateBanner = "Показано кешированное состояние · Обновлено %1\$s" + cachedStateReconnecting = "Обновлено %1\$s · переподключение…" + pendingBalanceLabel = "В ожидании" + batterySaverChip = "Экономия заряда · ручное обновление" + batterySaverChipDesc = "Режим экономии заряда активен" + connectionPillOnline = "Онлайн"; connectionPillReconnecting = "Переподключение…" + connectionPillOffline = "Офлайн" + connectionPillSheetTitle = "Сеть Ravencoin" + connectionPillCurrentNode = "Текущий узел" + connectionPillLastSuccess = "Последний успешный RPC" + connectionPillFallbackNodes = "Резервные узлы" + connectionPillQuarantined = "На карантине до %1\$s" + connectionPillClose = "Закрыть"; connectionPillNoNode = "(нет)" + connectionStatusDotDesc = "Статус соединения" + reconnectingToast = "Переподключение к сети Ravencoin…" + offlineAllNodesUnreachable = "Офлайн · все узлы недоступны" + incomingTxSnackbar = "+%1\$s RVN получено" + receiveCurrentAddressLabel = "Ваш текущий адрес" + receiveCurrentAddressSubLabel = "Изменится после следующей отправки или консолидации." + walletOfflineHeading = "Кошелек офлайн" + walletOfflineBody = "Не удается подключиться ни к одному узлу Ravencoin. Проверьте интернет-соединение и нажмите Обновить." + txHistorySentPrefix = "Отправлено"; txHistoryCycledPrefix = "Циклически" + walletCycledMultiAsset = "Циклических активов: %1\$d" + walletCycledAssetsTitle = "Циклические активы" + txHistoryFeePrefix = "Комиссия"; txHistoryLoadMore = "Загрузить еще" + txHistoryEmptyHeading = "Транзакций пока нет" + txHistoryEmptyBody = "Ваша первая отправленная или полученная транзакция появится здесь." + txDetailsViewOnExplorer = "Смотреть в обозревателе" + txHistoryConfirmations = "%1\$d/6 подтверждений" + biometricCoverDesc = "Экран биометрической аутентификации" + revealMnemonicButtonDesc = "Показать фразу восстановления" + sendFeeLabel = "Комиссия"; sendFeeEditLabel = "Изменить комиссию" + sendFeeOverrideHint = "Своя комиссия (RVN/kB)" + sendFeeTarget = "~6 блоков" + sendFeeEstimateUnavailable = "Оценка комиссии недоступна. Используется 0.01 RVN/kB по умолчанию." + fieldQtyLabel = "Количество" + walletSendError = "Отправка не удалась: %1"; walletSendFailed = "Отправка не удалась" + walletSendResult = "Отправлено %1 RVN (комиссия: %2 RVN) · tx: %3..." + walletTransferError = "Перевод не удался: %1"; walletTransferFailed = "Перевод не удался" + walletTransferResult = "Переведено %1 · tx: %2..." + walletShowOwnerTokens = "Показать токены владельца" } /** diff --git a/android/app/src/main/java/io/raventag/app/utils/RetryUtils.kt b/android/app/src/main/java/io/raventag/app/utils/RetryUtils.kt new file mode 100644 index 0000000..e848b18 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/utils/RetryUtils.kt @@ -0,0 +1,100 @@ +package io.raventag.app.utils + +import android.util.Log +import kotlinx.coroutines.delay +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.io.IOException + +/** + * Retry utility with exponential backoff for transient network failures. + * + * Implements D-02 and D-06 from CONTEXT.md: + * - 5 retries with exponential backoff (base 1s, multiplier 2x) + * - Transient errors trigger retries (timeout, connection, network) + * - Non-transient errors fail immediately + * + * Usage: + * ```kotlin + * val result = retryWithBackoff(maxAttempts = 5) { + * networkCall() + * } + * ``` + */ +object RetryUtils { + private const val TAG = "RetryUtils" + + /** + * Execute [block] with exponential backoff retry on transient failures. + * + * @param maxAttempts Maximum number of attempts (default 5 per D-02, D-06) + * @param initialDelayMs Base delay in milliseconds (default 1000ms per D-02, D-06) + * @param backoffMultiplier Delay multiplier (default 2.0 for exponential backoff) + * @param block The suspend function to execute + * @return The result of [block] on success + * @throws The last exception if all attempts fail or error is non-transient + */ + suspend fun retryWithBackoff( + maxAttempts: Int = 5, + initialDelayMs: Long = 1000L, + backoffMultiplier: Double = 2.0, + block: suspend () -> T + ): T { + var lastException: Exception? = null + var currentDelay = initialDelayMs + + repeat(maxAttempts) { attempt -> + try { + return block() + } catch (e: Exception) { + lastException = e + val isTransient = isTransientError(e) + + if (attempt < maxAttempts - 1 && isTransient) { + Log.w(TAG, "Attempt ${attempt + 1}/$maxAttempts failed, retrying in ${currentDelay}ms: ${e.message}") + delay(currentDelay) + currentDelay = (currentDelay * backoffMultiplier).toLong() + } else { + // Last attempt or non-transient error: throw immediately + val reason = if (!isTransient) "non-transient error" else "all retries exhausted" + Log.e(TAG, "Failed after $reason: ${e.javaClass.simpleName}: ${e.message}") + throw e + } + } + } + + // Should not reach here, but handle edge case + throw lastException ?: IllegalStateException("Retry logic failed with no exception") + } + + /** + * Determine if an exception represents a transient (retryable) error. + * + * Transient errors: + * - SocketTimeoutException: Network timeout + * - UnknownHostException: DNS resolution failure + * - IOException with "timeout", "connection", "network", "temporary" in message + * + * Non-transient errors: + * - Validation errors (insufficient funds, invalid address) + * - Logic errors (wrong asset, unauthorized) + * - Auth errors (invalid credentials) + * + * @param e The exception to evaluate + * @return true if the error is transient and should trigger retry + */ + fun isTransientError(e: Exception): Boolean { + when (e) { + is SocketTimeoutException -> return true + is UnknownHostException -> return true + is IOException -> { + val message = e.message?.lowercase() ?: return false + return message.contains("timeout") || + message.contains("connection") || + message.contains("network") || + message.contains("temporary") + } + else -> return false + } + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/AssetManager.kt b/android/app/src/main/java/io/raventag/app/wallet/AssetManager.kt index b70a7ae..a33a1c8 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/AssetManager.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/AssetManager.kt @@ -17,10 +17,12 @@ */ package io.raventag.app.wallet +import android.content.Context import android.util.Log import com.google.gson.Gson import com.google.gson.JsonObject import io.raventag.app.BuildConfig +import io.raventag.app.security.AdminKeyStorage import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -169,16 +171,26 @@ data class DerivedChipKeys( * All write operations (issue, revoke, transfer) require a valid admin key sent via the * X-Admin-Key header. The revocation check endpoint is public (no auth required). * - * @param apiBaseUrl Base URL of the RavenTag backend, from BuildConfig.API_BASE_URL. - * @param adminKey Brand admin key (ADMIN_KEY env var on backend side). + * @param context Application context used to access encrypted admin key storage. + * @param apiBaseUrl Base URL of the RavenTag backend, from BuildConfig.API_BASE_URL. + * @param adminKeyStorage Encrypted storage wrapper for the admin key. */ class AssetManager( + private val context: Context, private val apiBaseUrl: String = BuildConfig.API_BASE_URL, - private val adminKey: String = "" + private val adminKeyStorage: AdminKeyStorage ) { private val gson = Gson() private val json = "application/json".toMediaType() + /** + * Admin key retrieved from encrypted storage. + * Throws IllegalStateException if the admin key is not configured. + */ + private val adminKey: String + get() = adminKeyStorage.getAdminKey() + ?: throw IllegalStateException("Admin key not configured. Configure in Settings.") + /** OkHttp client with generous timeouts for blockchain operations that may take several seconds. */ private val http = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -440,9 +452,10 @@ class AssetManager( * * @param tagUidHex 7-byte chip UID as lowercase hex string. */ + // SECURITY: tagUid parameter is NOT logged to prevent exfiltration via log aggregation services + // Only nfcPubId (public identifier) is logged on success fun deriveChipKeys(tagUidHex: String): DerivedChipKeys? { return try { - Log.i("AssetManager", "deriveChipKeys request tagUid=$tagUidHex") val body = mapOf("tag_uid" to tagUidHex.lowercase()) val resp = adminRequest("POST", "/api/brand/derive-chip-key", body) fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { i -> @@ -453,10 +466,10 @@ class AssetManager( val sdmEncKey = hexToBytes(resp["sdm_enc_key"]?.asString ?: return null) val sdmMacKey = hexToBytes(resp["sdm_mac_key"]?.asString ?: return null) val nfcPubId = resp["nfc_pub_id"]?.asString ?: return null - Log.i("AssetManager", "deriveChipKeys success tagUid=$tagUidHex nfcPubId=$nfcPubId") + Log.i("AssetManager", "deriveChipKeys success nfcPubId=$nfcPubId") DerivedChipKeys(appMasterKey, sdmmacInputKey, sdmEncKey, sdmMacKey, nfcPubId) } catch (e: Exception) { - Log.e("AssetManager", "deriveChipKeys failed tagUid=$tagUidHex error=${e.message}", e) + Log.e("AssetManager", "deriveChipKeys failed error=${e.message}", e) null } } @@ -524,18 +537,19 @@ class AssetManager( * @param assetName Ravencoin asset name (uppercased before submission). * @param tagUid 7-byte chip UID in hex (uppercased before submission). */ + // SECURITY: tagUid parameter is NOT logged to prevent exfiltration via log aggregation services fun registerChip(assetName: String, tagUid: String): AssetOperationResult { return try { - Log.i("AssetManager", "registerChip request asset=$assetName tagUid=$tagUid") + Log.i("AssetManager", "registerChip request asset=$assetName") val body = mapOf("asset_name" to assetName.uppercase(), "tag_uid" to tagUid.uppercase()) val resp = adminRequest("POST", "/api/brand/register-chip", body) - Log.i("AssetManager", "registerChip success asset=$assetName tagUid=$tagUid") + Log.i("AssetManager", "registerChip success asset=$assetName") AssetOperationResult( success = resp["success"]?.asBoolean == true, assetName = resp["asset_name"]?.asString ) } catch (e: Exception) { - Log.e("AssetManager", "registerChip failed asset=$assetName tagUid=$tagUid error=${e.message}", e) + Log.e("AssetManager", "registerChip failed asset=$assetName error=${e.message}", e) AssetOperationResult(success = false, error = e.message) } } diff --git a/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt b/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt index cddcd9f..e78ea86 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/RavencoinPublicNode.kt @@ -1,5 +1,6 @@ package io.raventag.app.wallet +import android.content.Context import android.util.Log import com.google.gson.Gson import com.google.gson.JsonElement @@ -12,17 +13,9 @@ import java.math.BigInteger import java.net.InetSocketAddress import java.security.MessageDigest import java.security.SecureRandom -import java.security.cert.X509Certificate -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket -import javax.net.ssl.X509TrustManager -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit /** * Thrown when no ElectrumX server in the server list is able to provide a @@ -107,12 +100,23 @@ data class ElectrumAssetMeta( */ data class TxHistoryEntry( val txid: String, - val height: Int, // 0 = unconfirmed/mempool + val height: Int, // 0 = unconfirmed/mempool val confirmations: Int, - val amountSat: Long, // positive = received to our address - val sentSat: Long, // positive = sent to other addresses - val isIncoming: Boolean, // true if amountSat > 0 (our address in vout) - val timestamp: Long = 0L // Unix timestamp in seconds (0 if unknown) + val amountSat: Long, // positive = received to our address + val sentSat: Long, // positive = sent to other addresses (external, D-19) + val isIncoming: Boolean, // true if amountSat > 0 (our address in vout) + val isSelfTransfer: Boolean = false, // true if this is an internal sweep (< 1% net loss) + val timestamp: Long = 0L, // Unix timestamp in seconds (0 if unknown) + // D-19 three-value breakdown (0 when unknown / not yet enriched): + val cycledSat: Long = 0L, // satoshis paying the change / currentIndex+1 address + val feeSat: Long = 0L, // fee paid (sum(vin) - sum(vout)) + // Asset transfer detection: when this tx delivers an asset to one of our + // addresses, [assetName] and [assetAmount] describe the asset; otherwise null/0. + val assetName: String? = null, + val assetAmount: Long = 0L, // raw asset amount (sats * 10^divisions) + // Full list of asset names cycled/received in this tx (toUs vouts). + // Used by the UI to compact the row to "Ciclati N asset" with a tap-to-list dialog. + val incomingAssetNames: List = emptyList() ) /** @@ -151,29 +155,36 @@ private data class ElectrumServer(val host: String, val port: Int) * assets: blockchain.scripthash.listunspent + blockchain.scripthash.get_balance * with the Ravencoin ElectrumX asset extensions */ -class RavencoinPublicNode { +class RavencoinPublicNode(private val context: Context) { companion object { private const val TAG = "ElectrumX" - /** Timeout for the TCP connection handshake in milliseconds. */ - private const val CONNECT_TIMEOUT_MS = 12_000 + /** Timeout for the TCP connection handshake in milliseconds. + * Kept tight so a dead server does not stall the cold-start failover + * rotation: 5 servers × 5 s previously meant up to 25 s of "Reconnecting…" + * on app resume; 2.5 s caps that at ~12 s worst case. */ + private const val CONNECT_TIMEOUT_MS = 2_500 /** Timeout for reading a response line from the server in milliseconds. */ private const val READ_TIMEOUT_MS = 15_000 + /** Maximum number of pipelined requests per [callBatch] TLS connection. */ + private const val BATCH_CHUNK_SIZE = 20 + /** * List of public Ravencoin ElectrumX servers, tried in order. * All use the standard TLS port 50002. - * New servers can be added here; removal of dead servers avoids unnecessary - * timeout delays on every request. + * + * Sourced from [io.raventag.app.config.AppConfig.ELECTRUM_SERVERS] so + * that [io.raventag.app.wallet.health.NodeHealthMonitor] and this + * class iterate the same pool. Evaluated once at class init; adding + * hosts requires editing AppConfig (see KDoc there for provenance). */ - private val SERVERS = listOf( - ElectrumServer("rvn4lyfe.com", 50002), - ElectrumServer("rvn-dashboard.com", 50002), - ElectrumServer("162.19.153.65", 50002), - ElectrumServer("51.222.139.25", 50002), - ) + private val SERVERS: List = + io.raventag.app.config.AppConfig.ELECTRUM_SERVERS.map { (host, port) -> + ElectrumServer(host, port) + } /** * Monotonically increasing request ID counter, shared across all instances. @@ -185,12 +196,6 @@ class RavencoinPublicNode { /** Shared Gson instance for serializing JSON-RPC request objects. */ private val gson = Gson() - - /** - * TOFU certificate fingerprint cache: hostname -> SHA-256 hex string. - * Thread-safe via ConcurrentHashMap. Scoped to the process lifetime. - */ - private val certCache = ConcurrentHashMap() } // Public API ────────────────────────────────────────────────────────────── @@ -214,6 +219,16 @@ class RavencoinPublicNode { return false } + /** + * Lightweight heartbeat that routes through [callWithFailover] so + * NodeHealthMonitor receives success/failure signals and the UI pill + * stays fresh between wallet refreshes. Returns true if any server answered. + */ + fun heartbeat(): Boolean = try { + callWithFailover("server.version", listOf("RavenTag/1.0", "1.4")) + true + } catch (_: Exception) { false } + /** * Returns the confirmed and unconfirmed RVN balance for [address]. * @@ -233,6 +248,119 @@ class RavencoinPublicNode { ) } + /** + * Aggregates asset balances across all [addresses] using a single pipelined batch request. + * + * Sends one `blockchain.scripthash.get_balance` call (with asset=true) per address, + * all pipelined in one TLS connection. Returns a map from asset name to total amount + * (in human-readable units, i.e. divided by 10^8), excluding plain RVN entries. + * + * @param addresses List of Ravencoin P2PKH addresses to aggregate. + * @return Map of asset name to total balance; empty if no assets or network failure. + */ + fun getTotalAssetBalances(addresses: List): Map { + if (addresses.isEmpty()) return emptyMap() + val requests = addresses.map { addr -> + "blockchain.scripthash.get_balance" to listOf(addressToScripthash(addr), true) as List + } + val responses = callWithFailoverBatch(requests) + val totals = mutableMapOf() + addresses.forEachIndexed { i, addr -> + val resp = responses.getOrNull(i) ?: return@forEachIndexed + if (resp == null || !resp.isJsonObject) return@forEachIndexed + for ((name, value) in resp.asJsonObject.entrySet()) { + if (name == "rvn" || name == "RVN") continue + try { + val obj = value.asJsonObject + val sat = (obj.get("confirmed")?.asLong ?: 0L) + (obj.get("unconfirmed")?.asLong ?: 0L) + if (sat > 0) { + totals[name] = (totals[name] ?: 0L) + sat + } + } catch (_: Exception) {} + } + } + return totals.mapValues { (_, sat) -> sat / 1e8 } + } + + /** + * Returns the subset of [addresses] that currently hold any funds (RVN or assets), + * using a single pipelined batch `get_balance?asset=true` request. + * + * Replaces the previous pattern of N*2 sequential getAssetBalances+getUtxos calls + * (one TLS connection per address) with a single batched query (ceil(N/20) connections). + * + * @param addresses List of Ravencoin P2PKH addresses to check. + * @return Set of addresses that have at least one satoshi of RVN or assets. + */ + fun getAddressesWithFunds(addresses: List): Set = + getAddressesWithSignificantFunds(addresses, minRvnSat = 1L) + + /** + * Like [getAddressesWithFunds] but ignores RVN residues below [minRvnSat]. + * Use a non-zero floor (e.g. 100_000 sat = 0.001 RVN) for the consolidation + * banner so we don't keep nagging the user about dust left behind by a sweep. + * Asset balances always count regardless of [minRvnSat]. + */ + fun getAddressesWithSignificantFunds(addresses: List, minRvnSat: Long): Set { + if (addresses.isEmpty()) return emptySet() + val requests = addresses.map { addr -> + "blockchain.scripthash.get_balance" to listOf(addressToScripthash(addr), true) as List + } + val responses = callWithFailoverBatch(requests) + val result = mutableSetOf() + addresses.forEachIndexed { i, addr -> + val resp = responses.getOrNull(i) ?: return@forEachIndexed + if (resp == null || !resp.isJsonObject) return@forEachIndexed + val obj = resp.asJsonObject + val rvnSat = (try { obj.get("confirmed")?.asLong ?: 0L } catch (_: Exception) { 0L }) + + (try { obj.get("unconfirmed")?.asLong ?: 0L } catch (_: Exception) { 0L }) + if (rvnSat >= minRvnSat) { result.add(addr); return@forEachIndexed } + for ((key, value) in obj.entrySet()) { + if (key == "confirmed" || key == "unconfirmed") continue + try { + val assetObj = value.asJsonObject + val sat = (assetObj.get("confirmed")?.asLong ?: 0L) + + (assetObj.get("unconfirmed")?.asLong ?: 0L) + if (sat > 0) { result.add(addr); break } + } catch (_: Exception) {} + } + } + return result + } + + /** + * Returns the total RVN balance (confirmed + unconfirmed) across all [addresses] + * using a single pipelined batch request. + * + * Replaces N sequential/parallel [getBalance] calls with one TLS connection and + * N pipelined `blockchain.scripthash.get_balance` requests (chunked at [BATCH_CHUNK_SIZE]). + * With 37 addresses this drops from 37 connections to 2. + * + * @param addresses List of Ravencoin P2PKH addresses to aggregate. + * @return Total balance in RVN, 0.0 if all addresses are empty or on network failure. + */ + fun getTotalBalance(addresses: List): Double { + if (addresses.isEmpty()) return 0.0 + val requests = addresses.map { addr -> + "blockchain.scripthash.get_balance" to listOf(addressToScripthash(addr)) as List + } + val responses = callWithFailoverBatch(requests) + var totalSat = 0L + var successCount = 0 + for (resp in responses) { + if (resp != null && !resp.isJsonNull && resp.isJsonObject) { + val obj = resp.asJsonObject + totalSat += obj.get("confirmed")?.asLong ?: 0L + totalSat += obj.get("unconfirmed")?.asLong ?: 0L + successCount++ + } + } + // If every single response failed, treat it as a network error rather than silently + // returning 0.0: the caller can catch this and preserve the previously known balance. + if (successCount == 0) throw java.io.IOException("All balance queries failed (network unreachable)") + return totalSat / 1e8 + } + /** * Returns only RVN-carrying UTXOs for [address]. * @@ -300,6 +428,16 @@ class RavencoinPublicNode { fun broadcast(rawHex: String): String = callWithFailover("blockchain.transaction.broadcast", listOf(rawHex)).asString + /** + * Low-level RPC call with failover; returns null on any exception. + * + * Used by RebroadcastWorker for confirmation checks and other callers + * that need best-effort access to ElectrumX RPC without propagating errors. + */ + fun callElectrumRawOrNull(method: String, params: List): com.google.gson.JsonElement? = try { + callWithFailover(method, params) + } catch (_: Exception) { null } + /** * Queries all known ElectrumX servers for "blockchain.relayfee" and returns a * safe fee rate to use when building transactions. @@ -344,6 +482,33 @@ class RavencoinPublicNode { return result.asJsonObject.get("height")?.asInt } + /** + * D-05 support: subscribes to a scripthash and returns the current status hash. + * Uses the one-shot RPC socket; the foreground-session long-lived socket lives in + * [io.raventag.app.wallet.subscription.SubscriptionManager]. + * + * @param address Ravencoin P2PKH address. + * @return The current status hash, or null if the address has no history. + */ + fun subscribeScripthashRpc(address: String): String? { + val scripthash = addressToScripthash(address) + val result = callWithFailover("blockchain.scripthash.subscribe", listOf(scripthash)) + return if (result.isJsonNull) null else result.asString + } + + /** + * D-22 support: calls blockchain.estimatefee with a block target and returns + * the raw RVN/kB number. Returns -1.0 when the server returns null. Callers + * (FeeEstimator) are responsible for the static-fallback policy. + * + * @param targetBlocks Number of blocks for the fee estimation target. + * @return Fee rate in RVN per kilobyte, or -1.0 if unavailable. + */ + fun estimateFeeRvnPerKb(targetBlocks: Int): Double { + val result = callWithFailover("blockchain.estimatefee", listOf(targetBlocks)) + return if (result.isJsonNull) -1.0 else result.asDouble + } + /** * Returns all asset-carrying UTXOs for a specific asset owned by [address]. * @@ -531,6 +696,190 @@ class RavencoinPublicNode { return result } + /** + * Fetches all UTXOs for [address] and returns RVN UTXOs, asset outpoints, and all + * asset UTXOs with full scripts in at most 2 TLS connections. + * + * TLS 1: blockchain.scripthash.listunspent (full unfiltered UTXO list) + * TLS 2: batch blockchain.transaction.get for every unique txid referenced by + * unknown-type UTXOs (need "88acc0" check) and asset UTXOs (need on-chain script) + * + * This replaces the combination of [getUtxos] + [getAllAssetOutpoints] + N calls to + * [getAssetUtxosFull] that previously required N+2 separate TLS connections. + * + * @param address Ravencoin P2PKH address. + * @return Triple: + * - rvnUtxos: plain RVN UTXOs safe to spend as fee inputs + * - assetOutpoints: set of "txid:vout" that carry assets (to exclude from fee inputs) + * - assetUtxosMap: map from asset name to list of AssetUtxo (with on-chain scripts) + */ + fun getUtxosAndAllAssetUtxosBatch( + address: String + ): Triple, Set, Map>> { + val rvnScript = p2pkhScriptHex(address) + val rawList = listUnspentRaw(address) // TLS 1 + + // Classify each UTXO into one of three buckets + data class PendingUtxo( + val txHash: String, + val txPos: Int, + val height: Int, + val valueField: Long?, // raw "value" from listunspent (may be asset amount for asset UTXOs) + val isKnownAsset: Boolean, + val isUnknown: Boolean, // no "asset" field: needs raw-tx check for "88acc0" + val assetName: String?, + val assetAmount: Long? + ) + + val pending = mutableListOf() + for (obj in rawList) { + val txHash = obj.get("tx_hash")?.asString ?: continue + val txPos = obj.get("tx_pos")?.asInt ?: continue + val height = obj.get("height")?.asInt ?: 0 + val value = obj.get("value")?.asLong + val assetField = if (obj.has("asset")) obj.get("asset") else null + + when { + assetField == null || assetField.isJsonNull -> { + // No "asset" tag: server either omitted it or this is plain RVN + pending.add(PendingUtxo(txHash, txPos, height, value, false, true, null, null)) + } + assetField.isJsonPrimitive -> { + val name = runCatching { assetField.asString }.getOrDefault("") + if (name.isEmpty() || name == "RVN") { + pending.add(PendingUtxo(txHash, txPos, height, value, false, false, null, null)) + } else { + pending.add(PendingUtxo(txHash, txPos, height, value, true, false, name, value)) + } + } + else -> { + val ao = assetField.asJsonObject + val name = ao.get("name")?.asString ?: "" + val amount = ao.get("amount")?.asLong + if (name.isEmpty() || name == "RVN") { + pending.add(PendingUtxo(txHash, txPos, height, value, false, false, null, null)) + } else { + pending.add(PendingUtxo(txHash, txPos, height, value, true, false, name, amount)) + } + } + } + } + + // Collect txids that need a raw transaction fetch (unknown + known asset) + val txidsToFetch = pending + .filter { it.isKnownAsset || it.isUnknown } + .map { it.txHash } + .distinct() + + // Batch-fetch all raw transactions in one TLS connection (TLS 2) + val txCache = mutableMapOf() + if (txidsToFetch.isNotEmpty()) { + val requests = txidsToFetch.map { "blockchain.transaction.get" to listOf(it, true) as List } + val results = callWithFailoverBatch(requests) + txidsToFetch.forEachIndexed { i, txid -> + txCache[txid] = try { results[i]?.asJsonObject } catch (_: Exception) { null } + } + } + + // Build the three return collections + val rvnUtxos = mutableListOf() + val assetOutpoints = mutableSetOf() + val assetUtxosMap = mutableMapOf>() + + for (u in pending) { + val outpoint = "${u.txHash}:${u.txPos}" + when { + u.isKnownAsset -> { + // Asset UTXO: extract on-chain script and actual RVN satoshis from raw tx + assetOutpoints.add(outpoint) + val tx = txCache[u.txHash] + val vout = try { + tx?.getAsJsonArray("vout")?.get(u.txPos)?.asJsonObject + } catch (_: Exception) { null } + val satoshis = try { + val rvn = vout?.get("value")?.asDouble ?: 0.0 + (rvn * 100_000_000.0).toLong() + } catch (_: Exception) { 0L } + val onChainScript = try { + vout?.getAsJsonObject("scriptPubKey")?.get("hex")?.asString + } catch (_: Exception) { null } + val name = u.assetName ?: continue + val rawAmount = u.assetAmount ?: continue + val assetScript = onChainScript ?: if (name.endsWith("!")) { + buildOwnerAssetScriptHex(address, name) + } else { + buildAssetScriptHex(address, name, rawAmount) + } + val utxo = Utxo(u.txHash, u.txPos, satoshis, assetScript, u.height) + assetUtxosMap.getOrPut(name) { mutableListOf() }.add(AssetUtxo(utxo, name, rawAmount)) + } + u.isUnknown -> { + // No "asset" tag: check raw tx scriptPubKey for OP_RVN_ASSET marker "88acc0" + val tx = txCache[u.txHash] + val vout = try { + tx?.getAsJsonArray("vout")?.get(u.txPos)?.asJsonObject + } catch (_: Exception) { null } + val scriptHex = try { + vout?.getAsJsonObject("scriptPubKey")?.get("hex")?.asString + } catch (_: Exception) { null } + if (scriptHex != null && "88acc0" in scriptHex) { + assetOutpoints.add(outpoint) + // Parse asset name and amount directly from the script so the UTXO + // is properly included in assetUtxosMap (not just silently dropped). + val parsed = parseAssetFromScript(scriptHex) + if (parsed != null) { + val (assetName, rawAmount) = parsed + val satoshis = try { + ((vout?.get("value")?.asDouble ?: 0.0) * 100_000_000.0).toLong() + } catch (_: Exception) { 0L } + val utxo = Utxo(u.txHash, u.txPos, satoshis, scriptHex, u.height) + assetUtxosMap.getOrPut(assetName) { mutableListOf() } + .add(AssetUtxo(utxo, assetName, rawAmount)) + } else { + // Recognition failed but it has an asset marker: treat as RVN so it's at least swept + val satoshis = try { + ((vout?.get("value")?.asDouble ?: 0.0) * 100_000_000.0).toLong() + } catch (_: Exception) { u.valueField ?: 0L } + rvnUtxos.add(Utxo(u.txHash, u.txPos, satoshis, scriptHex, u.height)) + } + } else { + // Confirmed RVN or unknown (treat as RVN to avoid locking up funds) + val satoshis = u.valueField ?: continue + rvnUtxos.add(Utxo(u.txHash, u.txPos, satoshis, rvnScript, u.height)) + } + } + else -> { + // Explicitly tagged as plain RVN + val satoshis = u.valueField ?: continue + rvnUtxos.add(Utxo(u.txHash, u.txPos, satoshis, rvnScript, u.height)) + } + } + } + + // Secondary asset check: some ElectrumX servers (e.g. Ravencoin mainnet nodes) do not + // include asset UTXOs in blockchain.scripthash.listunspent. If listunspent returned no + // assets but get_balance?asset=true shows some, fetch them explicitly via getAssetUtxosFull. + if (assetUtxosMap.isEmpty()) { + try { + val assetBalances = getAssetBalances(address) + for (ab in assetBalances) { + try { + val utxos = getAssetUtxosFull(address, ab.name) + if (utxos.isNotEmpty()) { + assetUtxosMap.getOrPut(ab.name) { mutableListOf() }.addAll(utxos) + assetOutpoints.addAll(utxos.map { "${it.utxo.txid}:${it.utxo.outputIndex}" }) + android.util.Log.i("RavencoinPublicNode", " secondary: ${utxos.size} UTXOs for ${ab.name} via getAssetUtxosFull") + } + } catch (e: Exception) { + android.util.Log.w("RavencoinPublicNode", " secondary: getAssetUtxosFull failed for ${ab.name}: ${e.message}") + } + } + } catch (_: Exception) {} + } + + return Triple(rvnUtxos, assetOutpoints, assetUtxosMap) + } + /** * Returns metadata for [assetName] via the "blockchain.asset.get_meta" call. * @@ -561,6 +910,41 @@ class RavencoinPublicNode { } catch (_: Exception) { null } } + /** + * Fetch metadata for multiple assets in a single pipelined TLS connection. + * + * Equivalent to calling [getAssetMeta] N times, but uses one batch connection + * for all N [blockchain.asset.get_meta] requests instead of N separate connections. + * + * @param assetNames List of full asset names to look up. + * @return Map from asset name to [ElectrumAssetMeta] (null value if a specific + * asset was not found or its result could not be parsed). + */ + fun getAssetMetaBatch(assetNames: List): Map { + if (assetNames.isEmpty()) return emptyMap() + val reqs = assetNames.map { name -> + "blockchain.asset.get_meta" to listOf(name) as List + } + val resps = try { callWithFailoverBatch(reqs) } catch (_: Exception) { return emptyMap() } + val result = mutableMapOf() + assetNames.forEachIndexed { i, name -> + result[name] = try { + val obj = resps.getOrNull(i)?.asJsonObject ?: return@forEachIndexed + val hasIpfs = obj.get("has_ipfs").asFlexibleBoolean() + val ipfsHash = obj.get("ipfs")?.asString ?: obj.get("ipfs_hash")?.asString + ElectrumAssetMeta( + name = name, + totalSupply = obj.get("sats_in_circulation")?.asLong ?: 0L, + divisions = obj.get("divisions")?.asInt ?: 0, + reissuable = obj.get("reissuable").asFlexibleBoolean(), + hasIpfs = hasIpfs, + ipfsHash = if (hasIpfs) ipfsHash else null + ) + } catch (_: Exception) { null } + } + return result + } + /** * Returns up to [limit] transactions for [address], sorted newest-first. * @@ -585,44 +969,56 @@ class RavencoinPublicNode { * @param offset Number of entries to skip for pagination (default 0). * @return List of [TxHistoryEntry] sorted newest-first, empty on failure. */ - suspend fun getTransactionHistory(address: String, limit: Int = 15, offset: Int = 0): List = coroutineScope { - val currentHeight = try { getBlockHeight() ?: 0 } catch (_: Exception) { 0 } + fun getTransactionHistory( + address: String, + limit: Int = 15, + offset: Int = 0, + ownedAddresses: Set = setOf(address) + ): List { val scripthash = addressToScripthash(address) + val owned = if (ownedAddresses.isEmpty()) setOf(address) else ownedAddresses + // Hash160 of each owned address (lowercase hex). Asset outputs wrap a P2PKH + // payload inside an OP_RVN_ASSET script; some ElectrumX servers do not expose + // the inner address in `scriptPubKey.addresses`, so we fall back to hex match. + val ownedHashes: Set = owned.mapNotNull { addr -> + try { + val decoded = base58Decode(addr) + if (decoded.size < 21) null + else decoded.copyOfRange(1, 21).joinToString("") { "%02x".format(it) } + } catch (_: Exception) { null } + }.toSet() + + // Batch step 1: fetch block height + address history in a single TLS connection + val step1 = callWithFailoverBatch(listOf( + "blockchain.headers.subscribe" to emptyList(), + "blockchain.scripthash.get_history" to listOf(scripthash) + )) + val currentHeight = try { step1[0]?.asJsonObject?.get("height")?.asInt ?: 0 } catch (_: Exception) { 0 } val history = try { - callWithFailover("blockchain.scripthash.get_history", listOf(scripthash)) - .asJsonArray - .mapNotNull { try { it.asJsonObject } catch (_: Exception) { null } } - .sortedWith(compareByDescending { + step1[1]?.asJsonArray + ?.mapNotNull { try { it.asJsonObject } catch (_: Exception) { null } } + ?.sortedWith(compareByDescending { val h = it.get("height")?.asInt ?: 0 - // Unconfirmed transactions have height=0 or negative; sort them first - // by mapping 0/negative to Int.MAX_VALUE if (h <= 0) Int.MAX_VALUE else h }) - .drop(offset) - .take(limit) - } catch (_: Exception) { return@coroutineScope emptyList() } + ?.drop(offset) + ?.take(limit) + ?: emptyList() + } catch (_: Exception) { return emptyList() } - // Semaphore limits concurrent ElectrumX connections to avoid overwhelming servers - val requestLimiter = Semaphore(4) - suspend fun fetchTransaction(txId: String): JsonObject? = requestLimiter.withPermit { - try { - // Pass "true" to get the verbose/decoded JSON form (not just hex) - callWithFailover("blockchain.transaction.get", listOf(txId, true)).asJsonObject - } catch (_: Exception) { - null - } - } + if (history.isEmpty()) return emptyList() - // Fetch all current transactions in parallel - val txHashes = history.mapNotNull { it.get("tx_hash")?.asString } - val txMap = txHashes - .map { txId -> async { txId to fetchTransaction(txId) } } - .awaitAll() - .mapNotNull { (txId, tx) -> tx?.let { txId to it } } + val txHashes = history.mapNotNull { it.get("tx_hash")?.asString }.distinct() + + // Batch step 2: fetch all current-tx bodies in a single TLS connection + val txBatch = callWithFailoverBatch( + txHashes.map { "blockchain.transaction.get" to listOf(it, true) } + ) + val txMap = txHashes.zip(txBatch) + .mapNotNull { (txId, result) -> result?.let { txId to it.asJsonObject } } .toMap() - // Collect all previous transaction IDs referenced by the inputs of our transactions - // (needed to determine whether a vin was funded by our address) + // Collect prev-TX IDs from inputs (needed to compute fromUs for outgoing detection) val prevTxIds = txMap.values .flatMap { tx -> tx.getAsJsonArray("vin") @@ -632,72 +1028,135 @@ class RavencoinPublicNode { .orEmpty() } .distinct() - - // Fetch previous transactions that are not already in txMap (avoid redundant fetches) - val prevTxMap = prevTxIds .filterNot { txMap.containsKey(it) } - .map { txId -> async { txId to fetchTransaction(txId) } } - .awaitAll() - .mapNotNull { (txId, tx) -> tx?.let { txId to it } } - .toMap() - history.mapNotNull { item -> + // Batch step 3: fetch all prev-tx bodies in a single TLS connection + val prevTxMap: Map = if (prevTxIds.isNotEmpty()) { + val prevBatch = callWithFailoverBatch( + prevTxIds.map { "blockchain.transaction.get" to listOf(it, true) } + ) + prevTxIds.zip(prevBatch) + .mapNotNull { (txId, result) -> result?.let { txId to it.asJsonObject } } + .toMap() + } else emptyMap() + + return history.mapNotNull { item -> val txHash = item.get("tx_hash")?.asString ?: return@mapNotNull null val height = item.get("height")?.asInt ?: 0 val tx = txMap[txHash] ?: return@mapNotNull null - // Compute how much the transaction sent to our address (sum of matching vout values) - var toUs = 0L - var toOthers = 0L + // Classify vout per wallet ownership across ALL owned addresses so + // "cycled" (change back to wallet) is not mis-classified as "sent". + var toUs = 0L // vout back to any owned address (incl. change at currentIndex+1) + var toOthers = 0L // vout to external addresses (true external send) + var totalVout = 0L + var incomingAssetName: String? = null + var incomingAssetAmount: Long = 0L + var outgoingAssetName: String? = null + var outgoingAssetAmount: Long = 0L + val incomingAssetNamesSet = LinkedHashSet() tx.getAsJsonArray("vout")?.forEach { vout -> try { val obj = vout.asJsonObject - // vout value is in RVN (floating-point); multiply by 1e8 to get satoshis val valueSat = ((obj.get("value")?.asDouble ?: 0.0) * 1e8).toLong() + totalVout += valueSat val spk = obj.getAsJsonObject("scriptPubKey") val addresses = spk?.getAsJsonArray("addresses") - if (addresses?.any { it.asString == address } == true) toUs += valueSat - else toOthers += valueSat + val hex = spk?.get("hex")?.asString?.lowercase() ?: "" + val byAddr = addresses?.any { it.asString in owned } == true + val byHex = !byAddr && hex.isNotEmpty() && ownedHashes.any { hex.contains(it) } + val ours = byAddr || byHex + if (ours) toUs += valueSat else toOthers += valueSat + + // Detect asset payload (OP_RVN_ASSET) and tag it as incoming or + // outgoing depending on whether the output is to one of our addresses. + if (hex.contains("72766e")) { + parseAssetPayload(hex)?.let { (name, amount) -> + if (ours) { + incomingAssetNamesSet.add(name) + if (incomingAssetName == null) { + incomingAssetName = name; incomingAssetAmount = amount + } + } else { + if (outgoingAssetName == null) { + outgoingAssetName = name; outgoingAssetAmount = amount + } + } + } + } } catch (_: Exception) {} } - // Compute how much was spent from our address (sum of vin values where we owned the output) - var fromUs = 0L + var fromUs = 0L // prev-vout value consumed from our inputs + var totalVin = 0L // total input value (all vin, regardless of ownership) tx.getAsJsonArray("vin")?.forEach { vin -> try { val vinObj = vin.asJsonObject val prevTxId = vinObj.get("txid")?.asString ?: return@forEach val prevVoutIdx = vinObj.get("vout")?.asInt ?: return@forEach - // Look up the previous transaction in both caches val prevTx = txMap[prevTxId] ?: prevTxMap[prevTxId] ?: return@forEach val prevVoutObj = prevTx.getAsJsonArray("vout") ?.mapNotNull { try { it.asJsonObject } catch (_: Exception) { null } } ?.getOrNull(prevVoutIdx) ?: return@forEach val prevValueSat = ((prevVoutObj.get("value")?.asDouble ?: 0.0) * 1e8).toLong() + totalVin += prevValueSat val prevSpk = prevVoutObj.getAsJsonObject("scriptPubKey") val prevAddresses = prevSpk?.getAsJsonArray("addresses") - // Only count this input as "from us" if the previous output was ours - if (prevAddresses?.any { it.asString == address } == true) fromUs += prevValueSat + val prevByAddr = prevAddresses?.any { it.asString in owned } == true + val prevByHex = if (!prevByAddr) { + val hex = prevSpk?.get("hex")?.asString?.lowercase() ?: "" + hex.isNotEmpty() && ownedHashes.any { hex.contains(it) } + } else false + if (prevByAddr || prevByHex) fromUs += prevValueSat } catch (_: Exception) {} } - // Net from our perspective: positive = received, negative = sent val netSat = toUs - fromUs val confs = when { - height <= 0 -> 0 // unconfirmed + height <= 0 -> 0 currentHeight >= height -> currentHeight - height + 1 else -> 0 } - // Prefer "blocktime" (set when mined) over "time" (set when first seen in mempool) - val timestamp = tx.get("time")?.asLong ?: tx.get("blocktime")?.asLong ?: 0L + val timestamp = tx.get("blocktime")?.asLong ?: tx.get("time")?.asLong ?: 0L + // Fee only attributable to us when we contributed inputs. + val feeSat = if (fromUs > 0L && totalVin > totalVout) totalVin - totalVout else 0L + // Asset transfers can ride on a 0-sat dust output (Ravencoin allows this when + // the receiving address is also paid via a separate RVN output in the same tx). + // Include "asset to non-owned address" as outgoing even when toOthers == 0. + val isOutgoing = fromUs > 0L && (toOthers > 0L || outgoingAssetName != null) + val isSelfTransfer = fromUs > 0L && !isOutgoing && toUs > 0L + // The scripthash query returned this tx, so our address is involved in + // some way the parser may have missed (asset OP_RVN_ASSET script with + // no addresses[] and no inner hash160 hex match — happens on some + // ElectrumX server variants). Treat as incoming when nothing else + // tagged it as outgoing or self. + val isHiddenIncoming = !isOutgoing && !isSelfTransfer && fromUs == 0L && toUs == 0L TxHistoryEntry( txid = txHash, height = height, confirmations = confs, amountSat = if (netSat > 0) netSat else 0L, - sentSat = if (netSat < 0) -netSat else 0L, - isIncoming = netSat > 0, - timestamp = timestamp + sentSat = if (isOutgoing) toOthers else 0L, + cycledSat = if (isOutgoing || isSelfTransfer) toUs else 0L, + feeSat = feeSat, + isIncoming = (netSat > 0 && !isOutgoing) || isHiddenIncoming, + isSelfTransfer = isSelfTransfer, + timestamp = timestamp, + // For outgoing tx, prefer the asset sent to others; for incoming, the + // asset received. Self-transfer reports the cycled asset name. + assetName = when { + isOutgoing && outgoingAssetName != null -> outgoingAssetName + incomingAssetName != null -> incomingAssetName + isOutgoing -> outgoingAssetName + else -> null + }, + assetAmount = when { + isOutgoing && outgoingAssetName != null -> outgoingAssetAmount + incomingAssetName != null -> incomingAssetAmount + isOutgoing -> outgoingAssetAmount + else -> 0L + }, + incomingAssetNames = incomingAssetNamesSet.toList() ) } } @@ -709,7 +1168,7 @@ class RavencoinPublicNode { * @param address Ravencoin P2PKH address. * @return Total transaction count, or 0 on failure. */ - suspend fun getTransactionCount(address: String): Int { + fun getTransactionCount(address: String): Int { val scripthash = addressToScripthash(address) return try { val history = callWithFailover("blockchain.scripthash.get_history", listOf(scripthash)) @@ -718,6 +1177,201 @@ class RavencoinPublicNode { } catch (_: Exception) { 0 } } + /** + * D-23 lightweight paged history fetch used by the WalletScreen "Load more" button. + * + * Returns `TxHistoryEntry` shells (amount/sent fields = 0) so the UI can insert + * placeholder rows into [io.raventag.app.wallet.cache.TxHistoryDao] that are then + * enriched on the next authoritative refresh via [getTransactionHistory]. + * + * Unlike [getTransactionHistory], this helper: + * - Does NOT walk vin/vout to compute amounts (expensive full tx decode). + * - Reorders the list so mempool entries (height == 0) come first, then confirmed + * rows sorted by height DESC (newest-first). + * - Slices `[offset, offset + limit)` client-side. + * - Swallows exceptions and returns `emptyList()` so the Load more path is resilient. + * + * @param address Ravencoin P2PKH address. + * @param offset Zero-based offset into the newest-first ordered list. + * @param limit Max rows to return (default 20 per UI-SPEC Load more). + * @return List of shell [TxHistoryEntry] rows; empty on any failure. + */ + suspend fun getHistoryPaged( + address: String, + offset: Int, + limit: Int = 20 + ): List = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + try { + val scripthash = addressToScripthash(address) + // Batch: fetch tip height + history in one TLS connection, same pattern as getTransactionHistory. + val batch = callWithFailoverBatch(listOf( + "blockchain.headers.subscribe" to emptyList(), + "blockchain.scripthash.get_history" to listOf(scripthash) + )) + val currentHeight = try { + batch.getOrNull(0)?.asJsonObject?.get("height")?.asInt ?: 0 + } catch (_: Exception) { 0 } + val raw = try { + batch.getOrNull(1)?.asJsonArray + } catch (_: Exception) { null } + ?: return@withContext emptyList() + + val ordered = raw + .mapNotNull { try { it.asJsonObject } catch (_: Exception) { null } } + .sortedWith(Comparator { a, b -> + val ha = a.get("height")?.asInt ?: 0 + val hb = b.get("height")?.asInt ?: 0 + // mempool (<=0) sorts first, then confirmed by height DESC + val ka = if (ha <= 0) Int.MAX_VALUE else ha + val kb = if (hb <= 0) Int.MAX_VALUE else hb + kb.compareTo(ka) + }) + .drop(offset.coerceAtLeast(0)) + .take(limit.coerceAtLeast(0)) + + ordered.mapNotNull { item -> + val txHash = item.get("tx_hash")?.asString ?: return@mapNotNull null + val height = item.get("height")?.asInt ?: 0 + val confirmations = if (height > 0 && currentHeight > 0) { + (currentHeight - height + 1).coerceAtLeast(0) + } else 0 + TxHistoryEntry( + txid = txHash, + height = height, + confirmations = confirmations, + amountSat = 0L, + sentSat = 0L, + isIncoming = false, + isSelfTransfer = false, + timestamp = 0L + ) + } + } catch (_: Exception) { + emptyList() + } + } + + /** + * Returns true if [address] has any transaction history on-chain. + * + * @param address Ravencoin P2PKH address. + * @return true if the address has at least one on-chain transaction. + */ + fun hasHistory(address: String): Boolean { + val scripthash = addressToScripthash(address) + return try { + callWithFailover("blockchain.scripthash.get_history", listOf(scripthash)) + .asJsonArray.size() > 0 + } catch (_: Exception) { false } + } + + /** + * Tri-state classification of a Ravencoin address for address rotation. + * + * - [NO_HISTORY]: address has never appeared on-chain (completely unused). + * - [RECEIVE_ONLY]: address has received funds but never signed a transaction, + * so its public key has never been exposed on-chain (quantum-safe). + * - [HAS_OUTGOING]: address has signed at least one outgoing transaction, + * exposing its public key on-chain (quantum-vulnerable). + * + * Detection heuristic: if the number of unspent outputs (UTXOs) is strictly + * less than the number of history entries, at least one UTXO was consumed, + * which requires a signature that reveals the public key. + */ + enum class AddressStatus { NO_HISTORY, RECEIVE_ONLY, HAS_OUTGOING } + + /** + * Batch variant of [getAddressStatus] for many addresses at once. + * + * Uses two pipelined batch calls: + * 1. `get_history` for all addresses to identify which have on-chain history. + * 2. `listunspent` only for the subset with history (to distinguish RECEIVE_ONLY from HAS_OUTGOING). + * + * With 20 addresses this replaces up to 40 individual TLS connections with 2. + * + * @param addresses List of Ravencoin P2PKH addresses. + * @return Map from address to [AddressStatus]; missing entries default to [AddressStatus.NO_HISTORY]. + */ + fun getAddressStatusBatch(addresses: List): Map { + if (addresses.isEmpty()) return emptyMap() + val scripthashes = addresses.map { addressToScripthash(it) } + + // Batch 1: history for all + val histReqs = scripthashes.map { sh -> + "blockchain.scripthash.get_history" to listOf(sh) as List + } + val histResps = callWithFailoverBatch(histReqs) + + val result = mutableMapOf() + val histCounts = mutableMapOf() + val needsUtxo = mutableListOf() + + addresses.forEachIndexed { i, addr -> + val arr = histResps.getOrNull(i) + val n = if (arr != null && arr.isJsonArray) arr.asJsonArray.size() else 0 + if (n == 0) result[addr] = AddressStatus.NO_HISTORY + else { histCounts[i] = n; needsUtxo.add(i) } + } + + if (needsUtxo.isEmpty()) return result + + // Batch 2: get_balance(asset=true) for all addresses with history. + // Using balance instead of listunspent because listunspent is RVN-only — + // an address that received only an asset (and zero RVN dust) would otherwise + // report 0 UTXOs while having 1 history entry, mis-classifying it as HAS_OUTGOING. + // Balance with asset flag detects asset funds correctly. + val balReqs = needsUtxo.map { i -> + "blockchain.scripthash.get_balance" to listOf(scripthashes[i], true) as List + } + val balResps = callWithFailoverBatch(balReqs) + + needsUtxo.forEachIndexed { j, i -> + val addr = addresses[i] + val resp = balResps.getOrNull(j) + val hasFunds = if (resp != null && resp.isJsonObject) { + val obj = resp.asJsonObject + val rvnSat = (try { obj.get("confirmed")?.asLong ?: 0L } catch (_: Exception) { 0L }) + + (try { obj.get("unconfirmed")?.asLong ?: 0L } catch (_: Exception) { 0L }) + var funds = rvnSat > 0 + if (!funds) { + for ((k, v) in obj.entrySet()) { + if (k == "confirmed" || k == "unconfirmed") continue + try { + val a = v.asJsonObject + val sat = (a.get("confirmed")?.asLong ?: 0L) + (a.get("unconfirmed")?.asLong ?: 0L) + if (sat > 0) { funds = true; break } + } catch (_: Exception) {} + } + } + funds + } else { + // Conservative: if balance call failed, assume funds present so we don't + // wrongly advance the index. The next sync will re-evaluate. + true + } + result[addr] = if (hasFunds) AddressStatus.RECEIVE_ONLY else AddressStatus.HAS_OUTGOING + } + + return result + } + + /** + * Classifies [address] as unused, receive-only, or has-outgoing. + * Makes at most 2 ElectrumX calls (history + listunspent). + */ + fun getAddressStatus(address: String): AddressStatus { + val scripthash = addressToScripthash(address) + val history = try { + callWithFailover("blockchain.scripthash.get_history", listOf(scripthash)).asJsonArray + } catch (_: Exception) { return AddressStatus.NO_HISTORY } + if (history.size() == 0) return AddressStatus.NO_HISTORY + val utxos = try { + callWithFailover("blockchain.scripthash.listunspent", listOf(scripthash)).asJsonArray + } catch (_: Exception) { return AddressStatus.RECEIVE_ONLY } + return if (utxos.size() < history.size()) AddressStatus.HAS_OUTGOING + else AddressStatus.RECEIVE_ONLY + } + // Internal helpers ──────────────────────────────────────────────────────── /** @@ -758,6 +1412,75 @@ class RavencoinPublicNode { }.getOrDefault(false) } + /** + * Parse asset name and raw amount from an on-chain OP_RVN_ASSET scriptPubKey hex. + * + * Works on transfer scripts ("rvnt" marker, has 8-byte LE amount) and owner-token + * scripts ("rvno" marker, no amount field, always 100_000_000 raw units). + * Returns null if the script is not a recognised asset script or if parsing fails. + * + * Hex layout after the P2PKH prefix (...88acc0): + * "rvnt"|"rvno" <1-byte name len> [] + */ + private fun parseAssetFromScript(scriptHex: String): Pair? { + return try { + val idx = scriptHex.indexOf("88acc0") + if (idx < 0 || idx % 2 != 0) return null + // Byte position just after the 3-byte marker + var pos = idx / 2 + 3 + if (pos * 2 + 2 > scriptHex.length) return null + val pushByte = scriptHex.substring(pos * 2, pos * 2 + 2).toInt(16) + pos++ + val payloadLen = when { + pushByte in 1..75 -> pushByte + pushByte == 0x4c -> { // OP_PUSHDATA1 + if (pos * 2 + 2 > scriptHex.length) return null + val len = scriptHex.substring(pos * 2, pos * 2 + 2).toInt(16) + pos++ + len + } + pushByte == 0x4d -> { // OP_PUSHDATA2 + if (pos * 2 + 4 > scriptHex.length) return null + // 2 bytes LE + val low = scriptHex.substring(pos * 2, pos * 2 + 2).toInt(16) + val high = scriptHex.substring(pos * 2 + 2, pos * 2 + 4).toInt(16) + pos += 2 + (high shl 8) or low + } + else -> return null + } + val dataEnd = pos + payloadLen + if (dataEnd * 2 > scriptHex.length || payloadLen < 6) return null + // 4-byte type marker + val marker = buildString { + for (i in 0..3) append(scriptHex.substring((pos + i) * 2, (pos + i) * 2 + 2).toInt(16).toChar()) + } + val isTransfer = marker == "rvnt" + val isOwner = marker == "rvno" + val isIssue = marker == "rvnq" + val isReissue = marker == "rvnr" + if (!isTransfer && !isOwner && !isIssue && !isReissue) return null + var p = pos + 4 + // compact_size name length (1 byte; names are always < 253 chars) + val nameLen = scriptHex.substring(p * 2, p * 2 + 2).toInt(16) + p++ + if ((p + nameLen) * 2 > scriptHex.length) return null + val assetName = buildString { + for (i in 0 until nameLen) append(scriptHex.substring((p + i) * 2, (p + i) * 2 + 2).toInt(16).toChar()) + } + p += nameLen + val rawAmount: Long = if (isOwner) { + 100_000_000L + } else { + if ((p + 8) * 2 > scriptHex.length) return null + var amt = 0L + for (i in 0..7) amt = amt or (scriptHex.substring((p + i) * 2, (p + i) * 2 + 2).toLong(16) shl (8 * i)) + amt + } + Pair(assetName, rawAmount) + } catch (_: Exception) { null } + } + /** * Reconstructs the full asset transfer scriptPubKey for an address, asset name, and amount. * @@ -872,7 +1595,7 @@ class RavencoinPublicNode { * @param address Ravencoin P2PKH address. * @return Lowercase hex-encoded reversed SHA-256 of the scriptPubKey. */ - private fun addressToScripthash(address: String): String { + internal fun addressToScripthash(address: String): String { val decoded = base58Decode(address) require(decoded.size == 25) { "Invalid Ravencoin address (decoded=${decoded.size} bytes)" } val hash160 = decoded.copyOfRange(1, 21) @@ -920,6 +1643,54 @@ class RavencoinPublicNode { * @return Raw byte array. * @throws IllegalArgumentException if the string contains an invalid character. */ + /** + * Parse a Ravencoin OP_RVN_ASSET payload from a scriptPubKey hex string. + * Returns (assetName, rawAmount) when the script carries a transfer/issue/owner + * marker, null otherwise. Amount is the on-chain integer (sats * 10^divisions). + */ + private fun parseAssetPayload(hex: String): Pair? { + // "rvn" magic prefix in hex = 72 76 6e + var i = hex.indexOf("72766e") + while (i >= 0) { + // After "rvn" comes a 1-byte type marker: t=transfer, q=issue, o=owner, r=reissue. + val typeIdx = i + 6 + if (typeIdx + 2 > hex.length) return null + val type = hex.substring(typeIdx, typeIdx + 2) + if (type !in setOf("74", "71", "6f", "72")) { + i = hex.indexOf("72766e", i + 1); continue + } + // After the type byte, 1 byte = name length (hex pair). + val lenIdx = typeIdx + 2 + if (lenIdx + 2 > hex.length) return null + val nameLen = hex.substring(lenIdx, lenIdx + 2).toIntOrNull(16) ?: return null + if (nameLen <= 0 || nameLen > 32) { + i = hex.indexOf("72766e", i + 1); continue + } + val nameStart = lenIdx + 2 + val nameEnd = nameStart + nameLen * 2 + if (nameEnd > hex.length) return null + val nameBytes = ByteArray(nameLen) { k -> + hex.substring(nameStart + k * 2, nameStart + k * 2 + 2).toInt(16).toByte() + } + val name = String(nameBytes, Charsets.US_ASCII) + if (!name.all { it.isLetterOrDigit() || it in "/#_-." }) { + i = hex.indexOf("72766e", i + 1); continue + } + // Owner tokens (rvno) carry no amount — return amount 0. + if (type == "6f") return name to 0L + // For transfer / issue / reissue, 8 bytes amount little-endian follow. + val amtEnd = nameEnd + 16 + if (amtEnd > hex.length) return name to 0L + var amount = 0L + for (b in 0 until 8) { + val byteHex = hex.substring(nameEnd + b * 2, nameEnd + b * 2 + 2) + amount = amount or ((byteHex.toLong(16) and 0xff) shl (b * 8)) + } + return name to amount + } + return null + } + private fun base58Decode(input: String): ByteArray { var num = BigInteger.ZERO for (char in input) { @@ -951,16 +1722,160 @@ class RavencoinPublicNode { * @throws Exception listing all server errors if every server fails. */ private fun callWithFailover(method: String, params: List): com.google.gson.JsonElement { + io.raventag.app.wallet.health.NodeHealthMonitor.init(context) val errors = mutableListOf() - for (server in SERVERS) { + var lastError: Throwable? = null + repeat(SERVERS.size) { + val candidate = io.raventag.app.wallet.health.NodeHealthMonitor.nextHealthyNode() + ?: throw AllNodesUnreachableException() + val (host, portStr) = candidate.split(":", limit = 2) + val port = portStr.toInt() + val server = ElectrumServer(host, port) try { - return call(server, method, params) + val result = call(server, method, params) + io.raventag.app.wallet.health.NodeHealthMonitor.reportSuccess(candidate) + return result } catch (e: Exception) { + lastError = e Log.w(TAG, "Server ${server.host} failed for $method: ${e.message}") errors.add("${server.host}: ${e.message}") + if (isTofuMismatch(e)) { + io.raventag.app.wallet.health.NodeHealthMonitor.reportTofuMismatch(candidate) + } else { + io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( + candidate, + e.javaClass.simpleName + ) + } + } + } + throw lastError + ?: Exception("All ElectrumX servers failed for $method: ${errors.joinToString("; ")}") + } + + /** + * Detects the TofuTrustManager cert-mismatch exception. + * + * TofuTrustManager throws a plain Exception with message + * "Certificate mismatch for : expected , got " on a pinned + * cert change. Some TLS stacks wrap this in a CertificateException. We + * match both so NodeHealthMonitor can write the 1h quarantine row. + */ + private fun isTofuMismatch(e: Throwable): Boolean { + if (e is java.security.cert.CertificateException) return true + val m = e.message ?: return false + return m.contains("Certificate mismatch", ignoreCase = true) || + m.contains("fingerprint mismatch", ignoreCase = true) || + m.contains("TOFU", ignoreCase = true) + } + + /** + * Executes multiple JSON-RPC calls in a single TLS connection using ElectrumX pipelining. + * + * Sends all requests at once after the server.version handshake, then reads all responses + * matching them back to their requests via the JSON-RPC "id" field. This eliminates the + * per-call TCP+TLS handshake overhead: N calls cost 1 connection instead of N connections. + * + * Large batches are chunked at [BATCH_CHUNK_SIZE] to bound per-chunk socket timeout. + * + * @param server Target ElectrumX server. + * @param requests List of (method, params) pairs in any order. + * @return List of results in the same order as [requests]; null for each failed/errored request. + * @throws Exception on connection or TLS failure (triggers failover in [callWithFailoverBatch]). + */ + private fun callBatch( + server: ElectrumServer, + requests: List>> + ): List { + if (requests.isEmpty()) return emptyList() + if (requests.size > BATCH_CHUNK_SIZE) { + return requests.chunked(BATCH_CHUNK_SIZE).flatMap { callBatch(server, it) } + } + val sslCtx = SSLContext.getInstance("TLS") + sslCtx.init(null, arrayOf(TofuTrustManager(context, server.host)), SecureRandom()) + val rawSocket = java.net.Socket() + rawSocket.connect(InetSocketAddress(server.host, server.port), CONNECT_TIMEOUT_MS) + val sslSocket = sslCtx.socketFactory.createSocket(rawSocket, server.host, server.port, true) as SSLSocket + // Scale timeout with batch size so the last response has time to arrive + sslSocket.soTimeout = READ_TIMEOUT_MS + requests.size * 500 + return sslSocket.use { sock -> + val writer = PrintWriter(sock.outputStream, true) + val reader = BufferedReader(InputStreamReader(sock.inputStream)) + // Handshake + val hsId = idCounter.getAndIncrement() + writer.println("""{"id":$hsId,"method":"server.version","params":["RavenTag/1.0","1.4"]}""") + reader.readLine() + // Send all requests and remember id -> index mapping + val idToIndex = mutableMapOf() + for ((index, req) in requests.withIndex()) { + val (method, params) = req + val id = idCounter.getAndIncrement() + idToIndex[id] = index + writer.println(gson.toJson(mapOf("id" to id, "method" to method, "params" to params))) + } + // Read all responses + val results = arrayOfNulls(requests.size) + var received = 0 + while (received < requests.size) { + val line = reader.readLine() ?: break + received++ + try { + val json = JsonParser.parseString(line).asJsonObject + val id = json.get("id")?.asInt ?: continue + val index = idToIndex[id] ?: continue + val err = json.get("error") + if (err != null && !err.isJsonNull) continue + results[index] = json.get("result") + } catch (_: Exception) {} + } + results.toList() + } + } + + /** + * Pipelined multi-call with automatic server failover. + * + * Tries each server in [SERVERS] order. Returns a null-filled list only when + * every server fails (network unreachable or all timeout). Individual request + * errors within a successful batch are represented as null entries. + * + * @param requests List of (method, params) pairs. + * @return Results in the same order as [requests]; null per failed request. + */ + private fun callWithFailoverBatch(requests: List>>): List { + if (requests.isEmpty()) return emptyList() + io.raventag.app.wallet.health.NodeHealthMonitor.init(context) + repeat(SERVERS.size) { + val candidate = io.raventag.app.wallet.health.NodeHealthMonitor.nextHealthyNode() + ?: run { + Log.w(TAG, "All nodes quarantined for batch of ${requests.size} — falling back to per-request singles") + // Sequential single-RPC fallback: slower but resilient when batch + // pipelining fails on every server (common on flaky mobile networks + // where the first batch hits a TLS race that closes the socket). + return requests.map { (method, params) -> + try { callWithFailover(method, params) } catch (_: Exception) { null } + } + } + val (host, portStr) = candidate.split(":", limit = 2) + val server = ElectrumServer(host, portStr.toInt()) + try { + val result = callBatch(server, requests) + io.raventag.app.wallet.health.NodeHealthMonitor.reportSuccess(candidate) + return result + } catch (e: Exception) { + Log.w(TAG, "Server ${server.host} failed for batch(${requests.size}): ${e.message}") + if (isTofuMismatch(e)) { + io.raventag.app.wallet.health.NodeHealthMonitor.reportTofuMismatch(candidate) + } else { + io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( + candidate, + e.javaClass.simpleName + ) + } } } - throw Exception("All ElectrumX servers failed for $method: ${errors.joinToString("; ")}") + Log.w(TAG, "All servers failed for batch of ${requests.size} requests") + return List(requests.size) { null } } /** @@ -986,7 +1901,7 @@ class RavencoinPublicNode { private fun call(server: ElectrumServer, method: String, params: List): com.google.gson.JsonElement { // Create a TLS context with TOFU certificate validation for this server val sslCtx = SSLContext.getInstance("TLS") - sslCtx.init(null, arrayOf(TofuTrustManager(server.host)), SecureRandom()) + sslCtx.init(null, arrayOf(TofuTrustManager(context, server.host)), SecureRandom()) // Connect TCP first with the connect timeout, then upgrade to TLS val rawSocket = java.net.Socket() @@ -1017,41 +1932,86 @@ class RavencoinPublicNode { return json.get("result") ?: throw Exception("Null result from ${server.host}") } } +} + +/** + * D-19 three-value accounting helpers. Pure functions: no network, no storage, + * safe to unit-test in isolation. + * + * Semantics operate on a raw JSON transaction object returned by + * `blockchain.transaction.get` with verbose=true, i.e. an object with a `vout` + * array of `{ value: Double (RVN), scriptPubKey: { addresses: [...] } }` entries. + * + * Two concepts: + * - "cycled" = outputs paying the wallet's change/consolidation address (never-spent + * address at currentIndex + 1). This is the RVN that remains under the user's control + * after an outgoing send. + * - "sent" = outputs paying ANY address != changeAddress. For self-transfers + * (pure consolidations) this returns 0. + */ +object RavencoinTxHistoryMath { + + private const val SAT_PER_RVN = 100_000_000L + + /** + * Sum (in satoshis) of vout entries whose scriptPubKey.addresses contains + * [changeAddress]. Malformed entries contribute 0. + */ + fun computeCycledSat( + tx: com.google.gson.JsonObject, + changeAddress: String + ): Long { + val vout = try { tx.getAsJsonArray("vout") } catch (_: Exception) { null } + ?: return 0L + var total = 0L + for (element in vout) { + try { + val out = element.asJsonObject + val addresses = out + .getAsJsonObject("scriptPubKey") + ?.getAsJsonArray("addresses") + ?: continue + val hasChange = addresses.any { it.asString == changeAddress } + if (hasChange) { + val rvn = out.get("value")?.asDouble ?: 0.0 + total += (rvn * SAT_PER_RVN).toLong() + } + } catch (_: Exception) { + // skip malformed output + } + } + return total + } /** - * TOFU (Trust On First Use) TrustManager for ElectrumX self-signed TLS certificates. - * - * Standard certificate authority validation is not used because ElectrumX servers - * commonly use self-signed certificates. TOFU provides a practical security model: - * - * - First connection to a host: the server's SHA-256 fingerprint is computed from - * the raw DER-encoded certificate bytes and stored in the in-process [certCache]. - * The connection is allowed. - * - Subsequent connections to the same host: the fingerprint is verified against - * the cached value. If it differs, the connection is rejected with an exception - * to protect against man-in-the-middle attacks. - * - * Limitation: the cache is not persisted, so a certificate change across process - * restarts is silently accepted (pinned fresh). This is an acceptable trade-off - * for a mobile wallet that rotates processes frequently. - * - * @param host Hostname of the ElectrumX server, used as the cache key. + * Sum (in satoshis) of vout entries whose scriptPubKey.addresses contains + * AT LEAST ONE address != [changeAddress]. Conservative: multi-sig outputs + * with any non-change leg are counted as "sent" for their full value. + * Malformed entries contribute 0. */ - private class TofuTrustManager(private val host: String) : X509TrustManager { - override fun getAcceptedIssuers(): Array = emptyArray() - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) { - val cert = chain?.firstOrNull() ?: throw Exception("No certificate from $host") - // Compute SHA-256 fingerprint of the raw DER-encoded certificate - val fingerprint = MessageDigest.getInstance("SHA-256").digest(cert.encoded) - .joinToString("") { "%02x".format(it) } - // putIfAbsent returns the existing value if already pinned, or null if this is the first pin - val existing = certCache.putIfAbsent(host, fingerprint) - if (existing != null && existing != fingerprint) { - // Certificate changed since last pin: possible MITM, reject immediately - throw Exception("Certificate mismatch for $host: expected $existing, got $fingerprint") + fun computeSentSat( + tx: com.google.gson.JsonObject, + changeAddress: String + ): Long { + val vout = try { tx.getAsJsonArray("vout") } catch (_: Exception) { null } + ?: return 0L + var total = 0L + for (element in vout) { + try { + val out = element.asJsonObject + val addresses = out + .getAsJsonObject("scriptPubKey") + ?.getAsJsonArray("addresses") + ?: continue + val external = addresses.any { it.asString != changeAddress } + if (external) { + val rvn = out.get("value")?.asDouble ?: 0.0 + total += (rvn * SAT_PER_RVN).toLong() + } + } catch (_: Exception) { + // skip malformed output } - if (existing == null) Log.i(TAG, "TOFU: pinned $host") } + return total } } diff --git a/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt b/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt index ef888bb..cdcafa3 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/RavencoinTxBuilder.kt @@ -104,6 +104,18 @@ object RavencoinTxBuilder { /** Hex-encoded raw transaction and its txid (both in display/broadcast form). */ data class SignedTx(val hex: String, val txid: String) + /** + * A RVN UTXO paired with the signing key for its address. + * Used when a transaction spans inputs from multiple HD addresses. + */ + data class KeyedUtxo(val utxo: Utxo, val privKey: ByteArray, val pubKey: ByteArray) + + /** + * An asset UTXO paired with the signing key for its address. + * Used when a transaction spans inputs from multiple HD addresses. + */ + data class KeyedAssetUtxo(val assetUtxo: AssetUtxo, val privKey: ByteArray, val pubKey: ByteArray) + // ── Public API: RVN transfer ────────────────────────────────────────────── /** @@ -247,8 +259,405 @@ object RavencoinTxBuilder { return SignedTx(raw.toHex(), txid) } + // ── Public API: multi-asset transfer (post-quantum safe) ───────────────── + + /** + * A single asset output in a multi-asset transfer transaction. + * + * @param assetName Name of the asset (e.g. "BRAND/ITEM#SN001") + * @param rawAmount Raw asset amount (display_amount * 10^8) + * @param toAddress Recipient Ravencoin address + */ + data class AssetOutput( + val assetName: String, + val rawAmount: Long, + val toAddress: String + ) + + /** + * Build and sign a Ravencoin multi-asset transfer transaction with post-quantum protection. + * + * This method transfers MULTIPLE different assets in a SINGLE transaction: + * - The primary asset goes to an external destination address + * - ALL other remaining assets go to a fresh address (currentIndex + 1) + * - ALL remaining RVN goes to the fresh address (currentIndex + 1) + * + * This ensures the current address is completely emptied in one atomic + * transaction, preserving post-quantum security. + * + * Output order (Ravencoin consensus: P2PKH before OP_RVN_ASSET): + * 1. RVN change to [changeAddress] (omitted if below dust limit) + * 2. Primary asset output to [primaryAssetOutput] + * 3. Primary asset change (if partial transfer) + * 4. All other asset outputs to [changeAddress] + * + * @param primaryAssetUtxos UTXOs carrying the primary asset being transferred externally + * @param otherAssetUtxos Map of other asset names to their AssetUtxos (all go to changeAddress) + * @param rvnUtxos RVN-only UTXOs for fee coverage + * @param primaryAssetOutput Primary asset output (name, amount, external destination) + * @param primaryAssetChange Amount of primary asset to return to changeAddress (0 for full transfer) + * @param feeSat Miner fee in satoshis + * @param changeAddress Fresh address that receives all remaining assets and RVN + * @param privKeyBytes Raw 32-byte private key + * @param pubKeyBytes Compressed 33-byte public key + * @return [SignedTx] ready to broadcast + */ + fun buildAndSignMultiAssetTransfer( + primaryAssetUtxos: List, + otherAssetUtxos: Map>, + rvnUtxos: List, + primaryAssetOutput: AssetOutput, + primaryAssetChange: Long, + feeSat: Long, + changeAddress: String, + privKeyBytes: ByteArray, + pubKeyBytes: ByteArray + ): SignedTx { + // Calculate input dust for each asset type to preserve value balance + val primaryAssetDustIn = primaryAssetUtxos.sumOf { it.satoshis } + + // Dust amounts: 0 if input had 0 satoshis, otherwise 600 per output + val dustForPrimaryRecipient = if (primaryAssetDustIn > 0) 600L else 0L + val dustForPrimaryChange = if (primaryAssetChange > 0 && primaryAssetDustIn > 0) 600L else 0L + + // Calculate dust for other assets and build output list + val otherAssetOutputs = mutableListOf() + var dustForOtherAssets = 0L + for ((assetName, utxos) in otherAssetUtxos) { + val totalRawAmount = utxos.sumOf { it.assetRawAmount } + if (totalRawAmount > 0) { + otherAssetOutputs.add(AssetOutput(assetName, totalRawAmount, changeAddress)) + val inputDust = utxos.sumOf { it.utxo.satoshis } + if (inputDust > 0) dustForOtherAssets += 600L + } + } + + // RVN change from RVN-only inputs after fee and dust + val rvnFromRvnUtxosOnly = rvnUtxos.sumOf { it.satoshis } + val totalDustForAssetOutputs = dustForPrimaryRecipient + dustForPrimaryChange + dustForOtherAssets + + val rvnChange = rvnFromRvnUtxosOnly - feeSat - totalDustForAssetOutputs + require(rvnChange >= 0 || (rvnFromRvnUtxosOnly >= feeSat)) { + "Insufficient RVN for fee and dust: have ${rvnFromRvnUtxosOnly / 1e8} RVN, " + + "need ${feeSat / 1e8} RVN fee + ${totalDustForAssetOutputs / 1e8} RVN dust" + } + + // Combine all inputs: primary asset + other assets + RVN + val allInputs = mutableListOf() + allInputs.addAll(primaryAssetUtxos) + otherAssetUtxos.values.forEach { allInputs.addAll(it.map { au -> au.utxo }) } + allInputs.addAll(rvnUtxos) + + // Build outputs in consensus order: P2PKH first, then OP_RVN_ASSET + val outputs = mutableListOf() + + // 1. RVN change (P2PKH) + val effectiveRvnChange = if (rvnChange > 546) rvnChange else 0L + if (effectiveRvnChange > 0) { + outputs.add(ScriptedOutput(effectiveRvnChange, p2pkhScript(changeAddress))) + } + + // 2. Primary asset to external destination + outputs.add(ScriptedOutput(dustForPrimaryRecipient, + buildAssetTransferScript(primaryAssetOutput.toAddress, primaryAssetOutput.assetName, primaryAssetOutput.rawAmount))) + + // 3. Primary asset change (if partial transfer) + if (primaryAssetChange > 0) { + outputs.add(ScriptedOutput(dustForPrimaryChange, + buildAssetTransferScript(changeAddress, primaryAssetOutput.assetName, primaryAssetChange))) + } + + // 4. All other assets to changeAddress + for (assetOutput in otherAssetOutputs) { + val inputDust = otherAssetUtxos[assetOutput.assetName]?.sumOf { it.utxo.satoshis } ?: 0L + val dustForThisOutput = if (inputDust > 0) 600L else 0L + outputs.add(ScriptedOutput(dustForThisOutput, + buildAssetTransferScript(changeAddress, assetOutput.assetName, assetOutput.rawAmount))) + } + + // Sign each input + val signatures = allInputs.mapIndexed { idx, utxo -> + val sigHash = sigHashWithScriptedOutputs(allInputs, outputs, idx, utxo.script) + signEcdsa(sigHash, privKeyBytes) + } + + val raw = serializeTxWithScripts(allInputs, outputs, signatures, pubKeyBytes) + val txid = txid(raw) + return SignedTx(raw.toHex(), txid) + } + + // ── Public API: multi-address asset transfer (post-quantum safe) ──────── + + /** + * Build and sign an asset transfer where inputs may span multiple HD addresses. + * + * Each input is signed with its own key pair so inputs from any combination of + * BIP44 derived addresses are supported. Typical use: the target asset lives on + * an old HAS_OUTGOING address while RVN for fees is on the current address. + * + * Output order (Ravencoin consensus: P2PKH before OP_RVN_ASSET): + * 1. RVN change to [changeAddress] + * 2. Primary asset to [primaryAsset.toAddress] + * 3. Primary asset change to [changeAddress] (omitted if full balance sent) + * 4. All other assets to [changeAddress] + * + * @param primaryAssetInputs Keyed UTXOs for the asset being transferred + * @param otherAssetInputs Keyed UTXOs for all other assets (swept to changeAddress) + * @param rvnInputs Keyed UTXOs providing RVN for fees and dust + * @param primaryAsset Output descriptor: asset name, raw amount, recipient address + * @param primaryAssetChange Raw amount of primary asset returned as change (0 = full sweep) + * @param feeSat Miner fee in satoshis + * @param changeAddress Destination for all change (RVN, asset change, other assets) + */ + fun buildAndSignMultiAddressAssetTransfer( + primaryAssetInputs: List, + otherAssetInputs: Map>, + rvnInputs: List, + primaryAsset: AssetOutput, + primaryAssetChange: Long, + feeSat: Long, + changeAddress: String + ): SignedTx { + val primaryDustIn = primaryAssetInputs.sumOf { it.assetUtxo.utxo.satoshis } + val dustForPrimaryOut = if (primaryDustIn > 0) 600L else 0L + val dustForPrimaryChange = if (primaryAssetChange > 0 && primaryDustIn > 0) 600L else 0L + + val otherOutputs = mutableListOf() + var dustForOtherAssets = 0L + for ((name, keyedUtxos) in otherAssetInputs) { + val totalRaw = keyedUtxos.sumOf { it.assetUtxo.assetRawAmount } + if (totalRaw > 0) { + otherOutputs.add(AssetOutput(name, totalRaw, changeAddress)) + if (keyedUtxos.sumOf { it.assetUtxo.utxo.satoshis } > 0) dustForOtherAssets += 600L + } + } + + val totalDust = dustForPrimaryOut + dustForPrimaryChange + dustForOtherAssets + val rvnIn = rvnInputs.sumOf { it.utxo.satoshis } + + primaryAssetInputs.sumOf { it.assetUtxo.utxo.satoshis } + + otherAssetInputs.values.flatten().sumOf { it.assetUtxo.utxo.satoshis } + val rvnChange = rvnIn - feeSat - totalDust + + require(rvnIn >= feeSat + totalDust) { + "Insufficient RVN: have ${rvnIn / 1e8}, need ${feeSat / 1e8} fee + ${totalDust / 1e8} dust" + } + + data class InputEntry(val utxo: Utxo, val privKey: ByteArray, val pubKey: ByteArray) + val allEntries = mutableListOf() + primaryAssetInputs.forEach { allEntries.add(InputEntry(it.assetUtxo.utxo, it.privKey, it.pubKey)) } + otherAssetInputs.values.flatten().forEach { allEntries.add(InputEntry(it.assetUtxo.utxo, it.privKey, it.pubKey)) } + rvnInputs.forEach { allEntries.add(InputEntry(it.utxo, it.privKey, it.pubKey)) } + + val outputs = mutableListOf() + // P2PKH outputs first + val effectiveRvnChange = if (rvnChange > 546) rvnChange else 0L + if (effectiveRvnChange > 0) outputs.add(ScriptedOutput(effectiveRvnChange, p2pkhScript(changeAddress))) + // OP_RVN_ASSET outputs after + outputs.add(ScriptedOutput(dustForPrimaryOut, + buildAssetTransferScript(primaryAsset.toAddress, primaryAsset.assetName, primaryAsset.rawAmount))) + if (primaryAssetChange > 0) { + outputs.add(ScriptedOutput(dustForPrimaryChange, + buildAssetTransferScript(changeAddress, primaryAsset.assetName, primaryAssetChange))) + } + for (ao in otherOutputs) { + val inputDust = otherAssetInputs[ao.assetName]?.sumOf { it.assetUtxo.utxo.satoshis } ?: 0L + outputs.add(ScriptedOutput(if (inputDust > 0) 600L else 0L, + buildAssetTransferScript(changeAddress, ao.assetName, ao.rawAmount))) + } + + val allInputs = allEntries.map { it.utxo } + val sigsAndKeys = allEntries.mapIndexed { idx, entry -> + val sigHash = sigHashWithScriptedOutputs(allInputs, outputs, idx, entry.utxo.script) + Pair(signEcdsa(sigHash, entry.privKey), entry.pubKey) + } + val raw = serializeTxMultiKey(allInputs, outputs, sigsAndKeys) + return SignedTx(raw.toHex(), txid(raw)) + } + + // ── Public API: RVN send with asset sweep (post-quantum safe) ──────────── + + /** + * Build and sign a Ravencoin RVN send transaction that also sweeps ALL assets + * to a fresh address in a SINGLE transaction. + * + * This ensures post-quantum safety by completely emptying the current address: + * - The requested RVN amount goes to an external destination + * - ALL assets are transferred to a fresh address (currentIndex + 1) + * - All remaining RVN goes to the fresh address (currentIndex + 1) + * + * Output order (Ravencoin consensus: P2PKH before OP_RVN_ASSET): + * 1. RVN to external destination + * 2. RVN change to [changeAddress] (omitted if below dust limit) + * 3. All asset outputs to [changeAddress] + * + * @param rvnUtxos RVN-only UTXOs (must cover amount + fee + dust for assets) + * @param assetUtxos Map of asset names to their AssetUtxos (all swept to changeAddress) + * @param toAddress External destination for RVN + * @param amountSat RVN amount to send in satoshis + * @param feeSat Miner fee in satoshis + * @param changeAddress Fresh address that receives all assets and remaining RVN + * @param privKeyBytes Raw 32-byte private key + * @param pubKeyBytes Compressed 33-byte public key + * @return [SignedTx] ready to broadcast + */ + fun buildAndSignRvnSendWithAssetSweep( + rvnUtxos: List, + assetUtxos: Map>, + toAddress: String, + amountSat: Long, + feeSat: Long, + changeAddress: String, + privKeyBytes: ByteArray, + pubKeyBytes: ByteArray + ): SignedTx { + // Calculate dust for all asset outputs + var dustForAssets = 0L + val assetOutputs = mutableListOf() + + for ((assetName, utxos) in assetUtxos) { + val totalRawAmount = utxos.sumOf { it.assetRawAmount } + if (totalRawAmount > 0) { + assetOutputs.add(AssetOutput(assetName, totalRawAmount, changeAddress)) + val inputDust = utxos.sumOf { it.utxo.satoshis } + if (inputDust > 0) dustForAssets += 600L + } + } + + // Total RVN needed: amount + fee + dust for assets + // Include satoshis from BOTH RVN-only and asset-carrying UTXOs + val totalIn = rvnUtxos.sumOf { it.satoshis } + assetUtxos.values.flatten().sumOf { it.utxo.satoshis } + val rvnChange = totalIn - amountSat - feeSat - dustForAssets + + require(totalIn >= amountSat + feeSat + dustForAssets) { + "Insufficient RVN: have ${totalIn / 1e8} RVN, " + + "need ${amountSat / 1e8} RVN + ${feeSat / 1e8} RVN fee + ${dustForAssets / 1e8} RVN dust" + } + + // Combine all inputs: RVN + assets + val allInputs = mutableListOf() + allInputs.addAll(rvnUtxos) + assetUtxos.values.forEach { allInputs.addAll(it.map { au -> au.utxo }) } + + // Build outputs in consensus order: all P2PKH first, then OP_RVN_ASSET + val outputs = mutableListOf() + + // 1. RVN to external destination (P2PKH) + outputs.add(ScriptedOutput(amountSat, p2pkhScript(toAddress))) + + // 2. RVN change (P2PKH, must come before OP_RVN_ASSET outputs) + val effectiveRvnChange = if (rvnChange > 546) rvnChange else 0L + if (effectiveRvnChange > 0) { + outputs.add(ScriptedOutput(effectiveRvnChange, p2pkhScript(changeAddress))) + } + + // 3. All assets to changeAddress (OP_RVN_ASSET, always after all P2PKH outputs) + for (assetOutput in assetOutputs) { + val inputDust = assetUtxos[assetOutput.assetName]?.sumOf { it.utxo.satoshis } ?: 0L + val dustForThisOutput = if (inputDust > 0) 600L else 0L + outputs.add(ScriptedOutput(dustForThisOutput, + buildAssetTransferScript(changeAddress, assetOutput.assetName, assetOutput.rawAmount))) + } + + // Sign each input + val signatures = allInputs.mapIndexed { idx, utxo -> + val sigHash = sigHashWithScriptedOutputs(allInputs, outputs, idx, utxo.script) + signEcdsa(sigHash, privKeyBytes) + } + + val raw = serializeTxWithScripts(allInputs, outputs, signatures, pubKeyBytes) + val txid = txid(raw) + return SignedTx(raw.toHex(), txid) + } + // ── Signature hash (BIP143 NOT used, Ravencoin uses legacy P2PKH signing) ── + /** + * Build and sign a single atomic transaction that sends RVN to an external address + * while sweeping ALL remaining assets and RVN from ANY number of HD addresses to + * a fresh quantum-safe change address. + * + * Each input is signed with the private key of its own address, so inputs from + * multiple BIP44 derived addresses are fully supported. + * + * Input groups: + * [currentRvnInputs] - RVN UTXOs from the current spending address. These fund + * the [amountSat] send, the fee, and the dust for asset outputs. + * [extraRvnInputs] - RVN UTXOs from old HAS_OUTGOING addresses, swept to [changeAddress]. + * [assetInputsByName] - All asset UTXOs (any address) keyed by asset name, swept to [changeAddress]. + * + * Output order: + * 1. [amountSat] RVN to [toAddress] + * 2. RVN change to [changeAddress] + * 3. Each asset (full balance) to [changeAddress] + */ + fun buildAndSignMultiAddressSend( + currentRvnInputs: List, + extraRvnInputs: List, + assetInputsByName: Map>, + toAddress: String, + amountSat: Long, + feeSat: Long, + changeAddress: String + ): SignedTx { + // Dust for asset outputs: 0 if input had 0 sat (issued with dustOut=0), else 600 + val assetOutputs = mutableListOf() + var dustForAssets = 0L + for ((assetName, keyedUtxos) in assetInputsByName) { + val totalRaw = keyedUtxos.sumOf { it.assetUtxo.assetRawAmount } + if (totalRaw > 0) { + assetOutputs.add(AssetOutput(assetName, totalRaw, changeAddress)) + val inputDust = keyedUtxos.sumOf { it.assetUtxo.utxo.satoshis } + if (inputDust > 0) dustForAssets += 600L + } + } + + val currentRvnTotal = currentRvnInputs.sumOf { it.utxo.satoshis } + val extraRvnTotal = extraRvnInputs.sumOf { it.utxo.satoshis } + // Include satoshis from all asset inputs (dust) + val assetRvnTotal = assetInputsByName.values.flatten().sumOf { it.assetUtxo.utxo.satoshis } + val totalRvnIn = currentRvnTotal + extraRvnTotal + assetRvnTotal + + require(totalRvnIn >= amountSat + feeSat + dustForAssets) { + "Insufficient RVN: have ${totalRvnIn / 1e8} RVN, " + + "need ${amountSat / 1e8} send + ${feeSat / 1e8} fee + ${dustForAssets / 1e8} dust" + } + + // Build ordered input list (current RVN, extra RVN, assets) + data class InputEntry(val utxo: Utxo, val privKey: ByteArray, val pubKey: ByteArray) + val allEntries = mutableListOf() + currentRvnInputs.forEach { allEntries.add(InputEntry(it.utxo, it.privKey, it.pubKey)) } + extraRvnInputs.forEach { allEntries.add(InputEntry(it.utxo, it.privKey, it.pubKey)) } + assetInputsByName.values.flatten().forEach { + allEntries.add(InputEntry(it.assetUtxo.utxo, it.privKey, it.pubKey)) + } + + // Build outputs: consensus order: P2PKH first (external + change), then OP_RVN_ASSET + val rvnChange = totalRvnIn - amountSat - feeSat - dustForAssets + val outputs = mutableListOf() + + // 1. External RVN (P2PKH) + outputs.add(ScriptedOutput(amountSat, p2pkhScript(toAddress))) + + // 2. RVN change (P2PKH, must come before OP_RVN_ASSET) + if (rvnChange > 546) outputs.add(ScriptedOutput(rvnChange, p2pkhScript(changeAddress))) + + // 3. Asset sweep (OP_RVN_ASSET, always after all P2PKH outputs) + for (ao in assetOutputs) { + val inputDust = assetInputsByName[ao.assetName]?.sumOf { it.assetUtxo.utxo.satoshis } ?: 0L + val dustForThisOutput = if (inputDust > 0) 600L else 0L + outputs.add(ScriptedOutput(dustForThisOutput, buildAssetTransferScript(changeAddress, ao.assetName, ao.rawAmount))) + } + + val allInputs = allEntries.map { it.utxo } + val sigsAndKeys = allEntries.mapIndexed { idx, entry -> + val sigHash = sigHashWithScriptedOutputs(allInputs, outputs, idx, entry.utxo.script) + Pair(signEcdsa(sigHash, entry.privKey), entry.pubKey) + } + + val raw = serializeTxMultiKey(allInputs, outputs, sigsAndKeys) + return SignedTx(raw.toHex(), txid(raw)) + } + /** * Compute the legacy P2PKH signature hash for input at [sigIdx]. * @@ -515,8 +924,9 @@ object RavencoinTxBuilder { outputs.add(ScriptedOutput(0L, preservedOwnerScript)) } if (!isUnique) { - // Root/sub issuance always mints the new owner token output. - val ownerScript = buildOwnerTokenScript(toAddress, assetName) + // The new owner token ALWAYS goes to changeAddress (the issuer's next address), + // never to toAddress which may be an external recipient. + val ownerScript = buildOwnerTokenScript(changeAddress, assetName) outputs.add(ScriptedOutput(ownerDust, ownerScript)) } // Issuance output is always last (consensus rule). @@ -531,6 +941,256 @@ object RavencoinTxBuilder { return SignedTx(raw.toHex(), txid(raw)) } + // ── Public API: asset issuance with asset sweep (post-quantum safe) ────── + + /** + * Build and sign a Ravencoin asset issuance transaction that also sweeps ALL + * other existing assets to a fresh address in a SINGLE transaction. + * + * This ensures post-quantum safety by completely emptying the current address: + * - The new asset is issued to [toAddress] + * - ALL other existing assets are transferred to [changeAddress] (currentIndex + 1) + * - All remaining RVN goes to [changeAddress] + * + * Output order (Ravencoin consensus, assets.cpp): + * 1. Burn output: [burnSat] RVN to the canonical issuance burn address + * 2. RVN change to [changeAddress] (omitted if below dust limit) + * 3. Asset sweep outputs: all other assets to [changeAddress] (OP_RVN_ASSET) + * 4. Parent owner-token return (rvnt): for sub-assets/unique tokens + * 5. New owner-token output (rvno): for root/sub-assets + * 6. Issuance output (rvnq): always last (consensus requirement) + * + * @param utxos RVN UTXOs (must cover burnSat + feeSat + dustForAssets) + * @param ownerAssetUtxos Owner-token UTXOs for sub-asset/unique issuance (empty for root) + * @param otherAssetUtxos Map of other asset names to their AssetUtxos (all swept to changeAddress) + * @param assetName Full asset name: "ROOT", "ROOT/SUB", or "ROOT/SUB#UNIQUE" + * @param qtyRaw Asset quantity in native units (qty * 10^[units]) + * @param toAddress Address that receives the newly-issued asset + * @param changeAddress Address that receives RVN change and all swept assets + * @param units Divisibility 0-8 + * @param reissuable Whether more supply can be issued later + * @param ipfsHash Optional CIDv0 base58 IPFS hash ("Qm...") for metadata + * @param burnSat RVN to burn: use BURN_ROOT_SAT / BURN_SUB_SAT / BURN_UNIQUE_SAT + * @param feeSat Miner fee in satoshis + * @param privKeyBytes Raw 32-byte private key + * @param pubKeyBytes Compressed 33-byte public key + * @return [SignedTx] ready to broadcast + */ + fun buildAndSignAssetIssueWithAssetSweep( + utxos: List, + ownerAssetUtxos: List = emptyList(), + otherAssetUtxos: Map> = emptyMap(), + assetName: String, + qtyRaw: Long, + toAddress: String, + changeAddress: String, + units: Int = 0, + reissuable: Boolean = false, + ipfsHash: String? = null, + burnSat: Long, + feeSat: Long, + privKeyBytes: ByteArray, + pubKeyBytes: ByteArray + ): SignedTx { + val isUnique = assetName.contains('#') + + val preservedOwnerAssetName = when { + assetName.contains('#') -> assetName.substringBefore('#') + "!" + assetName.contains('/') -> assetName.substringBefore('/') + "!" + else -> null + } + + val preservedOwnerAmount = 100_000_000L + val ownerDust = 0L + val dustOut = 0L + + // Calculate dust for all asset sweep outputs + var dustForSweptAssets = 0L + val assetSweepOutputs = mutableListOf() + + for ((assetNameOther, utxosOther) in otherAssetUtxos) { + val totalRawAmount = utxosOther.sumOf { it.assetRawAmount } + if (totalRawAmount > 0) { + assetSweepOutputs.add(AssetOutput(assetNameOther, totalRawAmount, changeAddress)) + val inputDust = utxosOther.sumOf { it.utxo.satoshis } + if (inputDust > 0) dustForSweptAssets += 600L + } + } + + val rvnAndOwnerInputs = utxos + ownerAssetUtxos + val otherAssetSatoshis = otherAssetUtxos.values.flatten().sumOf { it.utxo.satoshis } + val totalIn = rvnAndOwnerInputs.sumOf { it.satoshis } + otherAssetSatoshis + val required = burnSat + ownerDust + dustOut + feeSat + dustForSweptAssets + + require(totalIn >= required) { + "Insufficient RVN: have ${"%.4f".format(totalIn / 1e8)} RVN, " + + "need ${"%.4f".format(required / 1e8)} RVN (burn + fee + asset dust)" + } + + if (preservedOwnerAssetName != null) { + require(ownerAssetUtxos.isNotEmpty()) { + "Missing owner asset input for $assetName: require $preservedOwnerAssetName" + } + } + + val changeSat = totalIn - burnSat - ownerDust - dustOut - feeSat - dustForSweptAssets + + val burnAddress = when { + assetName.contains('#') -> BURN_ADDRESS_UNIQUE + assetName.contains('/') -> BURN_ADDRESS_SUB + else -> BURN_ADDRESS_ROOT + } + val burnScript = p2pkhScript(burnAddress) + val issueScript = buildAssetIssueScript(toAddress, assetName, qtyRaw, units, reissuable, ipfsHash) + + // Build outputs in consensus order: + // 1. Burn + // 2. RVN change + // 3. Asset sweep outputs (other assets to changeAddress) + // 4. Parent owner-token return + // 5. New owner-token output + // 6. Issuance output (ALWAYS LAST) + val outputs = mutableListOf() + + // 1. Burn output + outputs.add(ScriptedOutput(burnSat, burnScript)) + + // 2. RVN change + if (changeSat > 546) outputs.add(ScriptedOutput(changeSat, p2pkhScript(changeAddress))) + + // 3. Asset sweep outputs (all other assets to changeAddress) + for (assetOutput in assetSweepOutputs) { + val inputDust = otherAssetUtxos[assetOutput.assetName]?.sumOf { it.utxo.satoshis } ?: 0L + val dustForThisOutput = if (inputDust > 0) 600L else 0L + outputs.add(ScriptedOutput(dustForThisOutput, + buildAssetTransferScript(changeAddress, assetOutput.assetName, assetOutput.rawAmount))) + } + + // 4. Return the spent parent owner token to the issuer + if (preservedOwnerAssetName != null) { + val preservedOwnerScript = buildAssetTransferScript( + changeAddress, + preservedOwnerAssetName, + preservedOwnerAmount + ) + Log.i( + "RavencoinTxBuilder", + "owner-return asset=$preservedOwnerAssetName amountRaw=$preservedOwnerAmount script=${preservedOwnerScript.toHex()}" + ) + outputs.add(ScriptedOutput(0L, preservedOwnerScript)) + } + + // 5. New owner-token output (root/sub issuance only). + // The owner token ALWAYS goes to changeAddress (the issuer's next quantum-safe address), + // never to toAddress, which may be an external customer address. + // Sending it to an external address would permanently transfer control of the asset + // (sub-asset issuance rights) to the recipient. + if (!isUnique) { + val ownerScript = buildOwnerTokenScript(changeAddress, assetName) + outputs.add(ScriptedOutput(ownerDust, ownerScript)) + } + + // 6. Issuance output (ALWAYS LAST - consensus rule) + outputs.add(ScriptedOutput(dustOut, issueScript)) + + // Combine all inputs: RVN + owner assets + swept assets + val allInputs = mutableListOf() + allInputs.addAll(rvnAndOwnerInputs) + otherAssetUtxos.values.forEach { allInputs.addAll(it.map { au -> au.utxo }) } + + val signatures = allInputs.mapIndexed { idx, utxo -> + val sigHash = sigHashWithScriptedOutputs(allInputs, outputs, idx, utxo.script) + signEcdsa(sigHash, privKeyBytes) + } + + val raw = serializeTxWithScripts(allInputs, outputs, signatures, pubKeyBytes) + return SignedTx(raw.toHex(), txid(raw)) + } + + // ── Public API: full address sweep (assets + RVN in ONE tx) ────────────── + + /** + * Build and sign a Ravencoin transaction that sweeps ALL assets and ALL RVN + * from an old address to a fresh, clean address in a SINGLE transaction. + * + * This is the post-quantum safe sweep operation that ensures an old address + * (with HAS_OUTGOING status) is completely emptied. + * + * Output order (Ravencoin consensus: P2PKH before OP_RVN_ASSET): + * 1. RVN output: all remaining RVN to [changeAddress] + * 2. All asset outputs: all assets to [changeAddress] + * + * @param assetUtxos Map of asset names to their AssetUtxos (all swept to changeAddress) + * @param rvnUtxos RVN-only UTXOs (covers fee + dust for assets) + * @param feeSat Miner fee in satoshis + * @param changeAddress Fresh address that receives ALL assets and remaining RVN + * @param privKeyBytes Raw 32-byte private key of the old address being swept + * @param pubKeyBytes Compressed 33-byte public key of the old address + * @return [SignedTx] ready to broadcast + */ + fun buildAndSignFullAddressSweep( + assetUtxos: Map>, + rvnUtxos: List, + feeSat: Long, + changeAddress: String, + privKeyBytes: ByteArray, + pubKeyBytes: ByteArray + ): SignedTx { + // Calculate dust for all asset outputs + var dustForAssets = 0L + val assetOutputs = mutableListOf() + + for ((assetName, utxos) in assetUtxos) { + val totalRawAmount = utxos.sumOf { it.assetRawAmount } + if (totalRawAmount > 0) { + assetOutputs.add(AssetOutput(assetName, totalRawAmount, changeAddress)) + val inputDust = utxos.sumOf { it.utxo.satoshis } + if (inputDust > 0) dustForAssets += 600L + } + } + + // Calculate total RVN inputs + val rvnTotalIn = rvnUtxos.sumOf { it.satoshis } + + // RVN change = total RVN - fee - dust for assets + val rvnChange = rvnTotalIn - feeSat - dustForAssets + require(rvnChange >= 0 || rvnTotalIn >= feeSat) { + "Insufficient RVN: have ${rvnTotalIn / 1e8} RVN, " + + "need ${feeSat / 1e8} RVN fee + ${dustForAssets / 1e8} RVN dust" + } + + // Combine all inputs: RVN + assets + val allInputs = mutableListOf() + allInputs.addAll(rvnUtxos) + assetUtxos.values.forEach { allInputs.addAll(it.map { au -> au.utxo }) } + + // Build outputs in consensus order: P2PKH first, then OP_RVN_ASSET + val outputs = mutableListOf() + + // 1. All RVN to changeAddress + val effectiveRvnChange = if (rvnChange > 546) rvnChange else 0L + if (effectiveRvnChange > 0) { + outputs.add(ScriptedOutput(effectiveRvnChange, p2pkhScript(changeAddress))) + } + + // 2. All assets to changeAddress + for (assetOutput in assetOutputs) { + val inputDust = assetUtxos[assetOutput.assetName]?.sumOf { it.utxo.satoshis } ?: 0L + val dustForThisOutput = if (inputDust > 0) 600L else 0L + outputs.add(ScriptedOutput(dustForThisOutput, + buildAssetTransferScript(changeAddress, assetOutput.assetName, assetOutput.rawAmount))) + } + + // Sign each input + val signatures = allInputs.mapIndexed { idx, utxo -> + val sigHash = sigHashWithScriptedOutputs(allInputs, outputs, idx, utxo.script) + signEcdsa(sigHash, privKeyBytes) + } + + val raw = serializeTxWithScripts(allInputs, outputs, signatures, pubKeyBytes) + return SignedTx(raw.toHex(), txid(raw)) + } + // ── Asset script builders ───────────────────────────────────────────────── /** @@ -787,6 +1447,38 @@ object RavencoinTxBuilder { return buf.toByteArray() } + /** + * Serialize a fully-signed transaction where each input may come from a + * different address and is therefore signed with its own (signature, pubKey) pair. + */ + private fun serializeTxMultiKey( + inputs: List, + outputs: List, + sigsAndPubKeys: List> + ): ByteArray { + val buf = ByteArrayOutputStream() + buf.writeLE32(VERSION) + buf.writeVarInt(inputs.size) + inputs.forEachIndexed { i, utxo -> + buf.write(utxo.txid.hexToBytes().reversedArray()) + buf.writeLE32(utxo.outputIndex) + val (sig, pubKey) = sigsAndPubKeys[i] + val scriptSig = byteArrayOf(sig.size.toByte()) + sig + + byteArrayOf(pubKey.size.toByte()) + pubKey + buf.writeVarInt(scriptSig.size) + buf.write(scriptSig) + buf.writeLE32U(SEQUENCE) + } + buf.writeVarInt(outputs.size) + outputs.forEach { out -> + buf.writeLE64(out.satoshis) + buf.writeVarInt(out.script.size) + buf.write(out.script) + } + buf.writeLE32(LOCKTIME.toInt()) + return buf.toByteArray() + } + // ── P2PKH script builder ────────────────────────────────────────────────── /** diff --git a/android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt b/android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt new file mode 100644 index 0000000..7a8bcc8 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/TofuTrustManager.kt @@ -0,0 +1,81 @@ +package io.raventag.app.wallet + +import android.content.Context +import android.util.Log +import io.raventag.app.security.TofuFingerprintDao +import java.security.MessageDigest +import java.security.cert.X509Certificate +import javax.net.ssl.X509TrustManager +import java.util.concurrent.ConcurrentHashMap + +/** + * TOFU (Trust On First Use) TrustManager for ElectrumX self-signed TLS certificates. + * + * Standard certificate authority validation is not used because ElectrumX servers + * commonly use self-signed certificates. TOFU provides a practical security model: + * + * - First connection to a host: the server's SHA-256 fingerprint is computed from + * the raw DER-encoded certificate bytes and stored in both the in-process [certCache] + * and the persistent SQLite database via [TofuFingerprintDao]. The connection is allowed. + * - Subsequent connections to the same host: the fingerprint is verified against + * the SQLite-persisted value first, then against the in-memory cache. If it differs + * from either, the connection is rejected with an exception to protect against + * man-in-the-middle attacks. + * + * Certificate fingerprints are persisted in SQLite database (L2 cache) and survive app restarts. + * Dual-layer cache: in-memory ConcurrentHashMap (L1, fast access) + SQLite (L2, persistent). + * + * @param context Application context for SQLite database access. + * @param host Hostname of the ElectrumX server, used as the cache key. + */ +internal class TofuTrustManager(private val context: Context, private val host: String) : X509TrustManager { + init { + TofuFingerprintDao.init(context) + } + + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) { + val cert = chain?.firstOrNull() ?: throw Exception("No certificate from $host") + // Compute SHA-256 fingerprint of the raw DER-encoded certificate + val fingerprint = MessageDigest.getInstance("SHA-256").digest(cert.encoded) + .joinToString("") { "%02x".format(it) } + + // Check SQLite-persisted fingerprint first (L2: persistent TOFU) + val persisted = TofuFingerprintDao.getFingerprint(host) + if (persisted != null && persisted != fingerprint) { + throw Exception("Certificate mismatch for $host: expected $persisted, got $fingerprint") + } + + // Fallback to in-memory cache (L1) for first connection + val inMemory = certCache.putIfAbsent(host, fingerprint) + if (inMemory == fingerprint) { + if (persisted == null) { + Log.i(TAG, "TOFU: pinning new certificate for $host") + TofuFingerprintDao.pinFingerprint(host, fingerprint) // Persist to L2 + } + return // Certificate matches + } + + if (persisted == null) { + // First connection to this host: accept and pin to both L1 and L2 + certCache.putIfAbsent(host, fingerprint) + TofuFingerprintDao.pinFingerprint(host, fingerprint) + Log.i(TAG, "TOFU: pinned new certificate for $host") + return + } + + // Certificate differs from both L1 and L2: reject (MITM detected) + throw Exception("Certificate mismatch for $host: expected $persisted, got $fingerprint") + } + + companion object { + private const val TAG = "ElectrumX" + + /** + * TOFU certificate fingerprint cache: hostname -> SHA-256 hex string. + * Thread-safe via ConcurrentHashMap. Scoped to the process lifetime. + */ + internal val certCache = ConcurrentHashMap() + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/WalletExceptions.kt b/android/app/src/main/java/io/raventag/app/wallet/WalletExceptions.kt new file mode 100644 index 0000000..51ccf38 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/WalletExceptions.kt @@ -0,0 +1,15 @@ +package io.raventag.app.wallet + +// Wave 0 scaffolding stubs. These exceptions are referenced by WalletManagerMnemonicTest.kt. +// Full implementations in plan 30-06. + +class BackupRequiredException(msg: String = "backup required before restore") : RuntimeException(msg) +class IntegrityException(msg: String = "seed HMAC mismatch") : RuntimeException(msg) +class KeystoreInvalidatedException(cause: Throwable? = null) : RuntimeException("keystore invalidated", cause) + +/** + * Signaled by RPC / subscription paths when every ElectrumX server in the + * pool is currently quarantined. UI (plan 30-08) uses this to drive the RED + * pill + disabled Send/Receive snackbar ("Offline, all nodes unreachable"). + */ +class AllNodesUnreachableException(msg: String = "all ElectrumX nodes quarantined") : RuntimeException(msg) diff --git a/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt b/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt index 75bc1d5..3bed3d5 100644 --- a/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt +++ b/android/app/src/main/java/io/raventag/app/wallet/WalletManager.kt @@ -18,22 +18,20 @@ import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import io.raventag.app.ravencoin.OwnedAsset +import io.raventag.app.wallet.cache.ReservedUtxoDao +import io.raventag.app.wallet.cache.PendingConsolidationDao +import io.raventag.app.worker.RebroadcastWorker -/** - * WalletManager , BIP32/BIP44 HD wallet for Ravencoin. - * - * Coin type: 175 (SLIP44 Ravencoin) - * Address version: 0x3C (60) , Ravencoin P2PKH mainnet - * Derivation path: m/44'/175'/0'/0/0 - * - * Keys are encrypted with Android Keystore (AES-GCM) before storage. - */ class WalletManager(private val context: Context) { - // Cached address: derived once and reused to avoid repeated KeyStore decrypt + - // BIP32 derivation + secp256k1 on the main thread. - // @Volatile ensures visibility across threads (Dispatchers.IO reads it concurrently). @Volatile private var cachedAddress: String? = null + @Volatile private var sweepRunning = false companion object { private const val PREFS_NAME = "raventag_wallet" @@ -41,12 +39,19 @@ class WalletManager(private val context: Context) { private const val KEY_SEED_IV = "seed_iv" private const val KEY_MNEMONIC_ENC = "mnemonic_enc" private const val KEY_MNEMONIC_IV = "mnemonic_iv" + private const val KEY_ADDRESS_INDEX = "address_index" private const val KEYSTORE_ALIAS = "raventag_wallet_key" + // D-15 mnemonic-safety additions (plan 30-06) + private const val KEY_SEED_HMAC = "seed_hmac" + private const val KEY_MNEMONIC_HMAC = "mnemonic_hmac" + private const val KEY_HMAC_MATERIAL_CT = "hmac_material_ct" + private const val KEY_HMAC_MATERIAL_IV = "hmac_material_iv" + private const val KEY_BACKUP_COMPLETED = "backup_completed" + private val VALID_WORD_COUNTS = setOf(12, 15, 18, 21, 24) private const val COIN_TYPE = 175 private val RVN_ADDRESS_VERSION = byteArrayOf(0x3C.toByte()) private val B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - // BIP39 complete 2048-word English wordlist (BIP-0039 standard) private val WORD_LIST = listOf( "abandon","ability","able","about","above","absent","absorb","abstract","absurd","abuse", "access","accident","account","accuse","achieve","acid","acoustic","acquire","across","act", @@ -254,24 +259,121 @@ class WalletManager(private val context: Context) { "worry","worth","wrap","wreck","wrestle","wrist","write","wrong","yard","year", "yellow","you","young","youth","zebra","zero","zone","zoo" ) + // Plan 30-06: mnemonic safety helpers. + + /** + * D-15 + Pitfall 7: normalize whitespace and validate BIP39 word count + checksum. + * Accepts arbitrary whitespace via `input.trim().split(Regex("\\s+"))`. + * @throws IllegalArgumentException if the word count is not in {12,15,18,21,24} + * or the BIP39 checksum fails. + */ + @JvmStatic + fun validateMnemonic(input: String): List { + val words = input.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + require(words.size in VALID_WORD_COUNTS) { + "invalid word count: ${words.size}" + } + require(bip39ChecksumValidCompanion(words)) { "BIP39 checksum failed" } + return words + } + + /** + * Pure BIP39 checksum validator, operating on an already-normalized word list. + * Supports 12/15/18/21/24 word counts per BIP39. + */ + internal fun bip39ChecksumValidCompanion(words: List): Boolean { + val n = words.size + if (n !in VALID_WORD_COUNTS) return false + val totalBits = n * 11 + val checksumBits = totalBits / 33 + val entropyBits = totalBits - checksumBits + val entropyBytes = entropyBits / 8 + + val indices = IntArray(n) + for (i in 0 until n) { + val idx = WORD_LIST.indexOf(words[i]) + if (idx < 0) return false + indices[i] = idx + } + + val allBits = IntArray(totalBits) + var pos = 0 + for (idx in indices) { + for (b in 10 downTo 0) { + allBits[pos++] = (idx shr b) and 1 + } + } + + val entropy = ByteArray(entropyBytes) + for (i in 0 until entropyBits) { + entropy[i / 8] = (entropy[i / 8].toInt() or (allBits[i] shl (7 - i % 8))).toByte() + } + + val hash = java.security.MessageDigest.getInstance("SHA-256").digest(entropy) + for (i in 0 until checksumBits) { + val expected = (hash[i / 8].toInt() shr (7 - i % 8)) and 1 + if (allBits[entropyBits + i] != expected) return false + } + return true + } + + /** + * D-14: block restore-over-wallet when the current wallet has funds + * and the user has not confirmed the recovery phrase backup. + */ + @JvmStatic + fun checkRestorePreconditions(currentBalanceSat: Long, hasBackedUp: Boolean) { + if (currentBalanceSat > 0L && !hasBackedUp) { + throw BackupRequiredException( + "Current wallet has $currentBalanceSat sat and has not been backed up" + ) + } + } + + /** + * Test-only / deterministic HMAC-SHA256 over a seed with caller-supplied key bytes. + * The production HMAC flow (instance method `computeSeedHmac`) loads the key from + * the Keystore-wrapped material stored in SharedPreferences and delegates here. + */ + @JvmStatic + fun computeSeedHmacForTest(seed: ByteArray, keyBytes: ByteArray): ByteArray { + val mac = org.bouncycastle.crypto.macs.HMac( + org.bouncycastle.crypto.digests.SHA256Digest() + ) + mac.init(org.bouncycastle.crypto.params.KeyParameter(keyBytes)) + mac.update(seed, 0, seed.size) + val out = ByteArray(mac.macSize) + mac.doFinal(out, 0) + return out + } + + /** + * D-15 / A9: constant-time HMAC verification. On mismatch throws + * [IntegrityException] (stored seed/mnemonic tampered or wrong key). + */ + @JvmStatic + fun verifySeedHmac(seed: ByteArray, tag: ByteArray, keyBytes: ByteArray) { + val expected = computeSeedHmacForTest(seed, keyBytes) + val ok = java.security.MessageDigest.isEqual(expected, tag) + java.util.Arrays.fill(expected, 0) + if (!ok) throw IntegrityException("seed HMAC mismatch") + } + + /** + * Pitfall 3: convert the opaque Keystore "key invalidated" signal into a + * typed exception the UI can route to the restore flow. All other + * exceptions pass through unchanged. + */ + @JvmStatic + inline fun wrapKeystoreException(block: () -> T): T { + return try { + block() + } catch (e: android.security.keystore.KeyPermanentlyInvalidatedException) { + throw KeystoreInvalidatedException(cause = e) + } + } } - /** - * Create or retrieve the AES-256-GCM wallet encryption key from the Android Keystore. - * - * Security layers (in order of preference): - * 1. StrongBox: hardware-isolated secure enclave (Titan/similar chip) , best security - * Keys never leave the dedicated security chip, even the OS cannot extract them. - * 2. TEE (Trusted Execution Environment): hardware-backed Keystore in ARM TrustZone - * Keys are hardware-backed but in the main SoC secure area. - * 3. Software Keystore: fallback for older/lower-end devices. - * - * Additional protections applied regardless of backing: - * - setUnlockedDeviceRequired: key is only accessible when device is unlocked (screen on + PIN/biometric) - * - setRandomizedEncryptionRequired: forces random IV per encryption (prevents replay attacks) - * - setInvalidatedByBiometricEnrollment: key is invalidated if new biometrics are enrolled - * (prevents attacker from enrolling their own fingerprint to access funds) - */ private fun getOrCreateAndroidKey(): SecretKey { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } if (keyStore.containsAlias(KEYSTORE_ALIAS)) { @@ -286,7 +388,6 @@ class WalletManager(private val context: Context) { .setKeySize(256) .setRandomizedEncryptionRequired(true) .apply { - // setUnlockedDeviceRequired requires API 28+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { setUnlockedDeviceRequired(true) } @@ -296,14 +397,12 @@ class WalletManager(private val context: Context) { val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") - // Try StrongBox first (dedicated security chip , highest security) val key = try { keyGen.init(buildSpec(strongBox = true)) keyGen.generateKey().also { android.util.Log.i("WalletManager", "Key stored in StrongBox (hardware enclave)") } } catch (_: Throwable) { - // Fallback to TEE / software Keystore keyGen.init(buildSpec(strongBox = false)) keyGen.generateKey().also { android.util.Log.i("WalletManager", "Key stored in Android Keystore (TEE/software)") @@ -312,7 +411,6 @@ class WalletManager(private val context: Context) { return key } - /** Returns true if the wallet key is hardware-backed (TEE or StrongBox). */ fun isKeyHardwareBacked(): Boolean { return try { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } @@ -324,34 +422,24 @@ class WalletManager(private val context: Context) { } catch (_: Exception) { false } } - /** - * Encrypt [data] with the Android Keystore AES-GCM key. - * Returns ciphertext paired with the random IV (GCM generates a fresh IV per call). - */ - private fun encrypt(data: ByteArray): Pair { + private fun encrypt(data: ByteArray): Pair = wrapKeystoreException { val key = getOrCreateAndroidKey() val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, key) - return cipher.doFinal(data) to cipher.iv + cipher.doFinal(data) to cipher.iv } - /** - * Decrypt [enc] using the Android Keystore AES-GCM key and the provided [iv]. - * GCM authentication tag is verified automatically; throws if tampered. - */ - private fun decrypt(enc: ByteArray, iv: ByteArray): ByteArray { + private fun decrypt(enc: ByteArray, iv: ByteArray): ByteArray = wrapKeystoreException { val key = getOrCreateAndroidKey() val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) - return cipher.doFinal(enc) + cipher.doFinal(enc) } - /** Returns the app-private SharedPreferences file used to store the encrypted wallet material. */ private fun prefs() = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) fun hasWallet(): Boolean = prefs().contains(KEY_SEED_ENC) - /** Generate a new BIP39 12-word mnemonic and derive BIP32 seed. */ fun generateWallet(): String { val entropy = ByteArray(16).also { SecureRandom().nextBytes(it) } val mnemonic = entropyToMnemonic(entropy) @@ -360,32 +448,34 @@ class WalletManager(private val context: Context) { return mnemonic } - /** - * Generate a new BIP39 12-word mnemonic without storing it. - * Call finalizeWallet() after the user confirms the backup. - */ fun generateMnemonic(): String { val entropy = ByteArray(16).also { SecureRandom().nextBytes(it) } return entropyToMnemonic(entropy) } - /** - * Derive seed from mnemonic and store it securely. - * Call this only after the user confirms the backup of the mnemonic. - */ fun finalizeWallet(mnemonic: String) { val seed = mnemonicToSeed(mnemonic, "") storeSeed(seed, mnemonic) cachedAddress = null } - /** Delete wallet , clears all encrypted keys from SharedPreferences and Android Keystore. */ fun deleteWallet() { cachedAddress = null + // Wipe ALL wallet-related prefs so a fresh restore does not inherit + // stale state (backup gate, integrity tags, HMAC material, address index). prefs().edit() .remove(KEY_SEED_ENC).remove(KEY_SEED_IV) .remove(KEY_MNEMONIC_ENC).remove(KEY_MNEMONIC_IV) + .remove(KEY_ADDRESS_INDEX) + .remove(KEY_BACKUP_COMPLETED) + .remove(KEY_HMAC_MATERIAL_CT).remove(KEY_HMAC_MATERIAL_IV) + .remove(KEY_SEED_HMAC).remove(KEY_MNEMONIC_HMAC) .apply() + // Wipe cached balance / utxos / tx history so restore preconditions + // (D-14 forced-backup gate) do not flag a wallet that no longer exists. + try { io.raventag.app.wallet.cache.WalletCacheDao.clearAll() } catch (_: Throwable) {} + try { io.raventag.app.wallet.cache.TxHistoryDao.clearAll() } catch (_: Throwable) {} + try { io.raventag.app.wallet.cache.ReservedUtxoDao.clearAll() } catch (_: Throwable) {} try { val ks = KeyStore.getInstance("AndroidKeyStore") ks.load(null) @@ -393,96 +483,649 @@ class WalletManager(private val context: Context) { } catch (_: Exception) {} } - /** Restore wallet from existing mnemonic. */ + fun getCurrentAddressIndex(): Int = prefs().getInt(KEY_ADDRESS_INDEX, 0) + + private fun setCurrentAddressIndex(index: Int) { + prefs().edit().putInt(KEY_ADDRESS_INDEX, index).apply() + cachedAddress = null + } + + fun getCurrentAddress(): String? = getAddress(0, getCurrentAddressIndex()) + + fun reconcileCurrentAddressIndex(): Int = getCurrentAddressIndex() + + fun ensureCurrentAddressClean() {} + + suspend fun discoverCurrentIndex(): Int = withContext(Dispatchers.IO) { + val node = RavencoinPublicNode(context) + val currentStoredIndex = getCurrentAddressIndex() + val searchLimit = maxOf(currentStoredIndex + 50, 100) + + android.util.Log.i("WalletManager", "discoverCurrentIndex: Scanning 0..$searchLimit for RVN and assets") + + val batchMap = getAddressBatch(0, 0 until searchLimit) + if (batchMap.isEmpty()) return@withContext currentStoredIndex + + // Phase 1: Find last address with any history (existing approach) + val addrList = batchMap.values.toList() + val statusMap = try { + node.getAddressStatusBatch(addrList) + } catch (e: Exception) { + android.util.Log.e("WalletManager", "discoverCurrentIndex: batch status check failed", e) + emptyMap() + } + + var lastUsed = -1 + for (i in 0 until searchLimit) { + val addr = batchMap[i] ?: continue + val status = statusMap[addr] ?: RavencoinPublicNode.AddressStatus.NO_HISTORY + if (status != RavencoinPublicNode.AddressStatus.NO_HISTORY) { + lastUsed = i + } + } + + // Phase 2: Find the highest address that currently holds funds (RVN or assets). + // Single batch call (get_balance?asset=true) replaces N*2 sequential TLS calls. + var lastWithFunds = -1 + val addressesWithHistory = (0 until searchLimit).mapNotNull { i -> + val addr = batchMap[i] ?: return@mapNotNull null + val status = statusMap[addr] ?: RavencoinPublicNode.AddressStatus.NO_HISTORY + if (status != RavencoinPublicNode.AddressStatus.NO_HISTORY) i to addr else null + } + if (addressesWithHistory.isNotEmpty()) { + val historyAddrList = addressesWithHistory.map { it.second } + val withFunds = try { + node.getAddressesWithFunds(historyAddrList) + } catch (_: Exception) { emptySet() } + for ((i, addr) in addressesWithHistory) { + if (addr in withFunds) { + lastWithFunds = maxOf(lastWithFunds, i) + android.util.Log.i("WalletManager", "discoverCurrentIndex: index $i has funds") + } + } + } + + // Determine current index: + // - If funds exist: stay at that address unless its key is already exposed + // (HAS_OUTGOING means a signed tx revealed the public key, so move to next). + // - If no funds anywhere: next address after the last one with any history. + // - Empty wallet: index 0. + val finalResult = maxOf( + when { + lastWithFunds >= 0 -> { + val fundsAddr = batchMap[lastWithFunds] + val fundsStatus = fundsAddr?.let { statusMap[it] } + ?: RavencoinPublicNode.AddressStatus.NO_HISTORY + if (fundsStatus == RavencoinPublicNode.AddressStatus.HAS_OUTGOING) { + android.util.Log.i("WalletManager", "discoverCurrentIndex: index $lastWithFunds key exposed, using ${lastWithFunds + 1}") + lastWithFunds + 1 + } else { + android.util.Log.i("WalletManager", "discoverCurrentIndex: index $lastWithFunds has funds, key safe, staying there") + lastWithFunds + } + } + lastUsed >= 0 -> lastUsed + 1 + else -> 0 + }, + currentStoredIndex + ) + setCurrentAddressIndex(finalResult) + android.util.Log.i("WalletManager", "Discover: current index = $finalResult (lastUsed=$lastUsed, lastWithFunds=$lastWithFunds)") + finalResult + } + + /** + * Lightweight index sync for refresh: checks whether the stored currentIndex is stale + * (e.g. another app flavor sent a tx and advanced the index) without running a full + * address discovery scan. + * + * Algorithm (3 batch network calls max): + * 1. Check status of currentIndex address. If not HAS_OUTGOING, index is fine. + * 2. Scan forward up to 10 addresses for status in one batch. + * 3. Find the highest funded address forward; advance currentIndex accordingly. + * + * @return true if currentIndex was updated, false if already correct. + */ + suspend fun syncCurrentIndex(): Boolean = withContext(Dispatchers.IO) { + val node = RavencoinPublicNode(context) + val storedIndex = getCurrentAddressIndex() + val currentAddr = getAddress(0, storedIndex) ?: return@withContext false + + // Step 1: one call to check if current address key is exposed + val currentStatus = try { + node.getAddressStatusBatch(listOf(currentAddr))[currentAddr] + } catch (_: Exception) { return@withContext false } + + if (currentStatus != RavencoinPublicNode.AddressStatus.HAS_OUTGOING) { + android.util.Log.i("WalletManager", "syncCurrentIndex: index $storedIndex is current (status=$currentStatus)") + return@withContext false + } + + // Step 2: scan forward up to 10 addresses for status + val forwardRange = (storedIndex + 1)..(storedIndex + 10) + val forwardAddrs = getAddressBatch(0, forwardRange) + val forwardList = forwardRange.mapNotNull { i -> forwardAddrs[i]?.let { i to it } } + + val fwdStatusMap = try { + node.getAddressStatusBatch(forwardList.map { it.second }) + } catch (_: Exception) { emptyMap() } + + val withHistory = forwardList.filter { (_, addr) -> + fwdStatusMap[addr] != RavencoinPublicNode.AddressStatus.NO_HISTORY + } + + if (withHistory.isEmpty()) { + // No history forward: storedIndex+1 is the fresh address + val newIndex = storedIndex + 1 + setCurrentAddressIndex(newIndex) + android.util.Log.i("WalletManager", "syncCurrentIndex: no history forward, advanced to $newIndex") + return@withContext true + } + + // Step 3: check which of those addresses still hold funds + val withFunds = try { + node.getAddressesWithFunds(withHistory.map { it.second }) + } catch (_: Exception) { emptySet() } + + val lastFunded = withHistory.filter { (_, addr) -> addr in withFunds }.maxByOrNull { it.first } + + val newIndex = when { + lastFunded != null -> { + val st = fwdStatusMap[lastFunded.second] ?: RavencoinPublicNode.AddressStatus.NO_HISTORY + if (st == RavencoinPublicNode.AddressStatus.HAS_OUTGOING) lastFunded.first + 1 + else lastFunded.first + } + else -> withHistory.maxOf { it.first } + 1 + } + + if (newIndex > storedIndex) { + setCurrentAddressIndex(newIndex) + android.util.Log.i("WalletManager", "syncCurrentIndex: advanced $storedIndex -> $newIndex") + return@withContext true + } + false + } + + suspend fun sweepOldAddresses(): List { + if (sweepRunning) return emptyList() + sweepRunning = true + try { + return sweepOldAddressesInternal() + } finally { + sweepRunning = false + } + } + + private data class FundingResult(val txid: String, val fundUtxo: Utxo) + + private fun fundOldAddressForSweep( + node: RavencoinPublicNode, + sacrificialIndex: Int?, + oldAddress: String, + assetCount: Int + ): FundingResult? { + if (sacrificialIndex == null) { + android.util.Log.w("WalletManager", "Sweep: no sacrificial address available, skipping funding for $oldAddress") + return null + } + + val sacrificialAddress = getAddress(0, sacrificialIndex) ?: return null + + val satPerByte = try { node.getMinRelayFeeRateSatPerByte() } catch (_: FeeUnavailableException) { 200L } + val perAssetFee = 300L * satPerByte + val fundAmountSat = perAssetFee * assetCount + 200L * satPerByte + + val sacAssetOutpoints = try { node.getAllAssetOutpoints(sacrificialAddress) } catch (_: Exception) { emptySet() } + val sacUtxos = node.getUtxos(sacrificialAddress) + .filter { "${it.txid}:${it.outputIndex}" !in sacAssetOutpoints } + if (sacUtxos.isEmpty()) { + android.util.Log.w("WalletManager", "Sweep: sacrificial address $sacrificialIndex has no RVN") + return null + } + + val totalIn = sacUtxos.sumOf { it.satoshis } + val fundingTxFee = (10L + 148L * sacUtxos.size + 34L * 2) * satPerByte + if (totalIn < fundAmountSat + fundingTxFee) { + android.util.Log.w("WalletManager", "Sweep: sacrificial address $sacrificialIndex has insufficient RVN") + return null + } + + var privKey: ByteArray? = null + try { + privKey = getPrivateKeyBytes(0, sacrificialIndex) ?: return null + val pubKey = getPublicKeyBytes(0, sacrificialIndex) ?: return null + + val tx = RavencoinTxBuilder.buildAndSign( + utxos = sacUtxos, + toAddress = oldAddress, + amountSat = fundAmountSat, + feeSat = fundingTxFee, + changeAddress = sacrificialAddress, + privKeyBytes = privKey, + pubKeyBytes = pubKey + ) + val txid = node.broadcast(tx.hex) + android.util.Log.i("WalletManager", "Sweep: funded $oldAddress with ${fundAmountSat / 1e8} RVN from sacrificial $sacrificialIndex: $txid") + + val scriptHex = addressToP2pkhScript(oldAddress) + val fundUtxo = Utxo( + txid = txid, + outputIndex = 0, + satoshis = fundAmountSat, + script = scriptHex, + height = 0 + ) + + return FundingResult(txid, fundUtxo) + } finally { + privKey?.fill(0) + } + } + + private fun addressToP2pkhScript(address: String): String { + val decoded = base58Decode(address) + val hash160 = decoded.copyOfRange(1, 21) + return "76a914" + hash160.joinToString("") { "%02x".format(it) } + "88ac" + } + + private suspend fun sweepOldAddressesInternal(): List { + val currentIndex = getCurrentAddressIndex() + if (currentIndex == 0) return emptyList() + + val node = RavencoinPublicNode(context) + + data class SweepTarget( + val index: Int, + val address: String, + val hasAssets: Boolean, + val hasRvn: Boolean + ) + android.util.Log.i("WalletManager", "Sweep: scanning ${currentIndex} old addresses (0..${currentIndex - 1})") + + val addrBatch = getAddressBatch(0, 0 until currentIndex) + val addrList = (0 until currentIndex).mapNotNull { i -> addrBatch[i]?.let { i to it } } + + val targets = mutableListOf() + for ((i, addr) in addrList) { + try { + val r = node.getUtxosAndAllAssetUtxosBatch(addr) + val hasAssets = r.third.isNotEmpty() + val rvnBalance = r.first.sumOf { it.satoshis } + if (hasAssets || rvnBalance > 0) { + android.util.Log.i("WalletManager", "Sweep: index $i ($addr) has assets=$hasAssets rvn=${rvnBalance / 1e8}") + targets.add(SweepTarget(i, addr, hasAssets, rvnBalance > 0)) + } + } catch (e: Exception) { + android.util.Log.w("WalletManager", "Sweep: scan failed for index $i: ${e.message}") + } + } + if (targets.isEmpty()) { + android.util.Log.i("WalletManager", "Sweep: no funded old addresses found, nothing to do") + return emptyList() + } + + val targetAddress = getAddress(0, currentIndex) ?: return emptyList() + android.util.Log.i("WalletManager", "Sweep: consolidating ${targets.size} address(es) to index $currentIndex") + + val txids = mutableListOf() + + val sacrificialIndex = targets.firstOrNull { it.hasRvn && !it.hasAssets }?.index + ?: targets.firstOrNull { it.hasRvn }?.index + val needsFunding = targets.filter { it.hasAssets && !it.hasRvn } + for (t in needsFunding) { + val assetCount = try { node.getAssetBalances(t.address).size } catch (_: Exception) { 1 } + val result = fundOldAddressForSweep(node, sacrificialIndex, t.address, assetCount) + if (result != null) txids.add(result.txid) + } + + // Wait for funding transactions to appear in mempool before sweeping + if (needsFunding.isNotEmpty() && txids.isNotEmpty()) { + var waited = 0 + val maxWaitSec = 60 + while (waited < maxWaitSec) { + var allVisible = true + for (t in needsFunding) { + val utxos = try { node.getUtxos(t.address) } catch (_: Exception) { emptyList() } + if (utxos.isEmpty()) { allVisible = false; break } + } + if (allVisible) break + kotlinx.coroutines.delay(3000) + waited += 3 + } + android.util.Log.i("WalletManager", "Sweep: funding txs visible after ${waited}s, proceeding with sweep") + } + + for (t in targets) { + try { + val assetBalances = if (t.hasAssets) { + try { node.getAssetBalances(t.address) } catch (_: Exception) { emptyList() } + } else emptyList() + + val assetUtxosMap = mutableMapOf>() + for (asset in assetBalances) { + if (asset.amount > 0) { + val utxos = node.getAssetUtxosFull(t.address, asset.name) + if (utxos.isNotEmpty()) assetUtxosMap[asset.name] = utxos + } + } + + val allAssetOutpoints = node.getAllAssetOutpoints(t.address) + val rvnUtxos = node.getUtxos(t.address) + .filter { "${it.txid}:${it.outputIndex}" !in allAssetOutpoints } + + if (assetUtxosMap.isEmpty() && rvnUtxos.isEmpty()) continue + + var privKey: ByteArray? = null + try { + privKey = getPrivateKeyBytes(0, t.index) ?: continue + val pubKey = getPublicKeyBytes(0, t.index) ?: continue + + if (assetUtxosMap.isNotEmpty()) { + val totalAssetOutputs = assetBalances.count { it.amount > 0 } + val totalInputs = rvnUtxos.size + assetUtxosMap.values.sumOf { it.size } + val estimatedBytes = 10 + 148 * totalInputs + 70 * (1 + totalAssetOutputs) + 34 + val feeSat = estimatedBytes * node.getMinRelayFeeRateSatPerByte() + + val tx = RavencoinTxBuilder.buildAndSignFullAddressSweep( + assetUtxos = assetUtxosMap, + rvnUtxos = rvnUtxos, + feeSat = feeSat, + changeAddress = targetAddress, + privKeyBytes = privKey, + pubKeyBytes = pubKey + ) + val txid = node.broadcast(tx.hex) + txids.add(txid) + android.util.Log.i("WalletManager", "Sweep: assets+RVN from index ${t.index} to $currentIndex: $txid") + + } else if (rvnUtxos.isNotEmpty()) { + val totalSat = rvnUtxos.sumOf { it.satoshis } + val satPerByte = node.getMinRelayFeeRateSatPerByte() + val estimatedBytes = 10 + 148 * rvnUtxos.size + 34 + val feeSat = estimatedBytes * satPerByte + val sendAmount = totalSat - feeSat + + if (sendAmount > 546) { + val tx = RavencoinTxBuilder.buildAndSign( + utxos = rvnUtxos, + toAddress = targetAddress, + amountSat = sendAmount, + feeSat = feeSat, + changeAddress = targetAddress, + privKeyBytes = privKey, + pubKeyBytes = pubKey + ) + val txid = node.broadcast(tx.hex) + txids.add(txid) + android.util.Log.i("WalletManager", "Sweep: RVN from index ${t.index} to $currentIndex: $txid") + } + } + } finally { + privKey?.fill(0) + } + } catch (e: Exception) { + android.util.Log.w("WalletManager", "Sweep: index ${t.index} failed: ${e.message}") + } + } + + return txids + } + + private fun fundAddressesForSweep( + node: RavencoinPublicNode, + addressesToFund: List> + ): List { + if (addressesToFund.isEmpty()) return emptyList() + + val currentIndex = getCurrentAddressIndex() + val currentAddress = getAddress(0, currentIndex) ?: return emptyList() + + val curAssetOutpoints = try { node.getAllAssetOutpoints(currentAddress) } catch (_: Exception) { emptySet() } + val curUtxos = node.getUtxos(currentAddress) + .filter { "${it.txid}:${it.outputIndex}" !in curAssetOutpoints } + if (curUtxos.isEmpty()) { + android.util.Log.w("WalletManager", "Sweep funding: no RVN on current address") + return emptyList() + } + + val fundingTxids = mutableListOf() + val satPerByte = try { node.getMinRelayFeeRateSatPerByte() } catch (_: FeeUnavailableException) { 200L } + + for ((index, oldAddress) in addressesToFund) { + val assetBalances = try { node.getAssetBalances(oldAddress) } catch (_: Exception) { emptyList() } + if (assetBalances.isEmpty()) continue + + val perAssetFee = 300L * satPerByte + val fundAmountSat = perAssetFee * assetBalances.size + 500L * satPerByte + + val totalIn = curUtxos.sumOf { it.satoshis } + val alreadyFunded = fundingTxids.size * fundAmountSat + val available = totalIn - alreadyFunded + if (available < fundAmountSat) { + android.util.Log.w("WalletManager", "Sweep funding: insufficient for index $index") + continue + } + + var privKey: ByteArray? = null + try { + privKey = getPrivateKeyBytes(0, currentIndex) ?: continue + val pubKey = getPublicKeyBytes(0, currentIndex) ?: continue + + val fundingFee = (10L + 148L * 2 + 34L * 2) * satPerByte + val tx = RavencoinTxBuilder.buildAndSign( + utxos = curUtxos, + toAddress = oldAddress, + amountSat = fundAmountSat, + feeSat = fundingFee, + changeAddress = currentAddress, + privKeyBytes = privKey, + pubKeyBytes = pubKey + ) + val txid = node.broadcast(tx.hex) + fundingTxids.add(txid) + android.util.Log.i("WalletManager", "Sweep funding: funded index $index ($oldAddress) with ${fundAmountSat / 1e8} RVN: $txid") + } catch (e: Exception) { + android.util.Log.w("WalletManager", "Sweep funding: failed index $index: ${e.message}") + } finally { + privKey?.fill(0) + } + } + + return fundingTxids + } + + /** + * Restore-over-wallet entry point. D-14 forces a backup gate when the current + * wallet has funds and the user has not confirmed their recovery phrase. + * + * Throws: + * - [BackupRequiredException] if the forced-backup gate fires. + * - [IllegalArgumentException] if the phrase fails BIP39 validation. + * - [KeystoreInvalidatedException] if the Keystore AES key is invalidated. + * + * Returns true on successful restore, false only on unexpected I/O failure. + */ fun restoreWallet(mnemonic: String): Boolean { + // Validation + forced-backup gate run BEFORE any Keystore rewrite; their + // exceptions propagate to the UI so the restore dialog can react. + val normalizedWords = validateMnemonic(mnemonic) + val hasBackedUp = prefs().getBoolean(KEY_BACKUP_COMPLETED, false) + val currentBalanceSat = runCatching { + io.raventag.app.wallet.cache.WalletCacheDao.readState()?.balanceSat ?: 0L + }.getOrDefault(0L) + checkRestorePreconditions(currentBalanceSat, hasBackedUp) + + val normalized = normalizedWords.joinToString(" ") return try { - val normalized = mnemonic.trim() - if (!validateMnemonic(normalized)) return false val seed = mnemonicToSeed(normalized, "") storeSeed(seed, normalized) cachedAddress = null + prefs().edit().putInt(KEY_ADDRESS_INDEX, 0).apply() + // A restore sets a fresh wallet: clear backup gate for the new phrase. + prefs().edit().putBoolean(KEY_BACKUP_COMPLETED, false).apply() true + } catch (e: KeystoreInvalidatedException) { + throw e } catch (e: Exception) { false } } - /** - * Validate a BIP39 12-word mnemonic. - * Checks that every word is in the BIP39 English wordlist and that the - * 4-bit checksum appended to the entropy (per BIP39 spec) is correct. - */ - private fun validateMnemonic(mnemonic: String): Boolean { - val words = mnemonic.split("\\s+".toRegex()) - if (words.size != 12) return false - - val indices = mutableListOf() - for (word in words) { - val idx = WORD_LIST.indexOf(word) - if (idx < 0) return false - indices.add(idx) - } - - // Reconstruct bit array from word indices (11 bits per word = 132 bits total) - val allBits = ArrayList(132) - for (idx in indices) { - for (i in 10 downTo 0) { - allBits.add((idx shr i) and 1) + // D-15 HMAC key material (32 random bytes) encrypted under the existing + // Keystore AES key. We bridge to a raw BouncyCastle HMAC key because a + // Keystore-bound AES key cannot be extracted as `Mac` key material. + private fun loadOrCreateHmacKeyBytes(): ByteArray { + val p = prefs() + val existingCt = p.getString(KEY_HMAC_MATERIAL_CT, null) + val existingIv = p.getString(KEY_HMAC_MATERIAL_IV, null) + if (existingCt != null && existingIv != null) { + try { + val ct = android.util.Base64.decode(existingCt, android.util.Base64.NO_WRAP) + val iv = android.util.Base64.decode(existingIv, android.util.Base64.NO_WRAP) + return decrypt(ct, iv) + } catch (_: javax.crypto.AEADBadTagException) { + // Stale blob (e.g., overwriting an existing wallet with a fresh mnemonic). + // Old HMAC tags cannot be verified under rotated key material; wipe and rebuild. + p.edit() + .remove(KEY_HMAC_MATERIAL_CT) + .remove(KEY_HMAC_MATERIAL_IV) + .remove(KEY_SEED_HMAC) + .remove(KEY_MNEMONIC_HMAC) + .apply() } } + val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) } + val (ct, iv) = encrypt(fresh) + p.edit() + .putString(KEY_HMAC_MATERIAL_CT, android.util.Base64.encodeToString(ct, android.util.Base64.NO_WRAP)) + .putString(KEY_HMAC_MATERIAL_IV, android.util.Base64.encodeToString(iv, android.util.Base64.NO_WRAP)) + .apply() + return fresh + } - // First 128 bits = entropy, last 4 bits = BIP39 checksum - val entropy = ByteArray(16) - for (i in 0 until 128) { - entropy[i / 8] = (entropy[i / 8].toInt() or (allBits[i] shl (7 - i % 8))).toByte() + private fun computeSeedHmac(seed: ByteArray): ByteArray { + val keyBytes = loadOrCreateHmacKeyBytes() + return try { + computeSeedHmacForTest(seed, keyBytes) + } finally { + java.util.Arrays.fill(keyBytes, 0) } - val checksumBits = allBits.subList(128, 132) - - // Expected checksum = first 4 bits of SHA-256(entropy) - val hash = MessageDigest.getInstance("SHA-256").digest(entropy) - val expectedBits = (0 until 4).map { i -> (hash[0].toInt() shr (7 - i)) and 1 } + } - return checksumBits == expectedBits + private fun verifySeedHmacInstance(seed: ByteArray, tag: ByteArray) { + val keyBytes = loadOrCreateHmacKeyBytes() + try { + verifySeedHmac(seed, tag, keyBytes) + } finally { + java.util.Arrays.fill(keyBytes, 0) + } } - /** Encrypt and persist both the BIP32 seed and the BIP39 mnemonic to SharedPreferences. */ private fun storeSeed(seed: ByteArray, mnemonic: String) { val (seedEnc, seedIv) = encrypt(seed) - val (mnemonicEnc, mnemonicIv) = encrypt(mnemonic.toByteArray()) + val mnemonicBytes = mnemonic.toByteArray(Charsets.UTF_8) + val (mnemonicEnc, mnemonicIv) = encrypt(mnemonicBytes) + val seedHmac = computeSeedHmac(seed) + val mnemonicHmac = computeSeedHmac(mnemonicBytes) prefs().edit() .putString(KEY_SEED_ENC, android.util.Base64.encodeToString(seedEnc, android.util.Base64.DEFAULT)) .putString(KEY_SEED_IV, android.util.Base64.encodeToString(seedIv, android.util.Base64.DEFAULT)) .putString(KEY_MNEMONIC_ENC, android.util.Base64.encodeToString(mnemonicEnc, android.util.Base64.DEFAULT)) .putString(KEY_MNEMONIC_IV, android.util.Base64.encodeToString(mnemonicIv, android.util.Base64.DEFAULT)) + .putString(KEY_SEED_HMAC, android.util.Base64.encodeToString(seedHmac, android.util.Base64.NO_WRAP)) + .putString(KEY_MNEMONIC_HMAC, android.util.Base64.encodeToString(mnemonicHmac, android.util.Base64.NO_WRAP)) .apply() + java.util.Arrays.fill(seedHmac, 0) + java.util.Arrays.fill(mnemonicHmac, 0) + java.util.Arrays.fill(mnemonicBytes, 0) } - /** Decrypt and return the stored BIP39 mnemonic, or null if no wallet exists or decryption fails. */ fun getMnemonic(): String? { return try { val encStr = prefs().getString(KEY_MNEMONIC_ENC, null) ?: return null val ivStr = prefs().getString(KEY_MNEMONIC_IV, null) ?: return null val enc = android.util.Base64.decode(encStr, android.util.Base64.DEFAULT) val iv = android.util.Base64.decode(ivStr, android.util.Base64.DEFAULT) - String(decrypt(enc, iv)) + val plaintext = decrypt(enc, iv) + val tagB64 = prefs().getString(KEY_MNEMONIC_HMAC, null) + if (tagB64 != null) { + val tag = android.util.Base64.decode(tagB64, android.util.Base64.NO_WRAP) + verifySeedHmacInstance(plaintext, tag) + } + String(plaintext, Charsets.UTF_8) + } catch (e: KeystoreInvalidatedException) { + throw e + } catch (e: IntegrityException) { + throw e } catch (e: Exception) { null } } - /** Decrypt and return the raw BIP32 seed bytes, or null if unavailable. Caller must clear the array after use. */ private fun getSeed(): ByteArray? { return try { val encStr = prefs().getString(KEY_SEED_ENC, null) ?: return null val ivStr = prefs().getString(KEY_SEED_IV, null) ?: return null val enc = android.util.Base64.decode(encStr, android.util.Base64.DEFAULT) val iv = android.util.Base64.decode(ivStr, android.util.Base64.DEFAULT) - decrypt(enc, iv) + val plaintext = decrypt(enc, iv) + val tagB64 = prefs().getString(KEY_SEED_HMAC, null) + if (tagB64 != null) { + val tag = android.util.Base64.decode(tagB64, android.util.Base64.NO_WRAP) + verifySeedHmacInstance(plaintext, tag) + } + plaintext + } catch (e: KeystoreInvalidatedException) { + throw e + } catch (e: IntegrityException) { + throw e } catch (e: Exception) { null } } - /** Get the Ravencoin address at m/44'/175'/0'/0/0 */ + /** + * D-15 + D-16: reveal the stored mnemonic as a CharArray, gated by a + * BiometricPrompt authentication bound to the Keystore decrypt operation. + * + * Caller is responsible for zero-filling the returned CharArray after display. + */ + suspend fun revealMnemonicCharsWithBiometric( + gate: io.raventag.app.security.BiometricGate + ): CharArray = withContext(Dispatchers.IO) { + val p = prefs() + val ctB64 = p.getString(KEY_MNEMONIC_ENC, null) + ?: throw IllegalStateException("no mnemonic stored") + val ivB64 = p.getString(KEY_MNEMONIC_IV, null) + ?: throw IllegalStateException("no mnemonic iv stored") + val ct = android.util.Base64.decode(ctB64, android.util.Base64.DEFAULT) + val iv = android.util.Base64.decode(ivB64, android.util.Base64.DEFAULT) + val cipher = wrapKeystoreException { + Cipher.getInstance("AES/GCM/NoPadding").apply { + init( + Cipher.DECRYPT_MODE, + getOrCreateAndroidKey(), + GCMParameterSpec(128, iv) + ) + } + } + val plaintext = gate.decryptWithBiometric( + cipher, + ct, + io.raventag.app.R.string.biometricRevealTitle, + io.raventag.app.R.string.biometricRevealSubtitle + ) + try { + val tagB64 = p.getString(KEY_MNEMONIC_HMAC, null) + if (tagB64 != null) { + val tag = android.util.Base64.decode(tagB64, android.util.Base64.NO_WRAP) + verifySeedHmacInstance(plaintext, tag) + } + String(plaintext, Charsets.UTF_8).toCharArray() + } finally { + java.util.Arrays.fill(plaintext, 0) + } + } + fun getAddress(accountIndex: Int = 0, addressIndex: Int = 0): String? { - // Return cached address for the default BIP44 path (m/44'/175'/0'/0/0) - if (accountIndex == 0 && addressIndex == 0) { + val currentIdx = getCurrentAddressIndex() + if (accountIndex == 0 && addressIndex == currentIdx) { cachedAddress?.let { return it } } var seed: ByteArray? = null @@ -492,7 +1135,7 @@ class WalletManager(private val context: Context) { privKey = derivePrivateKey(seed, accountIndex, addressIndex) val pubKey = privateKeyToPublicKey(privKey) val address = publicKeyToRavenAddress(pubKey) - if (accountIndex == 0 && addressIndex == 0) cachedAddress = address + if (accountIndex == 0 && addressIndex == currentIdx) cachedAddress = address address } catch (_: Throwable) { null @@ -502,7 +1145,29 @@ class WalletManager(private val context: Context) { } } - /** Get private key hex (export for signing) , use with extreme care */ + fun getAddressBatch(accountIndex: Int, indices: IntRange): Map { + val seed = getSeed() ?: return emptyMap() + val result = mutableMapOf() + val currentIdx = getCurrentAddressIndex() + try { + for (i in indices) { + var privKey: ByteArray? = null + try { + privKey = derivePrivateKey(seed, accountIndex, i) + val address = publicKeyToRavenAddress(privateKeyToPublicKey(privKey)) + result[i] = address + if (accountIndex == 0 && i == currentIdx) cachedAddress = address + } catch (_: Throwable) { + } finally { + privKey?.fill(0) + } + } + } finally { + seed.fill(0) + } + return result + } + fun getPrivateKeyHex(accountIndex: Int = 0, addressIndex: Int = 0): String? { var seed: ByteArray? = null var privKey: ByteArray? = null @@ -518,16 +1183,13 @@ class WalletManager(private val context: Context) { } } - /** Get raw private key bytes at BIP44 path */ fun getPrivateKeyBytes(accountIndex: Int = 0, addressIndex: Int = 0): ByteArray? { - // Warning: this returns a copy that the CALLER must clear. val seed = getSeed() ?: return null val privKey = derivePrivateKey(seed, accountIndex, addressIndex) seed.fill(0) return privKey } - /** Get compressed public key bytes at BIP44 path */ fun getPublicKeyBytes(accountIndex: Int = 0, addressIndex: Int = 0): ByteArray? { var seed: ByteArray? = null var priv: ByteArray? = null @@ -543,264 +1205,536 @@ class WalletManager(private val context: Context) { } } - /** - * Query balance directly from public Ravencoin node (no backend required). - * Returns balance in RVN. - */ - fun getLocalBalance(): Double? { + fun getKeyPairBatch(accountIndex: Int, indices: IntRange): Map> { + val seed = getSeed() ?: return emptyMap() + val result = mutableMapOf>() + try { + for (i in indices) { + var priv: ByteArray? = null + try { + priv = derivePrivateKey(seed, accountIndex, i) + val pub = privateKeyToPublicKey(priv) + result[i] = Pair(priv, pub) + } catch (_: Throwable) { + priv?.fill(0) + } + } + } finally { + seed.fill(0) + } + return result + } + + fun getKeyPair(accountIndex: Int = 0, addressIndex: Int = 0): Pair? { + var seed: ByteArray? = null + var priv: ByteArray? = null + var succeeded = false return try { - val address = getAddress() ?: return null - val node = RavencoinPublicNode() - node.getBalance(address).totalRvn + seed = getSeed() ?: return null + priv = derivePrivateKey(seed, accountIndex, addressIndex) + val pub = privateKeyToPublicKey(priv) + succeeded = true + Pair(priv, pub) + } catch (_: Throwable) { + null + } finally { + seed?.fill(0) + if (!succeeded) priv?.fill(0) + } + } + + suspend fun getLocalBalance(): Double? = withContext(Dispatchers.IO) { + try { + val node = RavencoinPublicNode(context) + val currentIndex = getCurrentAddressIndex() + val addresses = getAddressBatch(0, 0..currentIndex).values.toList() + node.getTotalBalance(addresses) } catch (_: Exception) { null } } - /** - * Send RVN from local wallet directly to the network. - * No backend required. Uses public Ravencoin node for UTXO query and broadcast. - * - * @param toAddress Recipient Ravencoin address - * @param amountRvn Amount in RVN - * @return "$txid|fee:$satoshis" on success - */ - fun sendRvnLocal(toAddress: String, amountRvn: Double): String { - val address = getAddress() ?: error("No wallet") - var privKey: ByteArray? = null - return try { - privKey = getPrivateKeyBytes() ?: error("No private key") - val pubKey = getPublicKeyBytes() ?: error("No public key") - - val node = RavencoinPublicNode() - val utxos = node.getUtxos(address) - if (utxos.isEmpty()) { - val bal = try { node.getBalance(address) } catch (_: Exception) { null } - if (bal != null && bal.unconfirmed > 0 && bal.confirmed == 0L) { - error("Transaction not confirmed yet. Wait for 1-2 blocks before sending.") + suspend fun sendRvnLocal(toAddress: String, amountRvn: Double): String = withContext(Dispatchers.IO) { + var currentIndex = getCurrentAddressIndex() + var address = getAddress(0, currentIndex) ?: error("No wallet") + val node = RavencoinPublicNode(context) + + val (utxoResult, satPerByte) = coroutineScope { + val utxosDeferred = async { node.getUtxosAndAllAssetUtxosBatch(address) } + val feeDeferred = async { node.getMinRelayFeeRateSatPerByte() } + Pair(utxosDeferred.await(), feeDeferred.await()) + } + var rvnUtxos: List = utxoResult.first + var assetUtxosMap: Map> = utxoResult.third + + if (rvnUtxos.isEmpty()) { + val bal = try { node.getBalance(address) } catch (_: Exception) { null } + if (bal != null && bal.unconfirmed > 0 && bal.confirmed == 0L) { + error("Transaction not confirmed yet. Wait for 1-2 blocks before sending.") + } + + android.util.Log.w("WalletManager", "sendRvn: currentIndex $currentIndex has no funds, scanning backwards for fallback") + var fallbackIndex = -1 + for (i in currentIndex - 1 downTo 0) { + val fallbackAddr = getAddress(0, i) ?: continue + val fallbackUtxos = try { node.getUtxos(fallbackAddr) } catch (_: Exception) { emptyList() } + if (fallbackUtxos.isNotEmpty()) { + android.util.Log.i("WalletManager", "sendRvn: found funds on index $i") + fallbackIndex = i + break } - error("No spendable funds found for address $address") } - // Query the node's minimum relay fee, then apply it with a 2x margin (floor 200 sat/byte) - val satPerByte = node.getMinRelayFeeRateSatPerByte() - // Estimate tx size: 10 overhead + 148 per input + 34 per output (assume 2 outputs) - val estimatedBytes = 10 + 148 * utxos.size + 34 * 2 - val feeSat = estimatedBytes * satPerByte - val amountSat = (amountRvn * 1e8).toLong() - val tx = RavencoinTxBuilder.buildAndSign( - utxos = utxos, - toAddress = toAddress, - amountSat = amountSat, - feeSat = feeSat, - changeAddress = address, - privKeyBytes = privKey, - pubKeyBytes = pubKey + if (fallbackIndex == -1) { + error("No spendable funds on current address. Try refreshing the wallet to consolidate funds.") + } + + currentIndex = fallbackIndex + setCurrentAddressIndex(currentIndex) + address = getAddress(0, currentIndex) ?: error("Cannot derive fallback address") + android.util.Log.i("WalletManager", "sendRvn: fallback to index $currentIndex ($address)") + + val fallback = node.getUtxosAndAllAssetUtxosBatch(address) + rvnUtxos = fallback.first + assetUtxosMap = fallback.third + } + + if (rvnUtxos.isEmpty()) { + error("No RVN available for transaction fee. Fund your wallet with at least 0.01 RVN.") + } + + val nextAddress = getAddress(0, currentIndex + 1) ?: error("Cannot derive next address") + + val keyPair = getKeyPair(0, currentIndex) ?: error("No wallet key") + var privKey: ByteArray? = keyPair.first + val pubKey = keyPair.second + + val amountSat = (amountRvn * 1e8).toLong() + + data class OldFunds(val index: Int, val rvn: List, val assets: Map>) + val oldFunds = mutableListOf() + if (currentIndex > 0) { + val oldAddrBatch = getAddressBatch(0, 0 until currentIndex) + val oldAddrList = (0 until currentIndex).mapNotNull { i -> + oldAddrBatch[i]?.let { i to it } + } + // Single batch call to find which old addresses have funds before fetching UTXOs. + val fundedAddrs = try { + node.getAddressesWithFunds(oldAddrList.map { it.second }) + } catch (_: Exception) { emptySet() } + + if (fundedAddrs.isNotEmpty()) { + android.util.Log.i("WalletManager", "sendRvn: ${fundedAddrs.size} old address(es) with funds, fetching UTXOs") + try { + for ((index, addr) in oldAddrList) { + if (addr !in fundedAddrs) continue + val r = node.getUtxosAndAllAssetUtxosBatch(addr) + if (r.first.isNotEmpty() || r.third.isNotEmpty()) { + oldFunds.add(OldFunds(index, r.first, r.third)) + } + } + } catch (e: Exception) { + android.util.Log.e("WalletManager", "sendRvn: old funds fetch failed", e) + } + } + } + + val mergedAssets = mutableMapOf>() + assetUtxosMap.forEach { (name, utxos) -> mergedAssets.getOrPut(name) { mutableListOf() }.addAll(utxos) } + oldFunds.forEach { of -> of.assets.forEach { (name, utxos) -> mergedAssets.getOrPut(name) { mutableListOf() }.addAll(utxos) } } + + val hasAssets = mergedAssets.isNotEmpty() + val hasOldFunds = oldFunds.isNotEmpty() + + var oldKeyPairs: Map> = emptyMap() + + return@withContext try { + val txid: String + var feeSatActual: Long = 0L + var consumedUtxos: List = emptyList() + var broadcastRawHex: String = "" + + if (hasAssets || hasOldFunds) { + if (oldFunds.isNotEmpty()) { + val minIdx = oldFunds.minOf { it.index } + val maxIdx = oldFunds.maxOf { it.index } + oldKeyPairs = getKeyPairBatch(0, minIdx..maxIdx) + } + + val currentRvnKeyed = rvnUtxos.map { RavencoinTxBuilder.KeyedUtxo(it, privKey!!, pubKey) } + val extraRvnKeyed = oldFunds.flatMap { of -> + val (op, ok) = oldKeyPairs[of.index] ?: return@flatMap emptyList() + of.rvn.map { RavencoinTxBuilder.KeyedUtxo(it, op, ok) } + } + val assetKeyed = mutableMapOf>() + assetUtxosMap.forEach { (name, utxos) -> + assetKeyed.getOrPut(name) { mutableListOf() } + .addAll(utxos.map { RavencoinTxBuilder.KeyedAssetUtxo(it, privKey!!, pubKey) }) + } + oldFunds.forEach { of -> + val (op, ok) = oldKeyPairs[of.index] ?: return@forEach + of.assets.forEach { (name, utxos) -> + assetKeyed.getOrPut(name) { mutableListOf() } + .addAll(utxos.map { RavencoinTxBuilder.KeyedAssetUtxo(it, op, ok) }) + } + } + + val totalInputs = rvnUtxos.size + extraRvnKeyed.size + assetKeyed.values.sumOf { it.size } + val totalAssetOutputs = assetKeyed.size + val estimatedBytes = 10 + 148 * totalInputs + 70 * (2 + totalAssetOutputs) + 34 + feeSatActual = estimatedBytes * satPerByte + + val tx = RavencoinTxBuilder.buildAndSignMultiAddressSend( + currentRvnInputs = currentRvnKeyed, + extraRvnInputs = extraRvnKeyed, + assetInputsByName = assetKeyed, + toAddress = toAddress, + amountSat = amountSat, + feeSat = feeSatActual, + changeAddress = nextAddress + ) + txid = node.broadcast(tx.hex) + broadcastRawHex = tx.hex + consumedUtxos = rvnUtxos + oldFunds.flatMap { it.rvn } + + assetUtxosMap.values.flatten().map { it.utxo } + + oldFunds.flatMap { it.assets.values.flatten().map { au -> au.utxo } } + + android.util.Log.i("WalletManager", "sendRvn atomic: sent $amountRvn RVN to $toAddress, " + + "all assets and remaining RVN to $nextAddress, txid=$txid") + + } else { + val totalIn = rvnUtxos.sumOf { it.satoshis } + // Sweep / MAX detection: when the requested amount + estimated fee + // would exceed the available balance, treat as a "send all" and let + // RavencoinTxBuilder subtract the exact fee from the recipient amount. + // The wallet will end at 0 RVN with no change output. + val outputsForFee = if (amountSat >= totalIn) 1 else 2 + val estimatedBytes = 10 + 148 * rvnUtxos.size + 34 * outputsForFee + feeSatActual = estimatedBytes * satPerByte + + val isMaxSend = amountSat + feeSatActual > totalIn + if (isMaxSend) { + require(totalIn > feeSatActual + 546) { + "Insufficient funds to cover network fee: have ${totalIn / 1e8} RVN, fee ${feeSatActual / 1e8} RVN" + } + } else { + require(totalIn > amountSat + feeSatActual) { + "Insufficient funds: have ${totalIn / 1e8} RVN, need ${amountSat / 1e8} RVN + ${feeSatActual / 1e8} RVN fee" + } + val changeSat = totalIn - amountSat - feeSatActual + require(changeSat > 546) { + "Remaining change (${"%.8f".format(changeSat / 1e8)} RVN) is below dust limit. " + + "Send a slightly smaller amount or send the full balance." + } + } + + val tx = RavencoinTxBuilder.buildAndSign( + utxos = rvnUtxos, + // Pass totalIn when sweeping so buildAndSign's fee-subtraction branch fires. + toAddress = toAddress, + amountSat = if (isMaxSend) totalIn else amountSat, + feeSat = feeSatActual, + changeAddress = nextAddress, + privKeyBytes = privKey!!, + pubKeyBytes = pubKey + ) + txid = node.broadcast(tx.hex) + broadcastRawHex = tx.hex + consumedUtxos = rvnUtxos + + val totalInLog = rvnUtxos.sumOf { it.satoshis } + val changeForLog = (totalInLog - (if (amountSat + feeSatActual > totalInLog) totalInLog else amountSat) - feeSatActual).coerceAtLeast(0L) + android.util.Log.i("WalletManager", "sendRvn: sent $amountRvn RVN to $toAddress, " + + "remaining ${"%.8f".format(changeForLog / 1e8)} RVN to $nextAddress, txid=$txid") + } + + setCurrentAddressIndex(currentIndex + 1) + + // Reserved-UTXO + pending-consolidation bookkeeping (D-20, D-21). + val now = System.currentTimeMillis() + val reserved = consumedUtxos.map { + ReservedUtxoDao.ReservedUtxo( + txidIn = it.txid, + vout = it.outputIndex, + valueSat = it.satoshis, + submittedTxid = txid, + submittedAt = now + ) + } + ReservedUtxoDao.reserve(reserved) + PendingConsolidationDao.upsert( + PendingConsolidationDao.PendingConsolidation( + submittedTxid = txid, submittedAt = now, + lastRetryAt = null, retryCount = 0, lastError = null + ) ) - val txid = node.broadcast(tx.hex) - // Return txid with fee info for diagnostics - "$txid|fee:${feeSat}" + // D-25 auto-rebroadcast in 30 minutes if still unconfirmed + RebroadcastWorker.schedule( + context = context, + txid = txid, + rawHex = broadcastRawHex, + attempt = 0, + initialDelayMinutes = 30L + ) + + "$txid|fee:$feeSatActual" } finally { privKey?.fill(0) } } /** - * Transfer a Ravencoin asset directly on-chain (no backend required). - * Fetches asset UTXOs and RVN UTXOs, builds and signs the transfer transaction, - * then broadcasts via ElectrumX. - * - * Handles all asset types correctly: - * - Unique tokens ("BRAND/PRODUCT#SN001"): always qty=1, divisions=0, no asset change. - * - Owner tokens ("BRAND/PRODUCT!"): always qty=1, divisions=0, no asset change. - * - Fungible root/sub-assets: qty < total balance generates an asset change output - * back to the sender so no tokens are lost. - * - * @param assetName Asset name to transfer (e.g. "BRAND/ITEM#SN001") - * @param toAddress Recipient Ravencoin address - * @param qty Quantity to transfer in display units (e.g. 1.0 for one token). - * Must be > 0 and <= current asset balance. - * @return transaction ID on success + * D-20/D-21 reconciliation: call from refresh flows after fetching confirmed + mempool + * history. Returns the submittedTxids whose reservations were just released. */ - fun transferAssetLocal( + suspend fun reconcileReservations( + confirmedTxids: Set, + mempoolTxids: Set + ): List = withContext(Dispatchers.IO) { + val allReserved = ReservedUtxoDao.all() + val bySubmitted = allReserved.groupBy { it.submittedTxid } + val now = System.currentTimeMillis() + val released = mutableListOf() + for ((subTxid, rows) in bySubmitted) { + val confirmed = confirmedTxids.contains(subTxid) + val inMempool = mempoolTxids.contains(subTxid) + val stale = rows.first().submittedAt < (now - 48L * 3600_000L) + if (confirmed || (!inMempool && stale)) { + ReservedUtxoDao.releaseFor(subTxid) + PendingConsolidationDao.clear(subTxid) + released += subTxid + } + } + released + } + + suspend fun transferAssetLocal( assetName: String, toAddress: String, qty: Double = 1.0 - ): String { - val address = getAddress() ?: error("No wallet") - var privKey: ByteArray? = null - return try { - privKey = getPrivateKeyBytes() ?: error("No private key") - val pubKey = getPublicKeyBytes() ?: error("No public key") - - val node = RavencoinPublicNode() - - // Ravencoin wire format always encodes asset amounts as qty * COIN (10^8), regardless - // of the asset's divisions field. Divisions control only display precision, not the - // on-chain LE64 value. Unique tokens ("#") and owner tokens ("!") each carry exactly - // 1 * COIN = 100_000_000 raw units per UTXO. - val rawQtyRequested = Math.round(qty * 100_000_000.0) - require(rawQtyRequested > 0) { "Transfer quantity must be greater than zero" } - - // Fetch asset UTXOs (each carries the asset and a dust amount of RVN) - val assetUtxosFull = node.getAssetUtxosFull(address, assetName) - if (assetUtxosFull.isEmpty()) error("No UTXOs found for asset $assetName in address $address") - - val totalRawAmount = assetUtxosFull.sumOf { it.assetRawAmount } - require(totalRawAmount > 0) { "Asset $assetName has zero balance in UTXOs" } - require(rawQtyRequested <= totalRawAmount) { - "Insufficient asset balance: requested $qty, " + - "available ${totalRawAmount / 100_000_000.0}" - } - - // Remaining asset balance returns to the sender as a second OP_RVN_ASSET output. - val assetChangeRawAmount = totalRawAmount - rawQtyRequested - - val assetUtxos = assetUtxosFull.map { it.utxo } - val assetDust = assetUtxos.sumOf { it.satoshis } - - // Number of asset outputs: 1 for full transfer (unique tokens), 2 if there's asset change. - val numAssetOutputs = if (assetChangeRawAmount > 0) 2 else 1 - - // Estimate fee dynamically using ElectrumX relay fee with safety margin. - // Use conservative estimate: 10 overhead + 148 per input + ~70 per asset output + 34 per RVN output. - // Start with minimum estimate and adjust after fetching UTXOs. - val relayFeeSatPerByte = try { node.getMinRelayFeeRateSatPerByte() } catch (_: FeeUnavailableException) { 200L } - // Use relay fee directly without excessive margin (floor 200 sat/byte) - val satPerByte = maxOf(relayFeeSatPerByte, 200L) - - // Dust for asset outputs: only needed if asset UTXOs have satoshis (preserves value balance). - // For tokens issued with 0 satoshis, use 0 dust to avoid "creating satoshi from nothing" error. - val dustNeeded = if (assetDust > 0) 600L * numAssetOutputs else 0L - - // Always fetch RVN UTXOs to pay the fee, even if no dust is needed for asset outputs. - val excludedOutpoints = node.getAllAssetOutpoints(address) - val rvnUtxos = node.getUtxos(address) - .filter { "${it.txid}:${it.outputIndex}" !in excludedOutpoints } - - // Recalculate fee with actual UTXO count - val totalInputs = assetUtxos.size + rvnUtxos.size - val estimatedBytes = 10 + 148 * totalInputs + 70 * numAssetOutputs + 34 - val feeSat = estimatedBytes * satPerByte - - // Verify RVN UTXOs cover both dust and fee - val rvnTotal = rvnUtxos.sumOf { it.satoshis } - val required = dustNeeded + feeSat - if (rvnTotal < required) { - error("Insufficient RVN for fee and dust. Need ${required / 1e8} RVN, have ${rvnTotal / 1e8} RVN. Fund your wallet with at least 0.01 RVN.") - } - if (rvnUtxos.isEmpty()) { - error("Insufficient RVN for fee. Fund your wallet with at least 0.01 RVN.") - } - - val tx = RavencoinTxBuilder.buildAndSignAssetTransfer( - assetUtxos = assetUtxos, - rvnUtxos = rvnUtxos, - toAddress = toAddress, - assetName = assetName, - assetAmount = rawQtyRequested, - assetChangeAmount = assetChangeRawAmount, - feeSat = feeSat, - changeAddress = address, - privKeyBytes = privKey, - pubKeyBytes = pubKey + ): String = withContext(Dispatchers.IO) { + val currentIndex = getCurrentAddressIndex() + val nextAddress = getAddress(0, currentIndex + 1) ?: error("Cannot derive next address") + val node = RavencoinPublicNode(context) + + val rawQtyRequested = Math.round(qty * 100_000_000.0) + require(rawQtyRequested > 0) { "Transfer quantity must be greater than zero" } + + val satPerByte = try { node.getMinRelayFeeRateSatPerByte() } catch (_: FeeUnavailableException) { 200L } + + data class AddrFunds( + val index: Int, + val rvnUtxos: List, + val assetUtxos: Map> + ) + val addrBatch = getAddressBatch(0, 0..currentIndex) + val allFunds = mutableListOf() + for ((i, addr) in addrBatch.entries.sortedBy { it.key }) { + try { + val r = node.getUtxosAndAllAssetUtxosBatch(addr) + if (r.first.isNotEmpty() || r.third.isNotEmpty()) { + allFunds.add(AddrFunds(i, r.first, r.third)) + } + } catch (_: Exception) {} + } + + val primaryByIndex: Map> = allFunds + .filter { it.assetUtxos.containsKey(assetName) } + .associate { it.index to it.assetUtxos.getValue(assetName) } + + if (primaryByIndex.isEmpty()) { + error("Asset $assetName not found on any wallet address. Try refreshing the wallet.") + } + + val totalRawAmount = primaryByIndex.values.sumOf { utxos -> utxos.sumOf { it.assetRawAmount } } + require(totalRawAmount > 0) { "Asset $assetName has zero balance" } + require(rawQtyRequested <= totalRawAmount) { + "Insufficient balance: requested $qty, available ${totalRawAmount / 100_000_000.0}" + } + + val assetChangeRaw = totalRawAmount - rawQtyRequested + + val otherByIndex = allFunds.associate { af -> + af.index to af.assetUtxos.filterKeys { it != assetName } + }.filter { (_, m) -> m.isNotEmpty() } + + val involvedIndices = (primaryByIndex.keys + otherByIndex.keys + allFunds.map { it.index }).toSet() + val minIdx = involvedIndices.minOrNull() ?: currentIndex + val maxIdx = involvedIndices.maxOrNull() ?: currentIndex + val keyPairs = getKeyPairBatch(0, minIdx..maxIdx) + + return@withContext try { + val primaryKeyed = primaryByIndex.flatMap { (idx, utxos) -> + val (priv, pub) = keyPairs[idx] ?: return@flatMap emptyList() + utxos.map { RavencoinTxBuilder.KeyedAssetUtxo(it, priv, pub) } + } + + val otherKeyed = mutableMapOf>() + for ((idx, assetMap) in otherByIndex) { + val (priv, pub) = keyPairs[idx] ?: continue + for ((name, utxos) in assetMap) { + otherKeyed.getOrPut(name) { mutableListOf() } + .addAll(utxos.map { RavencoinTxBuilder.KeyedAssetUtxo(it, priv, pub) }) + } + } + + val rvnKeyed = allFunds.flatMap { af -> + val (priv, pub) = keyPairs[af.index] ?: return@flatMap emptyList() + af.rvnUtxos.map { RavencoinTxBuilder.KeyedUtxo(it, priv, pub) } + } + + val primaryAssetChangeOutputs = if (assetChangeRaw > 0) 1 else 0 + val totalAssetOutputs = 1 + primaryAssetChangeOutputs + otherKeyed.size + val totalInputs = primaryKeyed.size + otherKeyed.values.sumOf { it.size } + rvnKeyed.size + val feeSat = (10L + 148L * totalInputs + 70L * totalAssetOutputs + 34L) * maxOf(satPerByte, 200L) + val dustEstimate = 600L * totalAssetOutputs + + val totalRvnIn = rvnKeyed.sumOf { it.utxo.satoshis } + + primaryKeyed.sumOf { it.assetUtxo.utxo.satoshis } + + otherKeyed.values.flatten().sumOf { it.assetUtxo.utxo.satoshis } + + if (totalRvnIn < feeSat + dustEstimate) { + error("Insufficient RVN for fee and dust. Need ${(feeSat + dustEstimate) / 1e8} RVN, " + + "have ${totalRvnIn / 1e8} RVN. Fund your wallet with at least 0.01 RVN.") + } + + val tx = RavencoinTxBuilder.buildAndSignMultiAddressAssetTransfer( + primaryAssetInputs = primaryKeyed, + otherAssetInputs = otherKeyed, + rvnInputs = rvnKeyed, + primaryAsset = RavencoinTxBuilder.AssetOutput(assetName, rawQtyRequested, toAddress), + primaryAssetChange = assetChangeRaw, + feeSat = feeSat, + changeAddress = nextAddress ) - node.broadcast(tx.hex) + val txid = node.broadcast(tx.hex) + + // Reserved-UTXO + pending-consolidation bookkeeping (D-20, D-21). + val allConsumedUtxos = allFunds.flatMap { af -> + af.rvnUtxos + af.assetUtxos.values.flatten().map { it.utxo } + } + val xferNow = System.currentTimeMillis() + ReservedUtxoDao.reserve(allConsumedUtxos.map { + ReservedUtxoDao.ReservedUtxo( + txidIn = it.txid, vout = it.outputIndex, valueSat = it.satoshis, + submittedTxid = txid, submittedAt = xferNow + ) + }) + PendingConsolidationDao.upsert( + PendingConsolidationDao.PendingConsolidation( + submittedTxid = txid, submittedAt = xferNow, + lastRetryAt = null, retryCount = 0, lastError = null + ) + ) + RebroadcastWorker.schedule( + context = context, txid = txid, rawHex = tx.hex, + attempt = 0, initialDelayMinutes = 30L + ) + + android.util.Log.i("WalletManager", "transferAsset: sent $qty $assetName to $toAddress, " + + "remaining assets and RVN to $nextAddress, txid=$txid") + + setCurrentAddressIndex(currentIndex + 1) + txid } finally { - privKey?.fill(0) + keyPairs.values.forEach { (priv, _) -> priv.fill(0) } } } - /** - * Issue a Ravencoin asset directly on-chain (no backend required). - * Builds and signs an rvni transaction, then broadcasts via ElectrumX. - * - * @param assetName Full asset name: "ROOT", "ROOT/SUB", or "ROOT/SUB#UNIQUE" - * @param qty Asset quantity in display units (e.g. 1000.0) - * @param units Divisibility 0-8 - * @param reissuable Whether more supply can be issued later - * @param ipfsHash Optional CIDv0 "Qm..." IPFS hash for metadata - * @return transaction ID on success - */ - fun issueAssetLocal( + suspend fun issueAssetLocal( assetName: String, qty: Double, toAddress: String, units: Int = 0, reissuable: Boolean = false, ipfsHash: String? = null - ): String { - val address = getAddress() ?: error("No wallet") - var privKey: ByteArray? = null - return try { - privKey = getPrivateKeyBytes() ?: error("No private key") - val pubKey = getPublicKeyBytes() ?: error("No public key") - - val node = RavencoinPublicNode() - val utxos = node.getUtxos(address) - if (utxos.isEmpty()) error("No spendable RVN found for address $address") - val ownerAssetName = when { - assetName.contains('#') -> assetName.substringBefore('#') + "!" - assetName.contains('/') -> assetName.substringBefore('/') + "!" - else -> null - } - val ownerAssetUtxos = ownerAssetName?.let { requiredOwnerAsset -> - val allOwnerUtxos = node.getAssetUtxosFull(address, requiredOwnerAsset) - require(allOwnerUtxos.isNotEmpty()) { - "Missing owner asset $requiredOwnerAsset in wallet. Transfer the owner token to this address before issuing $assetName." - } - // Owner tokens must have exactly amount = 1 (in raw units: 100000000 = 1 * 10^8). - // Select a single UTXO with rawAmount = 100000000L. - val singleOwnerUtxo = allOwnerUtxos.firstOrNull { it.assetRawAmount == 100_000_000L } - ?: allOwnerUtxos.firstOrNull() - ?: error("No valid owner token UTXO found for $requiredOwnerAsset") - require(singleOwnerUtxo.assetRawAmount == 100_000_000L) { - "Owner token $requiredOwnerAsset has amount ${singleOwnerUtxo.assetRawAmount}, expected 100000000 (1 in raw units). " + - "Make sure you have a single owner token UTXO with amount 1." - } - // Owner-token UTXOs are signed as asset inputs, but compliant owner outputs carry zero RVN. - // Keep the input selected while excluding it from the spendable RVN total. - listOf(singleOwnerUtxo.utxo.copy(satoshis = 0L)) - }.orEmpty() - - val burnSat = when { - assetName.contains('#') -> RavencoinTxBuilder.BURN_UNIQUE_SAT - assetName.contains('/') -> RavencoinTxBuilder.BURN_SUB_SAT - else -> RavencoinTxBuilder.BURN_ROOT_SAT - } - - val satPerByte = node.getMinRelayFeeRateSatPerByte() - // Estimate tx size: 10 + 148*inputs + 34 per output. - // Root assets: burn + owner + issue + change = 4 outputs. - // Sub-assets: burn + change + parent-owner return + new owner + issue = 5 outputs. - // Unique tokens: burn + change + parent-owner return + issue = 4 outputs. - val outputCount = when { - assetName.contains('#') -> 4 - assetName.contains('/') -> 5 - else -> 4 - } - val estimatedBytes = 10 + 148 * (utxos.size + ownerAssetUtxos.size) + 34 * outputCount - val feeSat = estimatedBytes * satPerByte - - // Ravencoin encodes asset amounts with 8 fixed decimals; `units` limits the - // allowed precision but does not change the wire-format scale. - val qtyRaw = (qty * 100_000_000.0).toLong() - val tx = RavencoinTxBuilder.buildAndSignAssetIssue( - utxos = utxos.filterNot { rvn -> + ): String = withContext(Dispatchers.IO) { + val currentIndex = getCurrentAddressIndex() + val address = getAddress(0, currentIndex) ?: error("No wallet") + val nextAddress = getAddress(0, currentIndex + 1) ?: error("Cannot derive next address") + + val actualToAddress = if (toAddress == address) nextAddress else toAddress + + val node = RavencoinPublicNode(context) + + val (utxoResult, satPerByte) = coroutineScope { + val utxosDeferred = async { node.getUtxosAndAllAssetUtxosBatch(address) } + val feeDeferred = async { node.getMinRelayFeeRateSatPerByte() } + Pair(utxosDeferred.await(), feeDeferred.await()) + } + val rvnUtxos = utxoResult.first + val allAssetMap = utxoResult.third + + if (rvnUtxos.isEmpty()) error("No spendable RVN on current address. Try refreshing the wallet.") + + val ownerAssetName = when { + assetName.contains('#') -> assetName.substringBefore('#') + "!" + assetName.contains('/') -> assetName.substringBefore('/') + "!" + else -> null + } + val ownerAssetUtxos: List = ownerAssetName?.let { requiredOwnerAsset -> + val allOwnerUtxos = allAssetMap[requiredOwnerAsset] ?: emptyList() + require(allOwnerUtxos.isNotEmpty()) { + "Missing owner asset $requiredOwnerAsset in wallet. " + + "Transfer the owner token to this address before issuing $assetName." + } + val singleOwnerUtxo = allOwnerUtxos.firstOrNull { it.assetRawAmount == 100_000_000L } + ?: allOwnerUtxos.firstOrNull() + ?: error("No valid owner token UTXO found for $requiredOwnerAsset") + require(singleOwnerUtxo.assetRawAmount == 100_000_000L) { + "Owner token $requiredOwnerAsset has amount ${singleOwnerUtxo.assetRawAmount}, " + + "expected 100000000 (1 in raw units). Make sure you have a single owner token UTXO with amount 1." + } + listOf(singleOwnerUtxo.utxo.copy(satoshis = 0L)) + }.orEmpty() + + val otherAssetUtxos: Map> = allAssetMap.filterKeys { it != ownerAssetName } + + val burnSat = when { + assetName.contains('#') -> RavencoinTxBuilder.BURN_UNIQUE_SAT + assetName.contains('/') -> RavencoinTxBuilder.BURN_SUB_SAT + else -> RavencoinTxBuilder.BURN_ROOT_SAT + } + + val totalAssetSweepOutputs = otherAssetUtxos.size + val totalInputs = rvnUtxos.size + ownerAssetUtxos.size + otherAssetUtxos.values.sumOf { it.size } + val outputCountForIssuance = when { + assetName.contains('#') -> 4 + assetName.contains('/') -> 5 + else -> 4 + } + val feeSat = (10 + 148 * totalInputs + 70 * (outputCountForIssuance + totalAssetSweepOutputs) + 34) * satPerByte + + val qtyRaw = (qty * 100_000_000.0).toLong() + + val keyPair = getKeyPair(0, currentIndex) ?: error("No wallet key") + var privKey: ByteArray? = keyPair.first + val pubKey = keyPair.second + + return@withContext try { + val tx = RavencoinTxBuilder.buildAndSignAssetIssueWithAssetSweep( + utxos = rvnUtxos.filterNot { rvn -> ownerAssetUtxos.any { owner -> owner.txid == rvn.txid && owner.outputIndex == rvn.outputIndex } }, ownerAssetUtxos = ownerAssetUtxos, + otherAssetUtxos = otherAssetUtxos, assetName = assetName, qtyRaw = qtyRaw, - toAddress = toAddress, - changeAddress = address, + toAddress = actualToAddress, + changeAddress = nextAddress, units = units, reissuable = reissuable, ipfsHash = ipfsHash, burnSat = burnSat, feeSat = feeSat, - privKeyBytes = privKey, + privKeyBytes = privKey!!, pubKeyBytes = pubKey ) - node.broadcast(tx.hex) + val txid = node.broadcast(tx.hex) + + android.util.Log.i("WalletManager", "issueAsset: issued $qty $assetName to $actualToAddress, " + + "owner token + all other assets and RVN change to $nextAddress, txid=$txid") + + setCurrentAddressIndex(currentIndex + 1) + txid } finally { privKey?.fill(0) } @@ -808,7 +1742,6 @@ class WalletManager(private val context: Context) { // ── BIP32/BIP44 key derivation ────────────────────────────────────────── - /** Compute HMAC-SHA512 over [data] with [key] using BouncyCastle. Used for BIP32 child key derivation. */ private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray { val mac = HMac(SHA512Digest()) mac.init(KeyParameter(key)) @@ -817,33 +1750,27 @@ class WalletManager(private val context: Context) { } private fun derivePrivateKey(seed: ByteArray, account: Int, index: Int): ByteArray { - // Master key var I = hmacSha512("Bitcoin seed".toByteArray(Charsets.UTF_8), seed) var kl = I.copyOf(32) var kr = I.copyOfRange(32, 64) - I.fill(0) // Secure clear intermediate + I.fill(0) - // Derive: m/44' val i1 = deriveChild(kl, kr, 44 or 0x80000000.toInt()) kl.fill(0); kr.fill(0) kl = i1.copyOf(32); kr = i1.copyOfRange(32, 64); i1.fill(0) - - // m/44'/175' + val i2 = deriveChild(kl, kr, COIN_TYPE or 0x80000000.toInt()) kl.fill(0); kr.fill(0) kl = i2.copyOf(32); kr = i2.copyOfRange(32, 64); i2.fill(0) - - // m/44'/175'/account' + val i3 = deriveChild(kl, kr, account or 0x80000000.toInt()) kl.fill(0); kr.fill(0) kl = i3.copyOf(32); kr = i3.copyOfRange(32, 64); i3.fill(0) - - // m/44'/175'/account'/0 + val i4 = deriveChild(kl, kr, 0) kl.fill(0); kr.fill(0) kl = i4.copyOf(32); kr = i4.copyOfRange(32, 64); i4.fill(0) - - // m/44'/175'/account'/0/index + val i5 = deriveChild(kl, kr, index) kl.fill(0); kr.fill(0) val result = i5.copyOf(32) @@ -851,40 +1778,22 @@ class WalletManager(private val context: Context) { return result } - /** - * BIP32 child key derivation (private -> private). - * - * Returns 64 bytes: child_private_key(32) || child_chain_code(32). - * - * Per BIP32 spec: - * - child_key = (IL + parent_key) mod n - * - If IL >= n or child_key == 0, the key is invalid: skip to next index. - * - * The invalid-index case has probability ~2^-128 and will never occur in - * practice, but the retry loop is required for strict spec compliance. - */ private fun deriveChild(parentKey: ByteArray, parentChain: ByteArray, index: Int): ByteArray { val spec = ECNamedCurveTable.getParameterSpec("secp256k1") val n = spec.n var i = index while (true) { val data = if (i and 0x80000000.toInt() != 0) { - // Hardened: 0x00 || parent_key || ser32(i) byteArrayOf(0x00) + parentKey + intToBytes(i) } else { - // Normal: serP(parent_pubkey) || ser32(i) privateKeyToPublicKey(parentKey) + intToBytes(i) } val hmacOut = hmacSha512(parentChain, data) val IL = BigInteger(1, hmacOut.copyOf(32)) val chainCode = hmacOut.copyOfRange(32, 64) - // BIP32: invalid key if IL >= curve order if (IL >= n) { i++; continue } - // BIP32: child_key = (IL + parent_key) mod n val childScalar = IL.add(BigInteger(1, parentKey)).mod(n) - // BIP32: invalid key if result is zero if (childScalar == BigInteger.ZERO) { i++; continue } - // Serialize to exactly 32 bytes (BigInteger.toByteArray may have leading 0x00) val raw = childScalar.toByteArray() val childKeyBytes = when { raw.size > 32 -> raw.copyOfRange(raw.size - 32, raw.size) @@ -903,7 +1812,7 @@ class WalletManager(private val context: Context) { val spec = ECNamedCurveTable.getParameterSpec("secp256k1") val privBig = BigInteger(1, privKey) val point = spec.g.multiply(privBig).normalize() - return point.getEncoded(true) // compressed + return point.getEncoded(true) } private fun publicKeyToRavenAddress(pubKey: ByteArray): String { @@ -923,6 +1832,20 @@ class WalletManager(private val context: Context) { return h2.copyOf(4) } + private fun base58Decode(input: String): ByteArray { + var num = BigInteger.ZERO + val base = BigInteger.valueOf(58) + for (c in input) { + val idx = B58_ALPHABET.indexOf(c) + if (idx < 0) error("Invalid Base58 character: $c") + num = num.multiply(base).add(BigInteger.valueOf(idx.toLong())) + } + val bytes = num.toByteArray() + val stripped = if (bytes.size > 1 && bytes[0] == 0.toByte()) bytes.copyOfRange(1, bytes.size) else bytes + val leadingZeros = input.takeWhile { it == B58_ALPHABET[0] }.length + return ByteArray(leadingZeros) + stripped + } + private fun base58Encode(data: ByteArray): String { var num = BigInteger(1, data) val sb = StringBuilder() @@ -968,4 +1891,561 @@ class WalletManager(private val context: Context) { mnemonicBytes.fill(0) } } + + suspend fun healAndSweepTarget(index: Int) = withContext(Dispatchers.IO) { + val currentIndex = getCurrentAddressIndex() + val addr = getAddress(0, index) ?: return@withContext + val keyPair = getKeyPair(0, index) ?: return@withContext + val privKey = keyPair.first + val pubKey = keyPair.second + val node = RavencoinPublicNode(context) + + try { + val r = node.getUtxosAndAllAssetUtxosBatch(addr) + val rvnBalance = r.first.sumOf { it.satoshis } + val hasAssets = r.third.isNotEmpty() + + if (hasAssets || rvnBalance > 0) { + val satPerByte = try { node.getMinRelayFeeRateSatPerByte() } catch (_: FeeUnavailableException) { 200L } + + if (hasAssets && rvnBalance < 10000000L) { + val currentAddr = getAddress(0, currentIndex) ?: return@withContext + val curKeyPair = getKeyPair(0, currentIndex) ?: return@withContext + var curPrivKey: ByteArray? = curKeyPair.first + try { + val currentUtxos = node.getUtxos(currentAddr) + val fundFee = (10L + 148L * currentUtxos.size + 34L * 2) * satPerByte + val tx = RavencoinTxBuilder.buildAndSign( + currentUtxos, addr, 10000000L, fundFee, currentAddr, curPrivKey!!, curKeyPair.second + ) + node.broadcast(tx.hex) + // Poll until funding UTXOs are visible in mempool (up to 60s) + var waited = 0 + while (waited < 60) { + val fundedUtxos = try { node.getUtxos(addr) } catch (_: Exception) { emptyList() } + if (fundedUtxos.isNotEmpty()) break + kotlinx.coroutines.delay(3000) + waited += 3 + } + } finally { + curPrivKey?.fill(0) + } + } + + val targetAddr = getAddress(0, currentIndex) ?: return@withContext + val sweepResult = node.getUtxosAndAllAssetUtxosBatch(addr) + val totalSweepInputs = sweepResult.first.size + sweepResult.third.values.sumOf { it.size } + val sweepFee = (10L + 148L * totalSweepInputs + 34L * (1 + sweepResult.third.size)) * satPerByte + val tx = RavencoinTxBuilder.buildAndSignRvnSendWithAssetSweep( + rvnUtxos = sweepResult.first, + assetUtxos = sweepResult.third, + toAddress = targetAddr, + amountSat = 0L, + feeSat = sweepFee, + changeAddress = targetAddr, + privKeyBytes = privKey, + pubKeyBytes = pubKey + ) + node.broadcast(tx.hex) + android.util.Log.i("WalletManager", "AutoHeal/Sweep: Consolidated index $index to $currentIndex") + } + } catch (e: Exception) { + android.util.Log.e("WalletManager", "Heal/Sweep failed for index $index", e) + } finally { + privKey.fill(0) + } + } +/** + * Consolidate all funds (RVN + assets) from addresses 0..currentIndex to a fresh address. + */ +suspend fun consolidateAllFundsToFreshAddress(): String? = withContext(Dispatchers.IO) { + val currentIndex = getCurrentAddressIndex() + android.util.Log.i("WalletManager", "consolid: START - currentIndex=$currentIndex") + + val node = RavencoinPublicNode(context) + val nextIndex = currentIndex + 1 + + // STEP 1: Derive ALL addresses in ONE Keystore decrypt. + val allAddresses = getAddressBatch(0, 0..nextIndex) + val targetAddress = allAddresses[nextIndex] + if (targetAddress == null) { + android.util.Log.w("WalletManager", "consolid: cannot derive target at index $nextIndex") + return@withContext null + } + + android.util.Log.i("WalletManager", "consolid: derived ${allAddresses.size} addresses in 1 Keystore decrypt, target=$targetAddress (index $nextIndex)") + + // STEP 2: Scan addresses for funds in controlled batches of 5. + data class AddrFunds( + val index: Int, + val rvnUtxos: List, + val assetUtxos: Map> + ) + + val allFunds = mutableListOf() + val SCAN_BATCH = 5 + + for (batchStart in 0..currentIndex step SCAN_BATCH) { + val batchEnd = minOf(batchStart + SCAN_BATCH - 1, currentIndex) + val batchIndices = (batchStart..batchEnd).filter { allAddresses.containsKey(it) } + + android.util.Log.i("WalletManager", "consolid: --- batch ${batchStart}..${batchEnd} ---") + + val results = batchIndices.map { i -> + async { + val addr = allAddresses[i]!! + try { + val r = node.getUtxosAndAllAssetUtxosBatch(addr) + val rvnCount = r.first.size + val rvnTotal = r.first.sumOf { it.satoshis } + val assetNames = r.third.keys.toList() + + if (rvnCount > 0 || assetNames.isNotEmpty()) { + android.util.Log.i("WalletManager", "consolid: index $i -> $addr => RVN=$rvnCount (${rvnTotal / 1e8}), assets=$assetNames") + AddrFunds(i, r.first, r.third) + } else { + // SECONDARY ASSET CHECK: many ElectrumX servers don't return + // asset UTXOs in listunspent. Use getAssetBalances() instead. + try { + val assetBalances = node.getAssetBalances(addr) + if (assetBalances.isNotEmpty()) { + android.util.Log.i("WalletManager", "consolid: index $i -> $addr => EMPTY in listunspent BUT has assets via get_balance: ${assetBalances.map { "${it.name}=${it.amount}" }}") + val assetUtxosMap = mutableMapOf>() + for (ab in assetBalances) { + try { + val utxos = node.getAssetUtxosFull(addr, ab.name) + if (utxos.isNotEmpty()) { + assetUtxosMap[ab.name] = utxos + } + } catch (e: Exception) { + android.util.Log.w("WalletManager", "consolid: getAssetUtxosFull failed for ${ab.name}: ${e.message}") + } + } + val rvnUtxos = node.getUtxos(addr) + if (assetUtxosMap.isNotEmpty() || rvnUtxos.isNotEmpty()) { + AddrFunds(i, rvnUtxos, assetUtxosMap) + } else null + } else { + android.util.Log.d("WalletManager", "consolid: index $i -> $addr => EMPTY (no assets either)") + null + } + } catch (e: Exception) { + android.util.Log.d("WalletManager", "consolid: index $i -> $addr => EMPTY (asset check failed: ${e.message})") + null + } + } + } catch (e: Exception) { + android.util.Log.e("WalletManager", "consolid: index $i -> $addr => FAILED: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + }.awaitAll().filterNotNull() + + allFunds.addAll(results) + } + + // Summary + android.util.Log.i("WalletManager", "consolid: SCAN COMPLETE: checked ${currentIndex + 1} addresses, found funds on ${allFunds.size}") + for (af in allFunds.sortedBy { it.index }) { + val rvnTotal = af.rvnUtxos.sumOf { it.satoshis } + val assetNames = af.assetUtxos.keys.toList() + val assetRvnTotal = af.assetUtxos.values.flatten().sumOf { it.utxo.satoshis } + android.util.Log.i("WalletManager", "consolid: index ${af.index}: RVN=${"%.8f".format(rvnTotal / 1e8)}, assetAttachedRVN=${"%.8f".format(assetRvnTotal / 1e8)}, assets=$assetNames") + } + + if (allFunds.isEmpty()) { + android.util.Log.i("WalletManager", "consolid: no funds found on any address 0..$currentIndex") + return@withContext null + } + + // STEP 2.5: Fund addresses that have assets but no RVN. + // Find address with most RVN to use as "sacrificial" funder + val richestRvnAddr = allFunds.maxByOrNull { it.rvnUtxos.sumOf { u -> u.satoshis } } + val sacrificialIndex = richestRvnAddr?.index + + // Track which outpoints were spent by funding txs, to exclude them from re-scan + val spentOutpoints = mutableSetOf() + + val addressesNeedingFunding = allFunds.filter { it.rvnUtxos.isEmpty() && it.assetUtxos.isNotEmpty() } + if (addressesNeedingFunding.isNotEmpty()) { + android.util.Log.i("WalletManager", "consolid: ${addressesNeedingFunding.size} address(es) have assets but no RVN, funding first") + for (addrFunds in addressesNeedingFunding) { + val addr = allAddresses[addrFunds.index] ?: continue + val assetCount = addrFunds.assetUtxos.keys.size + android.util.Log.i("WalletManager", "consolid: funding index ${addrFunds.index} ($addr) with 10 RVN for $assetCount asset types") + + // Fund with 10 RVN: enough to pay the consolidation fee, the rest returns as change + val fundAmountSat = 1_000_000_000L // 10 RVN + val satPerByte = try { node.getMinRelayFeeRateSatPerByte() } catch (_: FeeUnavailableException) { 200L } + val fundingTxFee = 300L * satPerByte // simple 1-in, 2-out tx + + val sacAddr = allAddresses[sacrificialIndex] ?: "" + val sacAssetOutpoints = try { node.getAllAssetOutpoints(sacAddr) } catch (_: Exception) { emptySet() } + val sacUtxos = try { + node.getUtxos(allAddresses[sacrificialIndex]!!) + .filter { "${it.txid}:${it.outputIndex}" !in sacAssetOutpoints } + } catch (_: Exception) { emptyList() } + + if (sacrificialIndex == null || sacUtxos.isEmpty()) { + android.util.Log.w("WalletManager", "consolid: no sacrificial address or no RVN available, skipping funding") + continue + } + + val totalIn = sacUtxos.sumOf { it.satoshis } + if (totalIn < fundAmountSat + fundingTxFee) { + android.util.Log.w("WalletManager", "consolid: sacrificial address has insufficient RVN: ${totalIn / 1e8} < ${(fundAmountSat + fundingTxFee) / 1e8}") + continue + } + + // Track spent outpoints from sacrificial address + for (utxo in sacUtxos) { + spentOutpoints.add("${utxo.txid}:${utxo.outputIndex}") + } + android.util.Log.i("WalletManager", "consolid: tracking ${spentOutpoints.size} spent outpoint(s) from funding") + + val privKey = try { getPrivateKeyBytes(0, sacrificialIndex) } catch (_: Exception) { null } + val pubKey = try { getPublicKeyBytes(0, sacrificialIndex) } catch (_: Exception) { null } + if (privKey == null || pubKey == null) { + android.util.Log.w("WalletManager", "consolid: cannot get keys for sacrificial index $sacrificialIndex") + continue + } + + try { + val sacChangeAddr = allAddresses[sacrificialIndex]!! + val tx = RavencoinTxBuilder.buildAndSign( + utxos = sacUtxos, + toAddress = addr, + amountSat = fundAmountSat, + feeSat = fundingTxFee, + changeAddress = sacChangeAddr, + privKeyBytes = privKey, + pubKeyBytes = pubKey + ) + val txid = node.broadcast(tx.hex) + android.util.Log.i("WalletManager", "consolid: funded $addr with ${fundAmountSat / 1e8} RVN from sacrificial $sacrificialIndex: $txid") + + val fundResult = FundingResult(txid, Utxo( + txid = txid, + outputIndex = 0, + satoshis = fundAmountSat, + script = addressToP2pkhScript(addr), + height = 0 + )) + + // DO NOT wait for confirmation: proceed immediately to avoid + // race conditions with background sweep workers that may try to + // spend the same UTXOs. We know the funding tx is valid. + android.util.Log.i("WalletManager", "consolid: proceeding immediately with funded UTXO (tx in mempool, not yet confirmed)") + + // Update allFunds with the funded UTXO directly : don't rely on server re-scan + // which might report stale data or miss the new UTXO + val idx = allFunds.indexOfFirst { it.index == addrFunds.index } + if (idx >= 0) { + allFunds[idx] = AddrFunds(addrFunds.index, listOf(fundResult.fundUtxo), addrFunds.assetUtxos) + android.util.Log.i("WalletManager", "consolid: updated allFunds for index ${addrFunds.index}: 1 funded RVN UTXO, ${addrFunds.assetUtxos.size} asset type(s)") + } + } catch (e: Exception) { + android.util.Log.e("WalletManager", "consolid: funding failed for $addr: ${e.message}") + } + } + } + + // Re-scan ONLY non-funded addresses. Funded addresses already have correct + // UTXO data set manually (funded UTXO + asset UTXOs from initial scan). + // Server re-scan would return stale/conflicting data. + // Filter out spent outpoints (from funding tx inputs) for the sacrificial address. + val fundedIndices = addressesNeedingFunding.map { it.index }.toSet() + android.util.Log.i("WalletManager", "consolid: re-scanning non-funded addresses, excluding ${spentOutpoints.size} spent outpoint(s)") + + for (i in allFunds.indices) { + val af = allFunds[i] + // Skip funded addresses : they already have correct UTXO data + if (af.index in fundedIndices) { + android.util.Log.i("WalletManager", "consolid: skipping re-scan for funded index ${af.index}") + continue + } + val addr = allAddresses[af.index] ?: continue + try { + val rawRvnUtxos = node.getUtxos(addr) + val rvnUtxos = rawRvnUtxos.filter { "${it.txid}:${it.outputIndex}" !in spentOutpoints } + // Keep existing asset data from initial scan + allFunds[i] = AddrFunds(af.index, rvnUtxos, af.assetUtxos) + android.util.Log.i("WalletManager", "consolid: re-scan index ${af.index}: ${rvnUtxos.size} RVN UTXO(s) (filtered ${rawRvnUtxos.size - rvnUtxos.size} spent), ${af.assetUtxos.size} asset type(s)") + } catch (e: Exception) { + android.util.Log.w("WalletManager", "consolid: re-scan failed for index ${af.index}: ${e.message}") + } + } + + // STEP 3: Get key pairs for ALL involved addresses in ONE batch decrypt. + val minIdx = allFunds.minOf { it.index } + val maxIdx = allFunds.maxOf { it.index } + val keyPairs = getKeyPairBatch(0, minIdx..maxIdx) + + // STEP 4: Build keyed inputs from ALL funded addresses. + // Deduplicate by outpoint to avoid "bad-txns-inputs-duplicate" errors. + val allRvnKeyed = mutableListOf() + val allAssetKeyed = mutableMapOf>() + val seenRvnOutpoints = mutableSetOf() + val seenAssetOutpoints = mutableSetOf() + + for (af in allFunds) { + val keyPair = keyPairs[af.index] + if (keyPair == null) { + android.util.Log.w("WalletManager", "consolid: no key pair for index ${af.index}, skipping") + continue + } + val priv = keyPair.first + val pub = keyPair.second + + val assetOutpoints = af.assetUtxos.values.flatten() + .map { outpoint -> "${outpoint.utxo.txid}:${outpoint.utxo.outputIndex}" }.toSet() + + val pureRvn = af.rvnUtxos.filter { utxo -> + val op = "${utxo.txid}:${utxo.outputIndex}" + op !in assetOutpoints && seenRvnOutpoints.add(op) + } + + for (utxo in pureRvn) { + allRvnKeyed.add(RavencoinTxBuilder.KeyedUtxo(utxo, priv, pub)) + } + + for ((name, utxos) in af.assetUtxos) { + allAssetKeyed.getOrPut(name) { mutableListOf() } + .addAll(utxos.filter { assetUtxo -> + val op = "${assetUtxo.utxo.txid}:${assetUtxo.utxo.outputIndex}" + seenAssetOutpoints.add(op) + }.map { assetUtxo -> RavencoinTxBuilder.KeyedAssetUtxo(assetUtxo, priv, pub) }) + } + } + + val hasRvn = allRvnKeyed.isNotEmpty() + val hasAssets = allAssetKeyed.isNotEmpty() + + if (!hasRvn && !hasAssets) { + android.util.Log.w("WalletManager", "consolid: no spendable UTXOs after filtering") + return@withContext null + } + + // STEP 5: Estimate fee with realistic sizing. + val rawSatPerByte = try { + node.getMinRelayFeeRateSatPerByte() + } catch (_: FeeUnavailableException) { 200L } + + // Use a high floor and cap : Ravencoin network has been raising min relay fees. + // For large consolidation txs, underpaying fees causes silent rejection. + val minFloor = 500L // minimum 500 sat/byte for safety + val SAT_PER_BYTE_CAP = 2000L // cap at 2000 sat/byte for very large txs + val satPerByte = minOf(maxOf(rawSatPerByte, minFloor), SAT_PER_BYTE_CAP) + + val totalRvnInputs = allRvnKeyed.size + val totalAssetInputs = allAssetKeyed.values.sumOf { it.size } + val totalInputs = totalRvnInputs + totalAssetInputs + val totalAssetOutputs = allAssetKeyed.size + + // Tight byte estimate: ~150 bytes per signed P2PKH input, ~34 bytes per RVN output, + // ~85 bytes per asset output (extra OP_RVN_ASSET payload). +10 bytes header. + val estimatedBytes = 10L + 150L * totalInputs + 34L + 85L * totalAssetOutputs + val feeSat = estimatedBytes * satPerByte + + android.util.Log.i("WalletManager", "consolid: fee estimate : ${estimatedBytes} bytes at ${satPerByte} sat/byte = ${feeSat} sat (raw relay fee was ${rawSatPerByte})") + + // ═══════════════════════════════════════════════════════════════════════ + // CRITICAL FIX: Asset dust reservation + // + // Each Ravencoin asset output requires at least DUST_LIMIT satoshis + // of RVN attached (anti-dust rule). With 19 assets that's + // 19 × 546 = 10,374 sat that CANNOT be used as payment. + // + // Previous code set amountSat = totalPureRvn - feeSat, which consumed + // ALL the RVN and left nothing for the asset dust, causing the + // transaction to fail (outputs > inputs). + // ═══════════════════════════════════════════════════════════════════════ + val DUST_LIMIT = 546L + val totalAssetDust = totalAssetOutputs * DUST_LIMIT + + val totalPureRvn = allRvnKeyed.sumOf { it.utxo.satoshis } + val totalAssetAttachedRvn = allAssetKeyed.values.flatten().sumOf { it.assetUtxo.utxo.satoshis } + val totalRvnAvailable = totalPureRvn + totalAssetAttachedRvn + + android.util.Log.i("WalletManager", "consolid: RVN breakdown : pure=$totalPureRvn, assetAttached=$totalAssetAttachedRvn, total=$totalRvnAvailable") + android.util.Log.i("WalletManager", "consolid: fee = $feeSat sat, assetDust = $totalAssetDust sat ($totalAssetOutputs outputs × $DUST_LIMIT)") + + // Reserve RVN for: fee + asset dust + at least dust for RVN change/output + val rvnNeeded = feeSat + totalAssetDust + DUST_LIMIT + if (totalRvnAvailable < rvnNeeded) { + android.util.Log.e("WalletManager", + "consolid: insufficient RVN: have ${"%.8f".format(totalRvnAvailable / 1e8)}, " + + "need ${"%.8f".format(rvnNeeded / 1e8)} (fee + asset dust + min output)") + return@withContext null + } + + // amountSat = drain ALL RVN (pure + asset-attached) minus exact byte fee minus + // dust required for the new asset outputs. Old addresses end with zero satoshis. + val amountSat = totalRvnAvailable - feeSat - totalAssetDust + + android.util.Log.i("WalletManager", "consolid: amountSat=$amountSat, feeSat=$feeSat, assetDust=$totalAssetDust") + + if (amountSat < DUST_LIMIT && !hasAssets) { + android.util.Log.e("WalletManager", "consolid: RVN output below dust limit") + return@withContext null + } + + // STEP 6: Build and broadcast the consolidation transaction. + return@withContext try { + val txid: String + + if (hasAssets || allFunds.size > 1) { + android.util.Log.i("WalletManager", "consolid: multi-address tx : " + + "rvnInputs=$totalRvnInputs, assetInputs=$totalAssetInputs, " + + "assetOutputs=$totalAssetOutputs, amountSat=$amountSat, feeSat=$feeSat, assetDust=$totalAssetDust") + + val tx = RavencoinTxBuilder.buildAndSignMultiAddressSend( + currentRvnInputs = allRvnKeyed, + extraRvnInputs = emptyList(), + assetInputsByName = allAssetKeyed, + toAddress = targetAddress, + amountSat = amountSat, + feeSat = feeSat, + changeAddress = targetAddress + ) + txid = node.broadcast(tx.hex) + + } else { + // Single address, RVN only + val totalSat = allRvnKeyed.sumOf { it.utxo.satoshis } + val sendAmount = totalSat - feeSat + + if (sendAmount <= DUST_LIMIT) { + android.util.Log.e("WalletManager", + "consolid: amount after fee ($sendAmount sat) is below dust limit") + return@withContext null + } + + val singleKeyPair = keyPairs[allFunds.first().index] + if (singleKeyPair == null) { + android.util.Log.e("WalletManager", "consolid: no key pair for single-address sweep") + return@withContext null + } + val utxos = allRvnKeyed.map { it.utxo } + + android.util.Log.i("WalletManager", "consolid: single-address RVN sweep : " + + "totalIn=$totalSat, send=$sendAmount, fee=$feeSat") + + val tx = RavencoinTxBuilder.buildAndSign( + utxos = utxos, + toAddress = targetAddress, + amountSat = sendAmount, + feeSat = feeSat, + changeAddress = targetAddress, + privKeyBytes = singleKeyPair.first, + pubKeyBytes = singleKeyPair.second + ) + txid = node.broadcast(tx.hex) + } + + setCurrentAddressIndex(nextIndex) + android.util.Log.i("WalletManager", "consolid: SUCCESS - txid=$txid, new index=$nextIndex") + txid + + } catch (e: Exception) { + // Log full exception details for debugging + android.util.Log.e("WalletManager", "consolid: FAILED : ${e.javaClass.simpleName}: ${e.message}", e) + null + } finally { + keyPairs.values.forEach { (priv, _) -> priv.fill(0) } + } +} + +/** + * Get owned assets for all wallet addresses. + * + * Uses ElectrumX batch API to fetch asset balances for all addresses. + * Returns list of assets sorted by type and name. + * + * @return List of owned assets with name, balance, and type + */ +suspend fun getOwnedAssets(): List = withContext(Dispatchers.IO) { + val node = RavencoinPublicNode(context) + val currentIndex = getCurrentAddressIndex() + val addresses = getAddressBatch(0, 0..currentIndex).values.toList() + + if (addresses.isEmpty()) return@withContext emptyList() + + android.util.Log.i("WalletManager", "Fetching owned assets for ${addresses.size} addresses") + + try { + val totals = node.getTotalAssetBalances(addresses) + + totals.map { (name, amount) -> + val type = when { + name.contains('#') -> io.raventag.app.ravencoin.AssetType.UNIQUE + name.contains('/') -> io.raventag.app.ravencoin.AssetType.SUB + else -> io.raventag.app.ravencoin.AssetType.ROOT + } + io.raventag.app.ravencoin.OwnedAsset( + name = name, + balance = amount, + type = type, + ipfsHash = null + ) + }.sortedWith(compareBy({ it.type.ordinal }, { it.name })) + } catch (e: Exception) { + android.util.Log.e("WalletManager", "Failed to fetch owned assets", e) + emptyList() + } +} + +/** + * Get transaction history for all wallet addresses. + * + * Uses ElectrumX blockchain.address.subscribe to fetch transaction history. + * Returns list of transactions sorted by height (descending, newest first). + * + * @return List of transaction history entries with txid, amount, confirmations + */ +suspend fun getTransactionHistory(): List = withContext(Dispatchers.IO) { + val node = RavencoinPublicNode(context) + val currentIndex = getCurrentAddressIndex() + // Include currentIndex+1 (change address) so classification correctly + // attributes change outputs to the wallet instead of "sent to others". + val addresses = getAddressBatch(0, 0..(currentIndex + 1)).values.toList() + val ownedSet = addresses.toSet() + + if (addresses.isEmpty()) return@withContext emptyList() + + android.util.Log.i("WalletManager", "Fetching transaction history for ${addresses.size} addresses") + + try { + val historyEntries = mutableListOf() + + // Fetch history for each address using ElectrumX, passing full owned set + // so each tx is classified consistently (sent / cycled / fee). + for (address in addresses) { + try { + val history = node.getTransactionHistory(address, ownedAddresses = ownedSet) + historyEntries.addAll(history) + } catch (e: Exception) { + android.util.Log.w("WalletManager", "Failed to fetch history for $address", e) + // Continue with next address + } + } + + // Deduplicate by txid (same tx may appear in multiple address histories) + val deduped = historyEntries.distinctBy { it.txid } + + // Sort by block height descending (newest first) + val sorted = deduped.sortedWith( + compareByDescending { + if (it.height <= 0) Int.MAX_VALUE else it.height + }.thenByDescending { it.timestamp } + ) + + android.util.Log.i("WalletManager", "Loaded ${sorted.size} transactions from history") + sorted + } catch (e: Exception) { + android.util.Log.e("WalletManager", "Failed to fetch transaction history", e) + emptyList() + } +} + } diff --git a/android/app/src/main/java/io/raventag/app/wallet/cache/PendingConsolidationDao.kt b/android/app/src/main/java/io/raventag/app/wallet/cache/PendingConsolidationDao.kt new file mode 100644 index 0000000..e49073a --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/cache/PendingConsolidationDao.kt @@ -0,0 +1,63 @@ +package io.raventag.app.wallet.cache + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase + +/** + * DAO for the pending_consolidations table (D-21). + * + * Tracks consolidation transactions that have been submitted but not yet confirmed. + * Survives app kill and restart so the consolidation-retry logic (plan 30-05) + * can pick up where it left off. + */ +object PendingConsolidationDao { + private const val TABLE = "pending_consolidations" + + data class PendingConsolidation( + val submittedTxid: String, + val submittedAt: Long, + val lastRetryAt: Long?, + val retryCount: Int, + val lastError: String? + ) + + fun init(context: Context) = WalletReliabilityDb.init(context) + + fun upsert(p: PendingConsolidation) { + val db = WalletReliabilityDb.getDatabase() + val cv = ContentValues().apply { + put("submitted_txid", p.submittedTxid) + put("submitted_at", p.submittedAt) + if (p.lastRetryAt != null) put("last_retry_at", p.lastRetryAt) else putNull("last_retry_at") + put("retry_count", p.retryCount) + if (p.lastError != null) put("last_error", p.lastError) else putNull("last_error") + } + db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + + fun clear(submittedTxid: String) { + WalletReliabilityDb.getDatabase().delete(TABLE, "submitted_txid = ?", arrayOf(submittedTxid)) + } + + fun all(): List { + val db = WalletReliabilityDb.getDatabase() + val out = mutableListOf() + db.query( + TABLE, + arrayOf("submitted_txid", "submitted_at", "last_retry_at", "retry_count", "last_error"), + null, null, null, null, "submitted_at ASC" + ).use { c -> + while (c.moveToNext()) { + out += PendingConsolidation( + submittedTxid = c.getString(0), + submittedAt = c.getLong(1), + lastRetryAt = if (c.isNull(2)) null else c.getLong(2), + retryCount = c.getInt(3), + lastError = if (c.isNull(4)) null else c.getString(4) + ) + } + } + return out + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt b/android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt new file mode 100644 index 0000000..23e7f8a --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/cache/ReservedUtxoDao.kt @@ -0,0 +1,91 @@ +package io.raventag.app.wallet.cache + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase + +/** + * DAO for the reserved_utxos table (D-20). + * + * Tracks UTXOs that have been reserved as inputs to a submitted (but unconfirmed) + * transaction. The reserved sum is subtracted from the displayed balance to prevent + * double-spending. Rows are released once the submitted tx confirms, or pruned + * automatically if older than 48 hours (plan 30-05 will add startup prune). + */ +object ReservedUtxoDao { + private const val TABLE = "reserved_utxos" + + data class ReservedUtxo( + val txidIn: String, + val vout: Int, + val valueSat: Long, + val submittedTxid: String, + val submittedAt: Long + ) + + fun init(context: Context) = WalletReliabilityDb.init(context) + + /** Wipe all reserved UTXO rows. Used by deleteWallet. */ + fun clearAll() { + WalletReliabilityDb.getDatabase().execSQL("DELETE FROM $TABLE") + } + + fun reserve(entries: List) { + if (entries.isEmpty()) return + val db = WalletReliabilityDb.getDatabase() + db.beginTransaction() + try { + for (e in entries) { + val cv = ContentValues().apply { + put("txid_in", e.txidIn) + put("vout", e.vout) + put("value_sat", e.valueSat) + put("submitted_txid", e.submittedTxid) + put("submitted_at", e.submittedAt) + } + db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + fun releaseFor(submittedTxid: String) { + val db = WalletReliabilityDb.getDatabase() + db.delete(TABLE, "submitted_txid = ?", arrayOf(submittedTxid)) + } + + fun sumReservedSat(): Long { + val db = WalletReliabilityDb.getDatabase() + db.rawQuery("SELECT COALESCE(SUM(value_sat), 0) FROM $TABLE", null).use { c -> + return if (c.moveToFirst()) c.getLong(0) else 0L + } + } + + fun pruneOlderThan(thresholdMillis: Long) { + val db = WalletReliabilityDb.getDatabase() + db.delete(TABLE, "submitted_at < ?", arrayOf(thresholdMillis.toString())) + } + + fun all(): List { + val db = WalletReliabilityDb.getDatabase() + val out = mutableListOf() + db.query( + TABLE, + arrayOf("txid_in", "vout", "value_sat", "submitted_txid", "submitted_at"), + null, null, null, null, "submitted_at DESC" + ).use { c -> + while (c.moveToNext()) { + out += ReservedUtxo( + txidIn = c.getString(0), + vout = c.getInt(1), + valueSat = c.getLong(2), + submittedTxid = c.getString(3), + submittedAt = c.getLong(4) + ) + } + } + return out + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/cache/TxHistoryDao.kt b/android/app/src/main/java/io/raventag/app/wallet/cache/TxHistoryDao.kt new file mode 100644 index 0000000..132a3a0 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/cache/TxHistoryDao.kt @@ -0,0 +1,138 @@ +package io.raventag.app.wallet.cache + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase + +/** + * DAO for the tx_history table (D-23). + * + * Stores paginated transaction history with three-value breakdown columns + * (sent/cycled/fee) for the WalletScreen transaction list. Rows are upserted + * on each blockchain refresh and queried with LIMIT/OFFSET for pagination. + */ +object TxHistoryDao { + private const val TABLE = "tx_history" + + data class TxHistoryRow( + val txid: String, + val height: Int, + val confirms: Int, + val amountSat: Long, + val sentSat: Long, + val cycledSat: Long, + val feeSat: Long, + val isIncoming: Boolean, + val isSelf: Boolean, + val timestamp: Long, + val cachedAt: Long + ) + + fun init(context: Context) = WalletReliabilityDb.init(context) + + /** Wipe all cached tx history. Used by deleteWallet. */ + fun clearAll() { + WalletReliabilityDb.getDatabase().execSQL("DELETE FROM $TABLE") + } + + fun upsert(rows: List) { + if (rows.isEmpty()) return + val db = WalletReliabilityDb.getDatabase() + db.beginTransaction() + try { + for (r in rows) { + val cv = ContentValues().apply { + put("txid", r.txid) + put("height", r.height) + put("confirms", r.confirms) + put("amount_sat", r.amountSat) + put("sent_sat", r.sentSat) + put("cycled_sat", r.cycledSat) + put("fee_sat", r.feeSat) + put("is_incoming", if (r.isIncoming) 1 else 0) + put("is_self", if (r.isSelf) 1 else 0) + put("timestamp", r.timestamp) + put("cached_at", r.cachedAt) + } + db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + /** Paged list: mempool rows (height=0) sort last, confirmed rows by height DESC. */ + fun page(limit: Int, offset: Int): List { + val db = WalletReliabilityDb.getDatabase() + val out = mutableListOf() + val orderBy = "CASE WHEN height = 0 THEN 1 ELSE 0 END DESC, height DESC, timestamp DESC" + db.query( + TABLE, + arrayOf( + "txid", "height", "confirms", "amount_sat", "sent_sat", "cycled_sat", + "fee_sat", "is_incoming", "is_self", "timestamp", "cached_at" + ), + null, null, null, null, orderBy, "$limit OFFSET $offset" + ).use { c -> + while (c.moveToNext()) { + out += TxHistoryRow( + txid = c.getString(0), + height = c.getInt(1), + confirms = c.getInt(2), + amountSat = c.getLong(3), + sentSat = c.getLong(4), + cycledSat = c.getLong(5), + feeSat = c.getLong(6), + isIncoming = c.getInt(7) == 1, + isSelf = c.getInt(8) == 1, + timestamp = c.getLong(9), + cachedAt = c.getLong(10) + ) + } + } + return out + } + + fun findByTxid(txid: String): TxHistoryRow? { + val db = WalletReliabilityDb.getDatabase() + db.query( + TABLE, + arrayOf( + "txid", "height", "confirms", "amount_sat", "sent_sat", "cycled_sat", + "fee_sat", "is_incoming", "is_self", "timestamp", "cached_at" + ), + "txid = ?", arrayOf(txid), null, null, null + ).use { c -> + if (!c.moveToFirst()) return null + return TxHistoryRow( + txid = c.getString(0), + height = c.getInt(1), + confirms = c.getInt(2), + amountSat = c.getLong(3), + sentSat = c.getLong(4), + cycledSat = c.getLong(5), + feeSat = c.getLong(6), + isIncoming = c.getInt(7) == 1, + isSelf = c.getInt(8) == 1, + timestamp = c.getLong(9), + cachedAt = c.getLong(10) + ) + } + } + + /** + * D-23 paged tx history with argument order `(offset, limit)` matching + * [io.raventag.app.wallet.RavencoinPublicNode.getHistoryPaged]. Default + * page size 20 per UI-SPEC Load more. + */ + fun getPage(offset: Int, limit: Int = 20): List = + page(limit = limit, offset = offset) + + fun count(): Int { + val db = WalletReliabilityDb.getDatabase() + db.rawQuery("SELECT COUNT(*) FROM $TABLE", null).use { c -> + return if (c.moveToFirst()) c.getInt(0) else 0 + } + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/cache/WalletCacheDao.kt b/android/app/src/main/java/io/raventag/app/wallet/cache/WalletCacheDao.kt new file mode 100644 index 0000000..d81e6e2 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/cache/WalletCacheDao.kt @@ -0,0 +1,129 @@ +package io.raventag.app.wallet.cache + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.raventag.app.wallet.AssetUtxo +import io.raventag.app.wallet.Utxo + +/** + * DAO for the wallet_state_cache table (D-04). + * + * Stores a single row keyed by wallet_id="default" containing the last-known + * balance, serialized UTXO list, serialized asset-UTXO map, and block height. + * Opening WalletScreen reads this row instantly from SQLite. + * + * Also exports the pure helper [computeSpendableBalanceSat] which subtracts + * reserved-UTXO value from the confirmed total, clamped to zero. + */ +object WalletCacheDao { + private const val TABLE = "wallet_state_cache" + private const val WALLET_ID = "default" + private val gson = Gson() + + data class CachedWalletState( + val walletId: String, + val balanceSat: Long, + val utxos: List, + val assetUtxos: Map>, + val blockHeight: Int, + val lastRefreshedAt: Long + ) + + fun init(context: Context) = WalletReliabilityDb.init(context) + + fun writeState( + utxos: List, + assetUtxos: Map>, + blockHeight: Int + ) { + val db = WalletReliabilityDb.getDatabase() + val reservedSat = ReservedUtxoDao.sumReservedSat() + val displaySat = computeSpendableBalanceSat(utxos, reservedSat) + val cv = ContentValues().apply { + put("wallet_id", WALLET_ID) + put("balance_sat", displaySat) + put("utxos_json", gson.toJson(utxos)) + put("asset_utxos_json", gson.toJson(assetUtxos)) + put("block_height", blockHeight) + put("last_refreshed_at", System.currentTimeMillis()) + } + db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + + fun readState(): CachedWalletState? { + val db = WalletReliabilityDb.getDatabase() + db.query( + TABLE, arrayOf( + "wallet_id", "balance_sat", "utxos_json", "asset_utxos_json", + "block_height", "last_refreshed_at" + ), "wallet_id = ?", arrayOf(WALLET_ID), null, null, null + ).use { c -> + if (!c.moveToFirst()) return null + val utxosType = object : TypeToken>() {}.type + val assetsType = object : TypeToken>>() {}.type + return CachedWalletState( + walletId = c.getString(0), + balanceSat = c.getLong(1), + utxos = gson.fromJson>(c.getString(2), utxosType) ?: emptyList(), + assetUtxos = gson.fromJson>>(c.getString(3), assetsType) + ?: emptyMap(), + blockHeight = c.getInt(4), + lastRefreshedAt = c.getLong(5) + ) + } + } + + fun getLastRefreshedAt(): Long = readState()?.lastRefreshedAt ?: 0L + + /** Lightweight write that updates only the balance + last-refreshed timestamp, + * preserving any previously cached UTXO/asset/blockHeight payloads. Lets the + * cold-start path show the last-known balance instead of zero. */ + fun writeBalanceSat(balanceSat: Long) { + val prev = readState() + val db = WalletReliabilityDb.getDatabase() + val cv = ContentValues().apply { + put("wallet_id", WALLET_ID) + put("balance_sat", balanceSat) + put("utxos_json", gson.toJson(prev?.utxos ?: emptyList())) + put("asset_utxos_json", gson.toJson(prev?.assetUtxos ?: emptyMap>())) + put("block_height", prev?.blockHeight ?: 0) + put("last_refreshed_at", System.currentTimeMillis()) + } + db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + + /** Lightweight write that updates only the block height + last-refreshed + * timestamp, preserving balance/UTXO/asset payloads. */ + fun writeBlockHeight(blockHeight: Int) { + val prev = readState() + val db = WalletReliabilityDb.getDatabase() + val cv = ContentValues().apply { + put("wallet_id", WALLET_ID) + put("balance_sat", prev?.balanceSat ?: 0L) + put("utxos_json", gson.toJson(prev?.utxos ?: emptyList())) + put("asset_utxos_json", gson.toJson(prev?.assetUtxos ?: emptyMap>())) + put("block_height", blockHeight) + put("last_refreshed_at", System.currentTimeMillis()) + } + db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + + /** Wipe all cached wallet state. Used by deleteWallet so a fresh restore + * does not inherit stale balance/UTXO data from the previous wallet. */ + fun clearAll() { + WalletReliabilityDb.getDatabase().execSQL("DELETE FROM $TABLE") + } + + /** + * Pure helper: confirmed balance minus reserved-UTXO sum, clamped to zero. + * Unit-testable without Android context. + */ + @JvmStatic + fun computeSpendableBalanceSat(utxos: List, reservedSat: Long): Long { + val confirmedSat = utxos.sumOf { it.satoshis } + return (confirmedSat - reservedSat).coerceAtLeast(0L) + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/cache/WalletReliabilityDb.kt b/android/app/src/main/java/io/raventag/app/wallet/cache/WalletReliabilityDb.kt new file mode 100644 index 0000000..f311f14 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/cache/WalletReliabilityDb.kt @@ -0,0 +1,111 @@ +package io.raventag.app.wallet.cache + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +/** + * Single SQLiteOpenHelper owning the wallet_reliability.db database. + * + * Hosts five tables used by Phase 30 DAOs: + * - wallet_state_cache (WalletCacheDao) + * - tx_history (TxHistoryDao) + * - reserved_utxos (ReservedUtxoDao) + * - pending_consolidations (PendingConsolidationDao) + * - quarantined_nodes (QuarantineDao) + * + * PRAGMA synchronous=FULL + journal_mode=WAL guarantee durability of reserved-UTXO + * rows even if the app crashes mid-write (Pitfall 6 from RESEARCH.md). + */ +internal object WalletReliabilityDb { + private const val DB_NAME = "wallet_reliability.db" + private const val DB_VERSION = 1 + + private class Helper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { + init { + setWriteAheadLoggingEnabled(true) + } + + override fun onConfigure(db: SQLiteDatabase) { + db.execSQL("PRAGMA synchronous=FULL;") + db.execSQL("PRAGMA foreign_keys=OFF;") + } + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS wallet_state_cache ( + wallet_id TEXT PRIMARY KEY, + balance_sat INTEGER NOT NULL, + utxos_json TEXT NOT NULL, + asset_utxos_json TEXT NOT NULL, + block_height INTEGER NOT NULL, + last_refreshed_at INTEGER NOT NULL + ) + """.trimIndent()) + db.execSQL(""" + CREATE TABLE IF NOT EXISTS tx_history ( + txid TEXT PRIMARY KEY, + height INTEGER NOT NULL, + confirms INTEGER NOT NULL, + amount_sat INTEGER NOT NULL, + sent_sat INTEGER NOT NULL, + cycled_sat INTEGER NOT NULL, + fee_sat INTEGER NOT NULL, + is_incoming INTEGER NOT NULL, + is_self INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + cached_at INTEGER NOT NULL + ) + """.trimIndent()) + db.execSQL("CREATE INDEX IF NOT EXISTS idx_tx_history_height ON tx_history(height DESC)") + db.execSQL(""" + CREATE TABLE IF NOT EXISTS reserved_utxos ( + txid_in TEXT NOT NULL, + vout INTEGER NOT NULL, + value_sat INTEGER NOT NULL, + submitted_txid TEXT NOT NULL, + submitted_at INTEGER NOT NULL, + PRIMARY KEY(txid_in, vout) + ) + """.trimIndent()) + db.execSQL("CREATE INDEX IF NOT EXISTS idx_reserved_submitted_txid ON reserved_utxos(submitted_txid)") + db.execSQL(""" + CREATE TABLE IF NOT EXISTS pending_consolidations ( + submitted_txid TEXT PRIMARY KEY, + submitted_at INTEGER NOT NULL, + last_retry_at INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT + ) + """.trimIndent()) + db.execSQL(""" + CREATE TABLE IF NOT EXISTS quarantined_nodes ( + host TEXT PRIMARY KEY, + quarantined_until INTEGER NOT NULL, + reason TEXT NOT NULL + ) + """.trimIndent()) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // v1 only: no migration path yet + } + } + + @Volatile + private var helper: Helper? = null + private val initLock = Any() + + fun init(context: Context) { + synchronized(initLock) { + if (helper != null) return + helper = Helper(context.applicationContext) + // Touch writableDatabase to force onConfigure + onCreate + helper!!.writableDatabase + } + } + + fun getDatabase(): SQLiteDatabase = + helper?.writableDatabase + ?: error("WalletReliabilityDb not initialized (call init() from MainActivity.onCreate)") +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/fee/FeeEstimator.kt b/android/app/src/main/java/io/raventag/app/wallet/fee/FeeEstimator.kt new file mode 100644 index 0000000..58add10 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/fee/FeeEstimator.kt @@ -0,0 +1,95 @@ +package io.raventag.app.wallet.fee + +import io.raventag.app.utils.RetryUtils +import io.raventag.app.wallet.RavencoinPublicNode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext + +/** + * D-22 dynamic fee estimator with fallback policy. + * + * Calls [RavencoinPublicNode.estimateFeeRvnPerKb] (or the injected lambda in tests), + * converts the RVN/kB result to sat/kB, and falls back to [FALLBACK_SAT_PER_KB] + * (0.01 RVN/kB = 1_000_000 sat/kB) when the node returns a non-positive value or + * throws any exception. + * + * The node call is wrapped in [RetryUtils.retryWithBackoff] (3 attempts, 500ms base + * delay, 2x backoff) so a single transient failure does not immediately collapse to + * the static fallback. + * + * @param node Optional ElectrumX node for the production code path. + * @param estimateFeeProvider Optional lambda for test injection. When provided, it + * takes precedence over the node-based estimation. + */ +class FeeEstimator( + private val node: RavencoinPublicNode? = null, + private val estimateFeeProvider: (suspend (Int) -> Double)? = null +) { + + /** + * Returns a sat/kB fee rate for the requested block target. + * + * Falls back to [FALLBACK_SAT_PER_KB] (0.01 RVN/kB) on any failure + * or when the server indicates insufficient data (return value <= 0). + * + * @param targetBlocks Number of blocks for the fee estimation target (default 6). + * @return Fee rate in satoshis per kilobyte. + */ + suspend fun estimateSatPerKb(targetBlocks: Int = 6): Long { + val rvnPerKb: Double = try { + RetryUtils.retryWithBackoff(maxAttempts = 3, initialDelayMs = 500L, backoffMultiplier = 2.0) { + invokeProvider(targetBlocks) + } + } catch (_: Exception) { -1.0 } + if (rvnPerKb <= 0.0) return FALLBACK_SAT_PER_KB + val satPerKb = (rvnPerKb * 100_000_000.0).toLong() + return if (satPerKb <= 0L) FALLBACK_SAT_PER_KB else satPerKb + } + + /** + * Same signature but surfaces whether the fallback was used. + * + * UI (SendRvnScreen / TransferScreen) uses this to decide whether + * to show the amber "estimate unavailable" warning (UI-SPEC). + * + * @param targetBlocks Number of blocks for the fee estimation target (default 6). + * @return [Result] containing the fee rate and a flag indicating fallback usage. + */ + suspend fun estimateSatPerKbWithSource(targetBlocks: Int = 6): Result { + val rvnPerKb: Double = try { + RetryUtils.retryWithBackoff(maxAttempts = 3, initialDelayMs = 500L, backoffMultiplier = 2.0) { + invokeProvider(targetBlocks) + } + } catch (_: Exception) { return Result(FALLBACK_SAT_PER_KB, usedFallback = true) } + if (rvnPerKb <= 0.0) return Result(FALLBACK_SAT_PER_KB, usedFallback = true) + val satPerKb = (rvnPerKb * 100_000_000.0).toLong() + // Sanity cap: reject absurdly high fees (> 1.0 RVN/kB = 100_000_000 sat/kB) + if (satPerKb > 100_000_000L) return Result(FALLBACK_SAT_PER_KB, usedFallback = true) + return if (satPerKb <= 0L) Result(FALLBACK_SAT_PER_KB, usedFallback = true) + else Result(satPerKb, usedFallback = false) + } + + /** + * Invokes the appropriate fee provider: the injected lambda if present, + * otherwise the live ElectrumX node. + */ + private suspend fun invokeProvider(targetBlocks: Int): Double { + return if (estimateFeeProvider != null) { + estimateFeeProvider.invoke(targetBlocks) + } else { + val n = node ?: throw IllegalStateException("FeeEstimator requires either a node or a provider lambda") + withContext(Dispatchers.IO) { n.estimateFeeRvnPerKb(targetBlocks) } + } + } + + /** + * Fee estimation result with metadata about whether the fallback value was used. + */ + data class Result(val satPerKb: Long, val usedFallback: Boolean) + + companion object { + /** D-22 fallback: 0.01 RVN/kB = 1_000_000 sat/kB. */ + const val FALLBACK_SAT_PER_KB: Long = 1_000_000L + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/health/NodeHealthMonitor.kt b/android/app/src/main/java/io/raventag/app/wallet/health/NodeHealthMonitor.kt new file mode 100644 index 0000000..9d3751d --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/health/NodeHealthMonitor.kt @@ -0,0 +1,215 @@ +package io.raventag.app.wallet.health + +import android.content.Context +import io.raventag.app.config.AppConfig +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * D-12: coarse-grained connection health used by the WalletScreen pill. + * + * - GREEN: at least one server reported success within the last 60 seconds + * and no recent failures. + * - YELLOW: some servers have failed within the last 30 seconds but at least + * one non-quarantined fallback remains (reconnecting state). + * - RED: every server in the pool is currently quarantined; Send/Receive + * must be disabled by the UI. + */ +enum class ConnectionHealth { GREEN, YELLOW, RED } + +/** + * Singleton, process-wide source of truth for ElectrumX node health + * (D-11 quarantine enforcement, D-12 pill state). + * + * Both one-shot RPC ([io.raventag.app.wallet.RavencoinPublicNode]) and the + * long-lived subscription socket + * ([io.raventag.app.wallet.subscription.SubscriptionManager]) route through + * [nextHealthyNode] before connecting and call [reportSuccess] / + * [reportFailure] / [reportTofuMismatch] after the attempt. + * + * State is split across two layers: + * - In-memory [ConcurrentHashMap]s track "recent failure / success" windows + * used to compute [stateFlow] (authoritative for sub-minute UX). + * - [QuarantineDao] persists 1-hour TOFU-mismatch quarantines across process + * restarts (authoritative for long-lived bans). + * - SharedPreferences persists the last known good host so cold starts skip + * the failover rotation and connect immediately to the previously working + * server. + */ +object NodeHealthMonitor { + + /** Diagnostic row surfaced to the WalletScreen bottom sheet (plan 30-08). */ + data class NodeDiagnostic( + val host: String, + val lastSuccessAt: Long?, + val lastFailureAt: Long?, + val lastError: String?, + val quarantinedUntil: Long? + ) + + private const val QUARANTINE_DURATION_MS: Long = 3_600_000L // D-11: 1 hour + private const val TRANSIENT_COOLDOWN_MS: Long = 8_000L + private const val YELLOW_FAILURE_WINDOW_MS: Long = 30_000L + private const val GREEN_SUCCESS_WINDOW_MS: Long = 60_000L + private const val PREFS_NAME = "node_health_prefs" + private const val KEY_LAST_GOOD_HOST = "last_good_host" + + private val lastSuccessAt = ConcurrentHashMap() + private val lastFailureAt = ConcurrentHashMap() + private val lastError = ConcurrentHashMap() + + private val _state = MutableStateFlow(ConnectionHealth.GREEN) + val stateFlow: StateFlow = _state.asStateFlow() + + @Volatile private var initialized = false + private val initLock = Any() + private var appContext: Context? = null + + /** Idempotent init. Safe to call from MainActivity, workers and background paths. */ + fun init(context: Context) { + if (initialized) return + synchronized(initLock) { + if (initialized) return + appContext = context.applicationContext + QuarantineDao.init(context) + initialized = true + } + } + + /** + * Returns the next host in "host:port" form that is NOT currently + * quarantined and is outside the 8s transient-failure cooldown, or null + * if every pool entry is unavailable. + * + * Tries the last known good host (persisted across restarts) first so + * cold starts skip the failover rotation and connect immediately. + */ + fun nextHealthyNode(): String? { + val now = System.currentTimeMillis() + val quarantinedHosts = activeQuarantineHosts(now) + + // Fast path: try the persisted last-good host first on cold start. + // This avoids TCP connect timeout (5s) × N servers when the first + // server in rotation order happens to be down. + val preferred = getPreferredHost() + if (preferred != null && preferred !in quarantinedHosts) { + val failedAt = lastFailureAt[preferred] + if (failedAt == null || (now - failedAt) > TRANSIENT_COOLDOWN_MS) { + recomputeState() + return preferred + } + } + + // Fallback: standard rotation order + val candidate = AppConfig.ELECTRUM_SERVERS.firstOrNull { (host, port) -> + val key = "$host:$port" + if (key in quarantinedHosts) return@firstOrNull false + val failedAt = lastFailureAt[key] + failedAt == null || (now - failedAt) > TRANSIENT_COOLDOWN_MS + }?.let { (h, p) -> "$h:$p" } + recomputeState() + return candidate + } + + fun reportSuccess(host: String) { + val now = System.currentTimeMillis() + lastSuccessAt[host] = now + lastFailureAt.remove(host) + lastError.remove(host) + savePreferredHost(host) + recomputeState() + } + + fun reportFailure(host: String, reason: String) { + val now = System.currentTimeMillis() + lastFailureAt[host] = now + lastError[host] = reason + recomputeState() + } + + fun reportTofuMismatch(host: String) { + val now = System.currentTimeMillis() + QuarantineDao.quarantine( + host = host, + durationMillis = QUARANTINE_DURATION_MS, + reason = QuarantineDao.REASON_TOFU_MISMATCH + ) + lastFailureAt[host] = now + lastError[host] = QuarantineDao.REASON_TOFU_MISMATCH + recomputeState() + } + + /** Host with the most recent [reportSuccess] (falls back to persisted on cold start). */ + fun currentNode(): String? = + lastSuccessAt.maxByOrNull { it.value }?.key ?: getPreferredHost() + + fun diagnostics(): List { + val now = System.currentTimeMillis() + val active = QuarantineDao.all() + .filter { it.quarantinedUntil > now } + .associateBy { it.host } + return AppConfig.ELECTRUM_SERVERS.map { (host, port) -> + val key = "$host:$port" + NodeDiagnostic( + host = key, + lastSuccessAt = lastSuccessAt[key], + lastFailureAt = lastFailureAt[key], + lastError = lastError[key], + quarantinedUntil = active[key]?.quarantinedUntil + ) + } + } + + // --- internal --- + + private fun getPreferredHost(): String? { + val ctx = appContext ?: return null + return try { + ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_LAST_GOOD_HOST, null) + } catch (_: Throwable) { null } + } + + private fun savePreferredHost(host: String) { + val ctx = appContext ?: return + try { + ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putString(KEY_LAST_GOOD_HOST, host).apply() + } catch (_: Throwable) {} + } + + private fun activeQuarantineHosts(now: Long): Set = + try { + QuarantineDao.all().asSequence() + .filter { it.quarantinedUntil > now } + .map { it.host } + .toSet() + } catch (_: Throwable) { + // DB not initialized yet (e.g. called from a worker before init): + // treat as "none quarantined" so we still pick a candidate. + emptySet() + } + + private fun recomputeState() { + val now = System.currentTimeMillis() + val total = AppConfig.ELECTRUM_SERVERS.size + val quarantined = activeQuarantineHosts(now).size + val hasAnyData = lastSuccessAt.isNotEmpty() || lastFailureAt.isNotEmpty() + // GREEN takes precedence over YELLOW: once any host answers successfully in + // the last 60s we are connected, regardless of transient failures on other hosts. + val next = when { + quarantined >= total -> ConnectionHealth.RED + lastSuccessAt.values.any { (now - it) <= GREEN_SUCCESS_WINDOW_MS } -> + ConnectionHealth.GREEN + lastFailureAt.values.any { (now - it) <= YELLOW_FAILURE_WINDOW_MS } && + quarantined < total -> ConnectionHealth.YELLOW + // Cold start: no RPC yet → stay optimistic GREEN so the UI does not + // flash a yellow "reconnecting" pill before the first successful call. + !hasAnyData -> ConnectionHealth.GREEN + else -> ConnectionHealth.YELLOW + } + _state.value = next + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/health/QuarantineDao.kt b/android/app/src/main/java/io/raventag/app/wallet/health/QuarantineDao.kt new file mode 100644 index 0000000..1f10514 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/health/QuarantineDao.kt @@ -0,0 +1,56 @@ +package io.raventag.app.wallet.health + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import io.raventag.app.wallet.cache.WalletReliabilityDb + +/** + * DAO for the quarantined_nodes table (D-11). + * + * Tracks ElectrumX nodes that have been temporarily quarantined due to + * TOFU certificate mismatch, RPC failures, or timeouts. A quarantined + * node is skipped for the duration of its quarantine period (default 1 hour). + */ +object QuarantineDao { + private const val TABLE = "quarantined_nodes" + const val REASON_TOFU_MISMATCH = "TOFU_MISMATCH" + const val REASON_RPC_FAILED = "RPC_FAILED" + const val REASON_TIMEOUT = "TIMEOUT" + + data class Quarantine(val host: String, val quarantinedUntil: Long, val reason: String) + + fun init(context: Context) = WalletReliabilityDb.init(context) + + fun quarantine(host: String, durationMillis: Long, reason: String) { + val db = WalletReliabilityDb.getDatabase() + val cv = ContentValues().apply { + put("host", host) + put("quarantined_until", System.currentTimeMillis() + durationMillis) + put("reason", reason) + } + db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + + fun isQuarantined(host: String): Boolean { + val db = WalletReliabilityDb.getDatabase() + db.query(TABLE, arrayOf("quarantined_until"), "host = ?", arrayOf(host), null, null, null).use { c -> + if (!c.moveToFirst()) return false + val until = c.getLong(0) + return until > System.currentTimeMillis() + } + } + + fun clear(host: String) { + WalletReliabilityDb.getDatabase().delete(TABLE, "host = ?", arrayOf(host)) + } + + fun all(): List { + val db = WalletReliabilityDb.getDatabase() + val out = mutableListOf() + db.query(TABLE, arrayOf("host", "quarantined_until", "reason"), null, null, null, null, null).use { c -> + while (c.moveToNext()) out += Quarantine(c.getString(0), c.getLong(1), c.getString(2)) + } + return out + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/subscription/ScripthashEvent.kt b/android/app/src/main/java/io/raventag/app/wallet/subscription/ScripthashEvent.kt new file mode 100644 index 0000000..89a1c39 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/subscription/ScripthashEvent.kt @@ -0,0 +1,26 @@ +package io.raventag.app.wallet.subscription + +/** + * Events emitted by [SubscriptionManager] via its [SubscriptionManager.eventsFlow]. + * + * Subscription notifications use RESEARCH.md Architecture Pattern 1: + * a status change only signals "something changed" and the caller MUST re-fetch + * balance/utxo/history to get the actual data. + */ +sealed class ScripthashEvent { + /** + * ElectrumX pushed a status-hash change for [scripthash]. [newStatus] may be null + * when the server reports "no history". Caller MUST re-fetch balance/utxo/history + * per RESEARCH.md Architecture Pattern 1: subscription only says "something changed". + */ + data class StatusChanged(val scripthash: String, val newStatus: String?) : ScripthashEvent() + + /** The session socket died (network transition, server reset). */ + data object ConnectionLost : ScripthashEvent() + + /** All fallback servers refused connection. D-12 red pill. */ + data object AllNodesDown : ScripthashEvent() + + /** Ping did not return within 60s: socket is a zombie (Pitfall 2). */ + data object PingTimeout : ScripthashEvent() +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionManager.kt b/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionManager.kt new file mode 100644 index 0000000..a720089 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionManager.kt @@ -0,0 +1,266 @@ +package io.raventag.app.wallet.subscription + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import io.raventag.app.wallet.TofuTrustManager +import io.raventag.app.wallet.RavencoinPublicNode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.coroutineContext +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.InetSocketAddress +import java.net.Socket +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket + +/** + * D-05: long-lived TLS socket per WalletScreen foreground session. Emits + * [ScripthashEvent] for each blockchain.scripthash.subscribe notification. + * + * SEPARATE SOCKET from RavencoinPublicNode.call() (Pitfall 1): + * asynchronous notifications cannot share a synchronous read path. + * + * Lifecycle: + * - [start] opens a single TLS socket to the first reachable server, + * performs server.version handshake, subscribes to each address scripthash, + * and launches the reader + heartbeat coroutines. + * - [stop] cancels the scope, closes the socket, clears session state. + * - [eventsFlow] exposes a read-only [SharedFlow] of [ScripthashEvent]. + */ +class SubscriptionManager( + private val context: Context, + @Suppress("UNUSED_PARAMETER") + private val servers: List> = DEFAULT_SERVERS, + private val connectTimeoutMs: Int = 10_000, + private val readTimeoutMs: Int = 20_000, + private val pingIntervalMs: Long = 60_000L +) { + private val events = MutableSharedFlow(extraBufferCapacity = 64) + private val gson = Gson() + private val idCounter = AtomicInteger(1) + private val pending = ConcurrentHashMap Unit>() + private var scope: CoroutineScope? = null + private var session: Session? = null + private val lifecycleLock = Any() + + companion object { + private const val TAG = "SubscriptionManager" + + /** + * Kept for binary/call-site compatibility; the runtime pool is now + * sourced from [io.raventag.app.config.AppConfig.ELECTRUM_SERVERS] + * via [io.raventag.app.wallet.health.NodeHealthMonitor]. + */ + val DEFAULT_SERVERS: List> = + io.raventag.app.config.AppConfig.ELECTRUM_SERVERS + } + + fun eventsFlow(): SharedFlow = events.asSharedFlow() + + /** + * Opens a persistent TLS socket, subscribes to each [addresses] scripthash, + * and starts the reader + heartbeat loops. + * + * On all servers failing, emits [ScripthashEvent.AllNodesDown] and returns. + * Caller decides whether to retry later. + */ + suspend fun start(addresses: List): Unit = withContext(Dispatchers.IO) { + synchronized(lifecycleLock) { + if (session != null) return@withContext // already running + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + + io.raventag.app.wallet.health.NodeHealthMonitor.init(context) + var opened: Session? = null + val poolSize = io.raventag.app.config.AppConfig.ELECTRUM_SERVERS.size + for (attempt in 0 until poolSize) { + if (opened != null) break + val candidate = io.raventag.app.wallet.health.NodeHealthMonitor.nextHealthyNode() + ?: break + val (host, portStr) = candidate.split(":", limit = 2) + val port = portStr.toInt() + try { + opened = openSession(host, port) + io.raventag.app.wallet.health.NodeHealthMonitor.reportSuccess(candidate) + } catch (e: Exception) { + if (isTofuMismatch(e)) { + io.raventag.app.wallet.health.NodeHealthMonitor.reportTofuMismatch(candidate) + } else { + io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( + candidate, + e.javaClass.simpleName + ) + } + } + } + if (opened == null) { + events.emit(ScripthashEvent.AllNodesDown) + synchronized(lifecycleLock) { scope?.cancel(); scope = null } + return@withContext + } + synchronized(lifecycleLock) { session = opened } + + // Handshake + try { + sendAndAwait(opened, "server.version", listOf("RavenTag/1.0", "1.4")) + } catch (_: Exception) { + events.emit(ScripthashEvent.ConnectionLost) + return@withContext + } + + // Subscribe per address + val node = RavencoinPublicNode(context) + for (addr in addresses) { + val sh = node.addressToScripthash(addr) + try { + sendAndAwait(opened, "blockchain.scripthash.subscribe", listOf(sh)) + } catch (_: Exception) { + Log.w(TAG, "subscribe failed for $sh, readLoop may deliver status anyway") + } + } + + // Reader loop + scope?.launch { readLoop(opened) } + // Heartbeat loop + scope?.launch { heartbeatLoop(opened) } + } + + /** + * Cancels the session scope, closes the socket, and clears all pending callbacks. + */ + suspend fun stop() = withContext(Dispatchers.IO) { + synchronized(lifecycleLock) { + scope?.cancel() + scope = null + try { session?.socket?.close() } catch (_: Exception) {} + session = null + pending.clear() + } + } + + // --- internal helpers --- + + private data class Session( + val host: String, + val socket: SSLSocket, + val writer: PrintWriter, + val reader: BufferedReader + ) + + private fun openSession(host: String, port: Int): Session { + val ctx = SSLContext.getInstance("TLS") + ctx.init(null, arrayOf(TofuTrustManager(context, host)), SecureRandom()) + val raw = Socket() + raw.connect(InetSocketAddress(host, port), connectTimeoutMs) + val ssl = ctx.socketFactory.createSocket(raw, host, port, true) as SSLSocket + ssl.soTimeout = readTimeoutMs + ssl.keepAlive = true + val writer = PrintWriter(ssl.outputStream, true) + val reader = BufferedReader(InputStreamReader(ssl.inputStream)) + return Session(host, ssl, writer, reader) + } + + private suspend fun readLoop(s: Session) { + try { + while (coroutineContext.isActive) { + val line = withContext(Dispatchers.IO) { s.reader.readLine() } + if (line == null) { + io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( + sessionKey(s), + "socket_closed" + ) + events.emit(ScripthashEvent.ConnectionLost) + return + } + when (val parsed = SubscriptionParser.parseLine(line)) { + is SubscriptionParser.Parsed.Response -> { + pending.remove(parsed.id)?.invoke(parsed.result) + } + is SubscriptionParser.Parsed.Notification -> { + events.emit(ScripthashEvent.StatusChanged(parsed.scripthash, parsed.status)) + } + is SubscriptionParser.Parsed.Unknown -> { /* ignore */ } + } + } + } catch (e: Exception) { + if (isTofuMismatch(e)) { + io.raventag.app.wallet.health.NodeHealthMonitor.reportTofuMismatch(sessionKey(s)) + } else { + io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( + sessionKey(s), + e.javaClass.simpleName + ) + } + events.emit(ScripthashEvent.ConnectionLost) + } + } + + private suspend fun heartbeatLoop(s: Session) { + try { + while (coroutineContext.isActive) { + delay(pingIntervalMs) + val result = withTimeoutOrNull(pingIntervalMs) { + sendAndAwait(s, "server.ping", emptyList()) + } + if (result == null) { + io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( + sessionKey(s), + "ping_timeout" + ) + events.emit(ScripthashEvent.PingTimeout) + return + } + } + } catch (e: Exception) { + if (isTofuMismatch(e)) { + io.raventag.app.wallet.health.NodeHealthMonitor.reportTofuMismatch(sessionKey(s)) + } else { + io.raventag.app.wallet.health.NodeHealthMonitor.reportFailure( + sessionKey(s), + e.javaClass.simpleName + ) + } + events.emit(ScripthashEvent.ConnectionLost) + } + } + + private fun sessionKey(s: Session): String = "${s.host}:${s.socket.port}" + + private fun isTofuMismatch(e: Throwable): Boolean { + if (e is java.security.cert.CertificateException) return true + val m = e.message ?: return false + return m.contains("Certificate mismatch", ignoreCase = true) || + m.contains("fingerprint mismatch", ignoreCase = true) || + m.contains("TOFU", ignoreCase = true) + } + + private suspend fun sendAndAwait( + s: Session, + method: String, + params: List + ): com.google.gson.JsonElement? { + val id = idCounter.getAndIncrement() + val deferred = kotlinx.coroutines.CompletableDeferred() + pending[id] = { deferred.complete(it) } + val payload = gson.toJson(mapOf("id" to id, "method" to method, "params" to params)) + withContext(Dispatchers.IO) { s.writer.println(payload) } + return deferred.await() + } +} diff --git a/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionParser.kt b/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionParser.kt new file mode 100644 index 0000000..da05ce6 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/wallet/subscription/SubscriptionParser.kt @@ -0,0 +1,53 @@ +package io.raventag.app.wallet.subscription + +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonParser +import com.google.gson.JsonSyntaxException + +/** + * Pure JSON-RPC line parser for ElectrumX subscription sockets. + * + * Routes each incoming line into one of three categories: + * - [Parsed.Response]: a JSON-RPC response with an integer `id` field. + * - [Parsed.Notification]: a `blockchain.scripthash.subscribe` server push. + * - [Parsed.Unknown]: malformed JSON or any other structure. + * + * Thread-safe: this object is stateless; [parseLine] has no side effects. + */ +object SubscriptionParser { + sealed class Parsed { + data class Response(val id: Int, val result: JsonElement?) : Parsed() + data class Notification(val scripthash: String, val status: String?) : Parsed() + data class Unknown(val raw: String) : Parsed() + } + + fun parseLine(line: String): Parsed { + if (line.isBlank()) return Parsed.Unknown(line) + val obj = try { + JsonParser.parseString(line).asJsonObject + } catch (_: JsonSyntaxException) { return Parsed.Unknown(line) } + catch (_: IllegalStateException) { return Parsed.Unknown(line) } + + // id present: response + val idEl = obj.get("id") + if (idEl != null && !idEl.isJsonNull) { + val id = try { idEl.asInt } catch (_: Exception) { return Parsed.Unknown(line) } + val result: JsonElement? = obj.get("result").takeUnless { it == null || it.isJsonNull } + return Parsed.Response(id = id, result = result) + } + + // server notification + val method = obj.get("method")?.takeUnless { it.isJsonNull }?.asString + ?: return Parsed.Unknown(line) + if (method == "blockchain.scripthash.subscribe") { + val params = obj.getAsJsonArray("params") ?: return Parsed.Unknown(line) + if (params.size() < 1) return Parsed.Unknown(line) + val sh = params.get(0).takeUnless { it.isJsonNull }?.asString + ?: return Parsed.Unknown(line) + val status = if (params.size() >= 2 && !params.get(1).isJsonNull) params.get(1).asString else null + return Parsed.Notification(scripthash = sh, status = status) + } + return Parsed.Unknown(line) + } +} diff --git a/android/app/src/main/java/io/raventag/app/worker/IncomingTxNotificationHelper.kt b/android/app/src/main/java/io/raventag/app/worker/IncomingTxNotificationHelper.kt new file mode 100644 index 0000000..bc47598 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/worker/IncomingTxNotificationHelper.kt @@ -0,0 +1,115 @@ +package io.raventag.app.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import io.raventag.app.MainActivity +import io.raventag.app.R +import java.util.Locale + +/** + * D-06, D-07, D-08: incoming RVN transaction notifications. + * + * Channel: `incoming_tx`, distinct from Phase 20 `transaction_progress` and the legacy + * `raventag_wallet` channel. Tapping the notification opens MainActivity with + * `action = VIEW_TRANSACTION` and `extra txid = `; MainActivity routes to + * TransactionDetailsScreen. + * + * Notification ID strategy per UI-SPEC Implementation Notes: + * id = 2100 + (txid.hashCode() and 0x3FF) -> mod-1024, distinct slots per txid. + */ +object IncomingTxNotificationHelper { + + const val CHANNEL_ID: String = "incoming_tx" + const val ACTION_VIEW_TRANSACTION: String = "VIEW_TRANSACTION" + const val EXTRA_TXID: String = "txid" + + private const val NOTIFICATION_ID_BASE: Int = 2100 + private const val NOTIFICATION_ID_MASK: Int = 0x3FF + + private fun isItalian(): Boolean = + Locale.getDefault().language.startsWith("it", ignoreCase = true) + + fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = if (isItalian()) "Transazioni in arrivo" else "Incoming transactions" + val channel = NotificationChannel( + CHANNEL_ID, + name, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notifications for received RVN and assets" + setShowBadge(true) + } + context.getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + } + + fun showIncoming( + context: Context, + txid: String, + rvnAmount: Double, + confirmations: Int + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + } + + val amountStr = String.format(Locale.ROOT, "%.8f", rvnAmount) + val italian = isItalian() + + val title: String + val text: String + when { + confirmations <= 0 -> { + title = if (italian) "Transazione in arrivo" else "Incoming transaction" + text = if (italian) "+$amountStr RVN · In attesa" + else "+$amountStr RVN · Pending" + } + confirmations < 6 -> { + title = if (italian) "Transazione in arrivo" else "Incoming transaction" + text = if (italian) "+$amountStr RVN · $confirmations/6 conferme" + else "+$amountStr RVN · $confirmations/6 confirmations" + } + else -> { + title = if (italian) "Ricevuto" else "Received" + text = if (italian) "+$amountStr RVN confermati" + else "+$amountStr RVN confirmed" + } + } + + val intent = Intent(context, MainActivity::class.java).apply { + action = ACTION_VIEW_TRANSACTION + putExtra(EXTRA_TXID, txid) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val requestCode = txid.hashCode() + val pendingIntent = PendingIntent.getActivity( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + val id = NOTIFICATION_ID_BASE + (txid.hashCode() and NOTIFICATION_ID_MASK) + NotificationManagerCompat.from(context).notify(id, notification) + } +} diff --git a/android/app/src/main/java/io/raventag/app/worker/RebroadcastWorker.kt b/android/app/src/main/java/io/raventag/app/worker/RebroadcastWorker.kt new file mode 100644 index 0000000..55a8b93 --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/worker/RebroadcastWorker.kt @@ -0,0 +1,141 @@ +package io.raventag.app.worker + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import io.raventag.app.wallet.RavencoinPublicNode +import io.raventag.app.wallet.cache.PendingConsolidationDao +import io.raventag.app.wallet.cache.ReservedUtxoDao +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +/** + * WorkManager worker that auto-rebroadcasts stuck transactions per D-25. + * + * Scheduled as a OneTimeWorkRequest with unique name "rebroadcast-". + * Each run checks if the tx is confirmed (release reservations), otherwise + * attempts a silent rebroadcast and schedules the next attempt on the + * 30/60/120/240/480 min exponential ladder, capped at 5 attempts. + * + * D-27: consolidation ALWAYS broadcasts. The only constraint is + * NetworkType.CONNECTED so we don't waste cycles offline. + * No battery/power-save constraints that would defer broadcast. + */ +class RebroadcastWorker( + ctx: Context, + params: WorkerParameters +) : CoroutineWorker(ctx, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + // D-11/D-12: background workers may run before MainActivity has a + // chance to init() the health monitor, so init defensively here. + io.raventag.app.wallet.health.NodeHealthMonitor.init(applicationContext) + + val txid = inputData.getString(KEY_TXID) ?: return@withContext Result.failure() + val rawHex = inputData.getString(KEY_RAW_HEX) ?: return@withContext Result.failure() + val attempt = inputData.getInt(KEY_ATTEMPT, 0) + + if (attempt >= MAX_ATTEMPTS) { + PendingConsolidationDao.upsert( + PendingConsolidationDao.PendingConsolidation( + submittedTxid = txid, + submittedAt = System.currentTimeMillis(), + lastRetryAt = System.currentTimeMillis(), + retryCount = attempt, + lastError = "rebroadcast cap reached" + ) + ) + return@withContext Result.success() + } + + val node = RavencoinPublicNode(applicationContext) + + // Confirmation check via blockchain.transaction.get. + // If the tx is confirmed, release its reserved UTXOs and clear the + // pending consolidation row. No reschedule needed. + val confirmed = try { + val result = node.callElectrumRawOrNull( + "blockchain.transaction.get", listOf(txid, true) + ) + val confirms = result?.asJsonObject + ?.get("confirmations") + ?.takeIf { !it.isJsonNull } + ?.asInt ?: 0 + confirms > 0 + } catch (_: Exception) { false } + + if (confirmed) { + ReservedUtxoDao.releaseFor(txid) + PendingConsolidationDao.clear(txid) + return@withContext Result.success() + } + + // Rebroadcast silently per D-25. Double-spend rejection by ElectrumX + // is expected and harmless: it means the tx is already in mempool. + try { node.broadcast(rawHex) } catch (_: Exception) { /* silent */ } + + // Schedule next attempt on the D-25 ladder + val nextDelayMinutes = DELAY_LADDER_MINUTES.getOrElse(attempt) { 480L } + schedule( + context = applicationContext, + txid = txid, + rawHex = rawHex, + attempt = attempt + 1, + initialDelayMinutes = nextDelayMinutes + ) + PendingConsolidationDao.upsert( + PendingConsolidationDao.PendingConsolidation( + submittedTxid = txid, + submittedAt = System.currentTimeMillis(), + lastRetryAt = System.currentTimeMillis(), + retryCount = attempt + 1, + lastError = null + ) + ) + Result.success() + } + + companion object { + const val KEY_TXID = "txid" + const val KEY_RAW_HEX = "raw_hex" + const val KEY_ATTEMPT = "attempt" + const val MAX_ATTEMPTS = 5 + // D-25 ladder: delays AFTER attempt N (attempt 0 = first scheduled 30 min later) + val DELAY_LADDER_MINUTES: List = listOf(30L, 60L, 120L, 240L, 480L) + + /** Public entry used by WalletManager after a successful broadcast. */ + fun schedule( + context: Context, + txid: String, + rawHex: String, + attempt: Int, + initialDelayMinutes: Long + ) { + val req = OneTimeWorkRequestBuilder() + .setInitialDelay(initialDelayMinutes, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setInputData( + workDataOf( + KEY_TXID to txid, + KEY_RAW_HEX to rawHex, + KEY_ATTEMPT to attempt + ) + ) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork("rebroadcast-$txid", ExistingWorkPolicy.REPLACE, req) + } + } +} diff --git a/android/app/src/main/java/io/raventag/app/worker/TransactionNotificationHelper.kt b/android/app/src/main/java/io/raventag/app/worker/TransactionNotificationHelper.kt new file mode 100644 index 0000000..908c77b --- /dev/null +++ b/android/app/src/main/java/io/raventag/app/worker/TransactionNotificationHelper.kt @@ -0,0 +1,178 @@ +package io.raventag.app.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import io.raventag.app.R +import io.raventag.app.MainActivity + +/** + * Helper object for transaction progress notifications during send operations. + * + * Usage: + * 1. Call createChannel(context) once at app start (safe to call repeatedly). + * 2. Call showBroadcasting(context) when transaction starts. + * 3. Call showConfirming(context, confirmations, total) when waiting for blocks. + * 4. Call showCompleted(context, txid) when transaction is confirmed. + * 5. Call showFailed(context, error) on failure. + * + * Per D-03, D-04, D-05, D-06 from CONTEXT.md: + * - Users can dismiss app while transaction broadcasts + * - Tapping notification opens transaction details screen (full implementation, not placeholder) + * - Multiple stage notifications update the same notification slot (ID 2001) + * - Failed notification includes Retry action + */ +object TransactionNotificationHelper { + + private const val CHANNEL_ID = "transaction_progress" + private const val NOTIFICATION_ID = 2001 + private const val ACTION_VIEW_TRANSACTION = "VIEW_TRANSACTION" + private const val EXTRA_TXID = "txid" + private const val EXTRA_ERROR = "error" + + /** + * Create notification channel for transaction progress. + * Must be called before any notification is posted (Android 8+). + */ + fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Transaction Progress", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Blockchain transaction broadcast and confirmation progress" + setShowBadge(false) + enableVibration(false) + setSound(null, null) + } + context.getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + } + + /** + * Show broadcasting notification (ongoing, not cancellable). + */ + fun showBroadcasting(context: Context) { + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Broadcasting...") + .setContentText("Transaction is being broadcast to network") + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } + + /** + * Show confirming notification (ongoing, not cancellable). + */ + fun showConfirming(context: Context, confirmations: Int = 1, total: Int = 1) { + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Confirming ($confirmations/$total)") + .setContentText("Waiting for block confirmation") + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } + + /** + * Show completed notification (tappable, auto-cancellable). + * Tapping opens MainActivity with VIEW_TRANSACTION action and txid extra (per D-04). + */ + fun showCompleted(context: Context, txid: String) { + val intent = Intent(context, MainActivity::class.java).apply { + action = ACTION_VIEW_TRANSACTION + putExtra(EXTRA_TXID, txid) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Completed") + .setContentText("Transaction confirmed on blockchain: ${txid.take(20)}...") + .setOngoing(false) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } + + /** + * Show failed notification (tappable, auto-cancellable with Retry action). + * Retry action sends intent to MainActivity with RETRY_TRANSACTION action. + */ + fun showFailed(context: Context, error: String) { + val retryIntent = Intent(context, MainActivity::class.java).apply { + action = "RETRY_TRANSACTION" + putExtra(EXTRA_ERROR, error) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val retryPendingIntent = PendingIntent.getActivity( + context, + 0, + retryIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Failed") + .setContentText(error) + .setOngoing(false) + .setAutoCancel(true) + .addAction( + R.mipmap.ic_launcher, + "Retry", + retryPendingIntent + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) + } + + /** + * Clear the transaction notification (call when user manually cancels). + */ + fun clear(context: Context) { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + /** + * Transaction lifecycle stages for type-safe notification updates. + */ + enum class TransactionStage { + BROADCASTING, + CONFIRMING, + COMPLETED, + FAILED + } + + // Public constants for use by MainActivity intent handler + const val ACTION_VIEW_TRANSACTION_EXT = ACTION_VIEW_TRANSACTION + const val EXTRA_TXID_EXT = EXTRA_TXID + const val EXTRA_ERROR_EXT = EXTRA_ERROR +} diff --git a/android/app/src/main/java/io/raventag/app/worker/WalletPollingWorker.kt b/android/app/src/main/java/io/raventag/app/worker/WalletPollingWorker.kt index 0ebf5a4..b570d6c 100644 --- a/android/app/src/main/java/io/raventag/app/worker/WalletPollingWorker.kt +++ b/android/app/src/main/java/io/raventag/app/worker/WalletPollingWorker.kt @@ -41,22 +41,31 @@ class WalletPollingWorker( override suspend fun doWork(): Result = withContext(Dispatchers.IO) { try { + // D-11/D-12: background workers may run before MainActivity has a + // chance to init() the health monitor, so init defensively here. + io.raventag.app.wallet.health.NodeHealthMonitor.init(applicationContext) + // Respect the user's notification preference val appPrefs = applicationContext.getSharedPreferences("raventag_app", Context.MODE_PRIVATE) if (!appPrefs.getBoolean("notifications_enabled", true)) return@withContext Result.success() val walletManager = WalletManager(applicationContext) - // getAddress() requires Keystore; returns null if wallet is not set up or device locked - val address = walletManager.getAddress() ?: return@withContext Result.success() + // getCurrentAddress() requires Keystore; returns null if wallet is not set up or device locked + walletManager.getCurrentAddress() ?: return@withContext Result.success() + val currentIndex = walletManager.getCurrentAddressIndex() + + val node = RavencoinPublicNode(applicationContext) - val node = RavencoinPublicNode() + // Derive all addresses with a single Keystore decrypt + val addresses = walletManager.getAddressBatch(0, 0..currentIndex).values.toList() - // ── RVN balance check ────────────────────────────────────────────── - val balance = node.getBalance(address) - val newRvnSat = balance.confirmed + balance.unconfirmed + // ── RVN balance check (single batch TLS call for all addresses) ──── + val newRvnSat = (node.getTotalBalance(addresses) * 1e8).toLong() val lastRvnSat = prefs.getLong("poll_rvn_sat", -1L) + var incomingDetected = false if (lastRvnSat >= 0 && newRvnSat > lastRvnSat) { + incomingDetected = true val receivedRvn = (newRvnSat - lastRvnSat) / 1e8 NotificationHelper.notify( applicationContext, @@ -67,8 +76,11 @@ class WalletPollingWorker( } prefs.edit().putLong("poll_rvn_sat", newRvnSat).apply() - // ── Asset balance check ──────────────────────────────────────────── - val assets = node.getAssetBalances(address) + // ── Asset balance check (single batch TLS call for all addresses) ── + val assetTotals = node.getTotalAssetBalances(addresses) + val assets = assetTotals.map { (name, amount) -> + io.raventag.app.wallet.ElectrumAssetBalance(name, amount) + } val lastAssetsType = object : TypeToken>() {}.type val lastAssets: Map = gson.fromJson( prefs.getString("poll_assets", "{}"), lastAssetsType @@ -81,6 +93,7 @@ class WalletPollingWorker( newAssets[asset.name] = newSat val lastSat = lastAssets[asset.name] ?: -1L if (lastSat >= 0 && newSat > lastSat) { + incomingDetected = true val diff = (newSat - lastSat) / 1e8 NotificationHelper.notify( applicationContext, @@ -92,6 +105,67 @@ class WalletPollingWorker( } prefs.edit().putString("poll_assets", gson.toJson(newAssets)).apply() + // ── D-06: per-address scripthash-status diff pass (plan 30-08). Fires + // IncomingTxNotificationHelper on a positive balance delta once a + // baseline has been established. First-ever observation only records + // the baseline (avoids retroactive spam on install/restore). + try { + val currentAddr = walletManager.getCurrentAddress() + if (!currentAddr.isNullOrBlank()) { + val status: String? = try { + node.subscribeScripthashRpc(currentAddr) + } catch (_: Exception) { + null + } + val prev = prefs.getString("last_status_$currentAddr", null) + if (status != prev) { + prefs.edit().putString("last_status_$currentAddr", status).apply() + if (prev != null) { + val balance = try { node.getBalance(currentAddr) } catch (_: Exception) { null } + val confirmedSat = balance?.confirmed ?: 0L + val unconfirmedSat = balance?.unconfirmed ?: 0L + val cachedSat = prefs.getLong("poll_rvn_sat", 0L) + val deltaSat = confirmedSat + unconfirmedSat - cachedSat + if (deltaSat > 0L) { + val history = try { + node.getTransactionHistory(currentAddr, limit = 3, offset = 0) + } catch (_: Exception) { emptyList() } + val lastNotified = prefs.getString("last_notified_txid", null) + val newestNew = history.firstOrNull { it.txid != lastNotified } + if (newestNew != null) { + IncomingTxNotificationHelper.showIncoming( + context = applicationContext, + txid = newestNew.txid, + rvnAmount = deltaSat / 1e8, + confirmations = newestNew.confirmations + ) + prefs.edit() + .putString("last_notified_txid", newestNew.txid) + .putLong("poll_rvn_sat", confirmedSat + unconfirmedSat) + .apply() + } + } + } + } + } + } catch (_: java.io.IOException) { + return@withContext Result.retry() + } catch (_: Exception) { + // D-06 is a silent path; swallow. + } + + // ── Auto-sweep: if any incoming transfer was detected, consolidate funds + // from HAS_OUTGOING addresses to the current quantum-safe address. + // Addresses that only received funds (RECEIVE_ONLY) are never touched. + if (incomingDetected) { + try { + walletManager.sweepOldAddresses() + } catch (_: Exception) { + // Sweep failure is non-fatal: funds stay on the old address until + // the next polling cycle or the user opens the app. + } + } + } catch (_: java.io.IOException) { // Network error: retry with backoff so we don't silently miss a run return@withContext Result.retry() diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5a002c9..ac9753e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ RavenTag + Authenticate + Reveal recovery phrase diff --git a/android/app/src/test/java/io/raventag/app/ConfirmationPollingTest.kt b/android/app/src/test/java/io/raventag/app/ConfirmationPollingTest.kt new file mode 100644 index 0000000..1fa4601 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/ConfirmationPollingTest.kt @@ -0,0 +1,71 @@ +package io.raventag.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +fun confirmationsToDisplayString(c: Int): String = when { + c >= 6 -> "Confermato" + c >= 1 -> "$c/6 conferme" + else -> "In attesa..." +} + +fun shouldAutoDismiss(c: Int): Boolean = c >= 6 + +class ConfirmationPollingTest { + + // ── confirmationsToDisplayString ────────────────────────────────────────── + + @Test + fun `display - pending at 0`() { + assertEquals("In attesa...", confirmationsToDisplayString(0)) + } + + @Test + fun `display - pending at negative`() { + assertEquals("In attesa...", confirmationsToDisplayString(-1)) + } + + @Test + fun `display - confirming at 1`() { + assertEquals("1/6 conferme", confirmationsToDisplayString(1)) + } + + @Test + fun `display - confirming at 3`() { + assertEquals("3/6 conferme", confirmationsToDisplayString(3)) + } + + @Test + fun `display - confirming at 5`() { + assertEquals("5/6 conferme", confirmationsToDisplayString(5)) + } + + @Test + fun `display - confirmed at 6`() { + assertEquals("Confermato", confirmationsToDisplayString(6)) + } + + @Test + fun `display - confirmed at 10`() { + assertEquals("Confermato", confirmationsToDisplayString(10)) + } + + // ── shouldAutoDismiss ───────────────────────────────────────────────────── + + @Test + fun `autoDismiss - false at 3`() { + assertFalse(shouldAutoDismiss(3)) + } + + @Test + fun `autoDismiss - true at 6`() { + assertTrue(shouldAutoDismiss(6)) + } + + @Test + fun `autoDismiss - true at 7`() { + assertTrue(shouldAutoDismiss(7)) + } +} diff --git a/android/app/src/test/java/io/raventag/app/IssueErrorClassificationTest.kt b/android/app/src/test/java/io/raventag/app/IssueErrorClassificationTest.kt new file mode 100644 index 0000000..9cc573a --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/IssueErrorClassificationTest.kt @@ -0,0 +1,213 @@ +package io.raventag.app + +import org.junit.Assert.assertEquals +import org.junit.Test + +data class TestStrings( + val issueErrorInsufficientFunds: String = "ERR_INSUFFICIENT_FUNDS", + val issueErrorDuplicateName: String = "ERR_DUPLICATE_NAME", + val issueErrorNodeUnreachable: String = "ERR_NODE_UNREACHABLE", + val issueErrorTimeout: String = "ERR_TIMEOUT", + val issueErrorFeeEstimation: String = "ERR_FEE_ESTIMATION", + val issueErrorIpfsAuth: String = "ERR_IPFS_AUTH", + val issueErrorIpfsFailed: String = "ERR_IPFS_FAILED", + val issueErrorInvalidAddress: String = "ERR_INVALID_ADDRESS", + val issueErrorNoWallet: String = "ERR_NO_WALLET", + val issueFailed: String = "Issuance failed" +) + +fun classifyIssuanceError(e: Throwable, s: TestStrings): String { + val msg = e.message?.lowercase() ?: "" + return when { + msg.contains("insufficient funds") || msg.contains("fondi insufficienti") + || msg.contains("no spendable") || msg.contains("nessun rvn spendibile") + -> s.issueErrorInsufficientFunds + msg.contains("duplicate") || msg.contains("already exists") || msg.contains("gia esiste") + -> s.issueErrorDuplicateName + msg.contains("connection refused") || msg.contains("unreachable") || msg.contains("irraggiungibile") + || msg.contains("unknownhost") + -> s.issueErrorNodeUnreachable + msg.contains("timeout") + -> s.issueErrorTimeout + msg.contains("fee") && (msg.contains("estimate") || msg.contains("commissione")) + -> s.issueErrorFeeEstimation + msg.contains("pinata") && (msg.contains("jwt") || msg.contains("auth") || msg.contains("scaduto")) + -> s.issueErrorIpfsAuth + msg.contains("ipfs") || msg.contains("caricamento ipfs fallito") + -> s.issueErrorIpfsFailed + msg.contains("invalid address") || msg.contains("indirizzo non valido") + -> s.issueErrorInvalidAddress + msg.contains("wallet non disponibile") || msg.contains("no wallet") + -> s.issueErrorNoWallet + else -> "${s.issueFailed}: ${e.message ?: ""}" + } +} + +class IssueErrorClassificationTest { + + private val s = TestStrings() + + // ── Insufficient funds ──────────────────────────────────────────────────── + + @Test + fun `insufficientFunds - english trigger`() { + val result = classifyIssuanceError(RuntimeException("insufficient funds"), s) + assertEquals(s.issueErrorInsufficientFunds, result) + } + + @Test + fun `insufficientFunds - no spendable`() { + val result = classifyIssuanceError(RuntimeException("no spendable RVN"), s) + assertEquals(s.issueErrorInsufficientFunds, result) + } + + @Test + fun `insufficientFunds - italian`() { + val result = classifyIssuanceError(RuntimeException("fondi insufficienti"), s) + assertEquals(s.issueErrorInsufficientFunds, result) + } + + @Test + fun `insufficientFunds - italian no spendable`() { + val result = classifyIssuanceError(RuntimeException("nessun rvn spendibile"), s) + assertEquals(s.issueErrorInsufficientFunds, result) + } + + // ── Duplicate name ──────────────────────────────────────────────────────── + + @Test + fun `duplicateName - english`() { + val result = classifyIssuanceError(RuntimeException("duplicate asset name"), s) + assertEquals(s.issueErrorDuplicateName, result) + } + + @Test + fun `duplicateName - already exists`() { + val result = classifyIssuanceError(RuntimeException("already exists"), s) + assertEquals(s.issueErrorDuplicateName, result) + } + + @Test + fun `duplicateName - italian`() { + val result = classifyIssuanceError(RuntimeException("gia esiste"), s) + assertEquals(s.issueErrorDuplicateName, result) + } + + // ── Node unreachable ────────────────────────────────────────────────────── + + @Test + fun `nodeUnreachable - connection refused`() { + val result = classifyIssuanceError(RuntimeException("connection refused"), s) + assertEquals(s.issueErrorNodeUnreachable, result) + } + + @Test + fun `nodeUnreachable - unreachable`() { + val result = classifyIssuanceError(RuntimeException("node unreachable"), s) + assertEquals(s.issueErrorNodeUnreachable, result) + } + + @Test + fun `nodeUnreachable - unknownHost`() { + val result = classifyIssuanceError(RuntimeException("unknownhost exception"), s) + assertEquals(s.issueErrorNodeUnreachable, result) + } + + @Test + fun `nodeUnreachable - italian`() { + val result = classifyIssuanceError(RuntimeException("nodo irraggiungibile"), s) + assertEquals(s.issueErrorNodeUnreachable, result) + } + + // ── Timeout ─────────────────────────────────────────────────────────────── + + @Test + fun `timeout - socket timeout`() { + val result = classifyIssuanceError(RuntimeException("socket timeout"), s) + assertEquals(s.issueErrorTimeout, result) + } + + // ── Fee estimation ──────────────────────────────────────────────────────── + + @Test + fun `feeEstimation - english`() { + val result = classifyIssuanceError(RuntimeException("fee estimate failed"), s) + assertEquals(s.issueErrorFeeEstimation, result) + } + + @Test + fun `feeEstimation - italian`() { + val result = classifyIssuanceError(RuntimeException("fee estimate commissione fallita"), s) + assertEquals(s.issueErrorFeeEstimation, result) + } + + // ── IPFS auth ───────────────────────────────────────────────────────────── + + @Test + fun `ipfsAuth - jwt expired`() { + val result = classifyIssuanceError(RuntimeException("pinata jwt expired"), s) + assertEquals(s.issueErrorIpfsAuth, result) + } + + @Test + fun `ipfsAuth - auth scaduto`() { + val result = classifyIssuanceError(RuntimeException("pinata auth scaduto"), s) + assertEquals(s.issueErrorIpfsAuth, result) + } + + // ── IPFS failed ─────────────────────────────────────────────────────────── + + @Test + fun `ipfsFailed - generic`() { + val result = classifyIssuanceError(RuntimeException("ipfs upload error"), s) + assertEquals(s.issueErrorIpfsFailed, result) + } + + @Test + fun `ipfsFailed - italian`() { + val result = classifyIssuanceError(RuntimeException("caricamento ipfs fallito"), s) + assertEquals(s.issueErrorIpfsFailed, result) + } + + // ── Invalid address ─────────────────────────────────────────────────────── + + @Test + fun `invalidAddress - english`() { + val result = classifyIssuanceError(RuntimeException("invalid address format"), s) + assertEquals(s.issueErrorInvalidAddress, result) + } + + @Test + fun `invalidAddress - italian`() { + val result = classifyIssuanceError(RuntimeException("indirizzo non valido"), s) + assertEquals(s.issueErrorInvalidAddress, result) + } + + // ── No wallet ───────────────────────────────────────────────────────────── + + @Test + fun `noWallet - italian`() { + val result = classifyIssuanceError(RuntimeException("wallet non disponibile"), s) + assertEquals(s.issueErrorNoWallet, result) + } + + @Test + fun `noWallet - english`() { + val result = classifyIssuanceError(RuntimeException("no wallet found"), s) + assertEquals(s.issueErrorNoWallet, result) + } + + // ── Fallback ────────────────────────────────────────────────────────────── + + @Test + fun `fallback - unknown error`() { + val result = classifyIssuanceError(RuntimeException("something completely unexpected"), s) + assertEquals("${s.issueFailed}: something completely unexpected", result) + } + + @Test + fun `fallback - null message`() { + val result = classifyIssuanceError(RuntimeException(), s) + assertEquals("${s.issueFailed}: ", result) + } +} diff --git a/android/app/src/test/java/io/raventag/app/ravencoin/RpcClientSuspendTest.kt b/android/app/src/test/java/io/raventag/app/ravencoin/RpcClientSuspendTest.kt new file mode 100644 index 0000000..b6da8f8 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/ravencoin/RpcClientSuspendTest.kt @@ -0,0 +1,91 @@ +package io.raventag.app.ravencoin + +import io.raventag.app.network.executeSuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Test suite for OkHttp suspend wrapper extension function. + * Verifies that executeSuspend() properly converts blocking execute() calls + * to suspend functions with coroutine cancellation support. + */ +class RpcClientSuspendTest { + + @Test + fun `executeSuspend exists as extension function`() = runBlocking { + // Arrange: Create HTTP client + val httpClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + + // Act: Try to call a real endpoint (httpbin.org for testing) + val request = Request.Builder() + .url("https://httpbin.org/status/200") + .get() + .build() + + // This will compile only if executeSuspend() exists as extension function + // If it fails to compile, the extension function is missing + try { + val response = httpClient.newCall(request).executeSuspend() + // Assert: Extension function exists (we reached here) + assertTrue(response.isSuccessful) + } catch (e: Exception) { + // Network errors are ok for this test - we just need to verify compilation + // The important thing is that executeSuspend() exists as a function + } + } + + @Test + fun `executeSuspend handles real HTTP request`() = runBlocking { + // Arrange: Create HTTP client with short timeout + val httpClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + + // Act: Call executeSuspend on a real HTTP request + val request = Request.Builder() + .url("https://httpbin.org/get") + .get() + .build() + + val response = withContext(Dispatchers.IO) { + httpClient.newCall(request).executeSuspend() + } + + // Assert: Response should be successful + assertTrue(response.isSuccessful) + assertEquals(200, response.code) + } + + @Test + fun `executeSuspend is a suspend function`() { + // Verify compile-time that executeSuspend is a suspend function + // This test validates type checking at compile time + + // This will only compile if executeSuspend is marked as suspend + suspend fun testSuspend() { + val httpClient = OkHttpClient() + val request = Request.Builder() + .url("https://example.com") + .build() + + // This line will only compile if executeSuspend is a suspend function + httpClient.newCall(request).executeSuspend() + } + + // If we reach here without compilation error, executeSuspend is suspend + assertTrue(true) + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt b/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt index c83d17e..9aac86b 100644 --- a/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt +++ b/android/app/src/test/java/io/raventag/app/wallet/RavencoinTxBuilderTest.kt @@ -480,4 +480,73 @@ class RavencoinTxBuilderTest { byteArrayOf(0x75) return script.joinToString("") { "%02x".format(it) } } + + // ── Wave 0 extension: D-19 cycled-amount change-address assertion ─────── + + @Test + fun multiAddressSend_change_to_fresh_address() { + // Create a fresh change address by incrementing the test private key + val freshPrivKey = testPrivKey.copyOf() + freshPrivKey[freshPrivKey.size - 1] = (freshPrivKey[freshPrivKey.size - 1] + 1).toByte() + val freshPubKey = pubKeyFromPrivKey(freshPrivKey) + val freshHash160 = hash160(freshPubKey) + val freshChangeAddress = toBase58Check(0x3C.toByte(), freshHash160) + + val utxos = listOf( + Utxo( + txid = "a".repeat(64), + outputIndex = 0, + satoshis = 2_000_000L, + script = senderScript, + height = 100 + ) + ) + val result = RavencoinTxBuilder.buildAndSign( + utxos = utxos, + toAddress = senderAddress, + amountSat = 1_000_000L, + feeSat = 100_000L, + changeAddress = freshChangeAddress, + privKeyBytes = testPrivKey, + pubKeyBytes = testPubKey + ) + assertNotNull(result) + // Parse the transaction to verify change output exists + val raw = result.hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + var offset = 4 // version + val inputCount = raw[offset].toInt() and 0xff + offset += 1 + repeat(inputCount) { + offset += 32 // txid + offset += 4 // vout + val scriptLen = raw[offset].toInt() and 0xff + offset += 1 + scriptLen + offset += 4 // sequence + } + val outputCount = raw[offset].toInt() and 0xff + offset += 1 + assertTrue("tx must have 2 outputs (to + change)", outputCount >= 2) + // Verify at least one output goes to the change address + var foundChangeOutput = false + repeat(outputCount) { + val valueBytes = raw.copyOfRange(offset, offset + 8) + val value = (0 until 8).sumOf { i -> + (valueBytes[i].toLong() and 0xFF) shl (8 * i) + } + offset += 8 + val scriptLen = raw[offset].toInt() and 0xff + offset += 1 + val script = raw.copyOfRange(offset, offset + scriptLen) + offset += scriptLen + // Check if this is a P2PKH output to freshChangeAddress + if (script.size >= 25 && script[0] == 0x76.toByte() && script[1] == 0xa9.toByte()) { + val hash160InScript = script.copyOfRange(3, 23) + if (hash160InScript.contentEquals(freshHash160)) { + foundChangeOutput = true + assertTrue("change output must have non-zero value", value > 0) + } + } + } + assertTrue("change output to fresh address must exist", foundChangeOutput) + } } diff --git a/android/app/src/test/java/io/raventag/app/wallet/WalletManagerMnemonicTest.kt b/android/app/src/test/java/io/raventag/app/wallet/WalletManagerMnemonicTest.kt new file mode 100644 index 0000000..0dff159 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/wallet/WalletManagerMnemonicTest.kt @@ -0,0 +1,63 @@ +package io.raventag.app.wallet + +import org.junit.Assert.* +import org.junit.Test +import org.junit.Ignore +import io.raventag.app.wallet.BackupRequiredException +import io.raventag.app.wallet.IntegrityException +import io.raventag.app.wallet.KeystoreInvalidatedException + +// Wave 0 tests. Wave 1-3 implementations will replace the Stub objects below with real classes. +// Until then, tests MUST fail. Do not make them pass by weakening assertions. + +class WalletManagerMnemonicTest { + private val validPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + @Ignore("requires access to private validateMnemonic; plan 30-06 will expose test helper") + @Test + fun validateMnemonic_rejects_padding() { + // Stub test body calling TODO() + TODO("30-06: BIP39 validation test") + } + + @Test + fun restore_forces_backup_when_wallet_non_zero_and_not_backed_up() { + try { + WalletManager.checkRestorePreconditions(currentBalanceSat = 100_000_000L, hasBackedUp = false) + fail("expected BackupRequiredException") + } catch (_: BackupRequiredException) { + } + WalletManager.checkRestorePreconditions(currentBalanceSat = 100_000_000L, hasBackedUp = true) + WalletManager.checkRestorePreconditions(currentBalanceSat = 0L, hasBackedUp = false) + } + + @Test + fun hmac_integrity_mismatch_throws() { + val seed = byteArrayOf(1, 2, 3) + val goodTag = WalletManager.computeSeedHmacForTest(seed, keyBytes = ByteArray(32) { it.toByte() }) + WalletManager.verifySeedHmac(seed, goodTag, keyBytes = ByteArray(32) { it.toByte() }) + try { + WalletManager.verifySeedHmac(seed, byteArrayOf(9, 9, 9), keyBytes = ByteArray(32) { it.toByte() }) + fail("expected IntegrityException") + } catch (_: IntegrityException) { + } + } + + @Test + fun key_invalidated_routes_to_restore() { + try { + WalletManager.wrapKeystoreException { + throw android.security.keystore.KeyPermanentlyInvalidatedException() + } + fail("expected KeystoreInvalidatedException") + } catch (e: KeystoreInvalidatedException) { + assertTrue(e.cause is android.security.keystore.KeyPermanentlyInvalidatedException) + } + try { + WalletManager.wrapKeystoreException { throw java.io.IOException("transient") } + fail("expected passthrough IOException") + } catch (e: java.io.IOException) { + assertEquals("transient", e.message) + } + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/cache/ReservedUtxoDaoTest.kt b/android/app/src/test/java/io/raventag/app/wallet/cache/ReservedUtxoDaoTest.kt new file mode 100644 index 0000000..cf1c7df --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/wallet/cache/ReservedUtxoDaoTest.kt @@ -0,0 +1,70 @@ +package io.raventag.app.wallet.cache + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Ignore +import org.junit.Test + +// Wave 0 tests. Pure-function tests run without Android context. +// Context-dependent tests require Robolectric or instrumented test runner. + +class ReservedUtxoDaoTest { + + @Ignore("requires Android Context (SQLite) - enable with Robolectric or instrumented test") + @Test + fun insert_on_broadcast_records_all_inputs() { + val now = System.currentTimeMillis() + val entries = listOf( + ReservedUtxoDao.ReservedUtxo("txA", 0, 100L, "subX", now), + ReservedUtxoDao.ReservedUtxo("txA", 1, 200L, "subX", now) + ) + ReservedUtxoDao.reserve(entries) + val all = ReservedUtxoDao.all() + assertEquals(2, all.size) + assertTrue(all.all { it.submittedTxid == "subX" }) + } + + @Ignore("requires Android Context (SQLite) - enable with Robolectric or instrumented test") + @Test + fun cleanup_on_confirm_removes_rows_for_submitted_txid() { + val now = System.currentTimeMillis() + ReservedUtxoDao.reserve(listOf( + ReservedUtxoDao.ReservedUtxo("txY1", 0, 100L, "subY", now), + ReservedUtxoDao.ReservedUtxo("txY2", 0, 200L, "subY", now), + ReservedUtxoDao.ReservedUtxo("txY3", 0, 300L, "subY", now), + ReservedUtxoDao.ReservedUtxo("txZ1", 0, 400L, "subZ", now) + )) + ReservedUtxoDao.releaseFor("subY") + val remaining = ReservedUtxoDao.all() + assertEquals(1, remaining.size) + assertEquals("subZ", remaining[0].submittedTxid) + } + + @Ignore("requires Android Context (SQLite) - enable with Robolectric or instrumented test") + @Test + fun prune_stale_removes_rows_older_than_48h() { + val now = System.currentTimeMillis() + val fortyEightHours = 48L * 3600 * 1000 + ReservedUtxoDao.reserve(listOf( + ReservedUtxoDao.ReservedUtxo("old", 0, 100L, "subOld", now - fortyEightHours - 3600 * 1000), + ReservedUtxoDao.ReservedUtxo("new", 0, 200L, "subNew", now - 3600 * 1000) + )) + ReservedUtxoDao.pruneOlderThan(now - fortyEightHours) + val remaining = ReservedUtxoDao.all() + assertEquals(1, remaining.size) + assertTrue(remaining[0].submittedAt > now - 2 * 3600 * 1000) + } + + @Ignore("requires Android Context (SQLite) - enable with Robolectric or instrumented test") + @Test + fun sum_reserved_returns_total_value() { + val now = System.currentTimeMillis() + ReservedUtxoDao.reserve(listOf( + ReservedUtxoDao.ReservedUtxo("a", 0, 100L, "sub", now), + ReservedUtxoDao.ReservedUtxo("b", 0, 250L, "sub", now), + ReservedUtxoDao.ReservedUtxo("c", 0, 999L, "sub", now) + )) + val sum = ReservedUtxoDao.sumReservedSat() + assertEquals(1349L, sum) + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/cache/WalletCacheDaoTest.kt b/android/app/src/test/java/io/raventag/app/wallet/cache/WalletCacheDaoTest.kt new file mode 100644 index 0000000..8b2020e --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/wallet/cache/WalletCacheDaoTest.kt @@ -0,0 +1,40 @@ +package io.raventag.app.wallet.cache + +import io.raventag.app.wallet.Utxo +import org.junit.Assert.assertEquals +import org.junit.Ignore +import org.junit.Test + +// Wave 0 tests. Wave 1-3 implementations will replace the Stub objects below with real classes. +// Until then, tests MUST fail. Do not make them pass by weakening assertions. + +class WalletCacheDaoTest { + + @Test + fun balance_subtracts_reserved_never_negative() { + val utxos = listOf(Utxo(txid = "a", outputIndex = 0, satoshis = 300_000_000L, script = "", height = 100)) + val reserved = 500_000_000L + // WalletCacheDao.computeSpendableBalanceSat signature: (utxos, reservedSat) -> Long + val spendable = WalletCacheDao.computeSpendableBalanceSat(utxos, reserved) + assertEquals(0L, spendable) + } + + @Test + fun balance_subtracts_reserved_positive() { + val utxos = listOf( + Utxo(txid = "a", outputIndex = 0, satoshis = 400_000_000L, script = "", height = 100), + Utxo(txid = "b", outputIndex = 0, satoshis = 300_000_000L, script = "", height = 100), + Utxo(txid = "c", outputIndex = 0, satoshis = 300_000_000L, script = "", height = 100) + ) + val reserved = 250_000_000L + val spendable = WalletCacheDao.computeSpendableBalanceSat(utxos, reserved) + assertEquals(750_000_000L, spendable) + } + + @Ignore("requires Android Context - implemented by plan 30-02") + @Test + fun roundtrip_preserves_utxos_and_timestamp() { + // Stub test body calling TODO() + TODO("30-02: SQLite roundtrip test") + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/fee/FeeEstimatorTest.kt b/android/app/src/test/java/io/raventag/app/wallet/fee/FeeEstimatorTest.kt new file mode 100644 index 0000000..6313fb1 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/wallet/fee/FeeEstimatorTest.kt @@ -0,0 +1,73 @@ +package io.raventag.app.wallet.fee + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException +import kotlinx.coroutines.runBlocking + +// Wave 0 tests. Wave 1-3 implementations will replace the Stub objects below with real classes. +// Until then, tests MUST fail. Do not make them pass by weakening assertions. + +class FeeEstimatorTest { + + /** + * Helper to create a FeeEstimator that uses a lambda for fee estimation + * instead of making real RPC calls. The lambda receives targetBlocks and + * returns the RVN/kB rate as a Double. + * + * Wave 1 plan 30-04 MUST provide a constructor or factory that accepts + * this lambda pattern. + */ + private fun createEstimator(estimateFn: suspend (Int) -> Double): FeeEstimator { + // The real FeeEstimator(RavencoinPublicNode) constructor exists in Wave 1. + // For Wave 0, we call the lambda-injectable constructor stub. + // This stub must exist for the test to compile. + return FeeEstimator(null, estimateFn) + } + + @Test + fun fallback_when_estimate_returns_negative_one() { + val estimator = createEstimator { -1.0 } + runBlocking { + assertEquals(1_000_000L, estimator.estimateSatPerKb(6)) + } + } + + @Test + fun fallback_when_estimate_returns_zero() { + val estimator = createEstimator { 0.0 } + runBlocking { + assertEquals(1_000_000L, estimator.estimateSatPerKb(6)) + } + } + + @Test + fun fallback_when_estimate_throws_IOException() { + val estimator = createEstimator { throw IOException("timeout") } + runBlocking { + assertEquals(1_000_000L, estimator.estimateSatPerKb(6)) + } + } + + @Test + fun converts_rvn_per_kb_to_sat_per_kb() { + // 0.002 RVN/kB = 200_000 sat/kB + val estimator = createEstimator { 0.002 } + runBlocking { + assertEquals(200_000L, estimator.estimateSatPerKb(6)) + } + } + + @Test + fun passes_target_blocks_to_lambda() { + var capturedTarget = 0 + val estimator = createEstimator { target -> + capturedTarget = target + 0.001 + } + runBlocking { + estimator.estimateSatPerKb(12) + } + assertEquals(12, capturedTarget) + } +} diff --git a/android/app/src/test/java/io/raventag/app/wallet/subscription/SubscriptionParserTest.kt b/android/app/src/test/java/io/raventag/app/wallet/subscription/SubscriptionParserTest.kt new file mode 100644 index 0000000..8fdda16 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/wallet/subscription/SubscriptionParserTest.kt @@ -0,0 +1,70 @@ +package io.raventag.app.wallet.subscription + +import com.google.gson.JsonPrimitive +import com.google.gson.JsonNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +// Wave 0 tests. Wave 1-3 implementations will replace the Stub objects below with real classes. +// Until then, tests MUST fail. Do not make them pass by weakening assertions. + +class SubscriptionParserTest { + + @Test + fun parses_response_with_id_as_Response() { + val input = """{"id":42,"result":"abc","jsonrpc":"2.0"}""" + val parsed = SubscriptionParser.parseLine(input) + assertTrue(parsed is SubscriptionParser.Parsed.Response) + val resp = parsed as SubscriptionParser.Parsed.Response + assertEquals(42, resp.id) + assertEquals(JsonPrimitive("abc"), resp.result) + } + + @Test + fun parses_scripthash_notification_as_Notification() { + val input = """{"jsonrpc":"2.0","method":"blockchain.scripthash.subscribe","params":["a1b2","statusHash"]}""" + val parsed = SubscriptionParser.parseLine(input) + assertTrue(parsed is SubscriptionParser.Parsed.Notification) + val notif = parsed as SubscriptionParser.Parsed.Notification + assertEquals("a1b2", notif.scripthash) + assertEquals("statusHash", notif.status) + } + + @Test + fun parses_scripthash_notification_with_null_status() { + val input = """{"jsonrpc":"2.0","method":"blockchain.scripthash.subscribe","params":["a1b2",null]}""" + val parsed = SubscriptionParser.parseLine(input) + assertTrue(parsed is SubscriptionParser.Parsed.Notification) + val notif = parsed as SubscriptionParser.Parsed.Notification + assertEquals("a1b2", notif.scripthash) + assertEquals(null, notif.status) + } + + @Test + fun parses_response_with_null_result() { + val input = """{"id":3,"result":null}""" + val parsed = SubscriptionParser.parseLine(input) + assertTrue(parsed is SubscriptionParser.Parsed.Response) + val resp = parsed as SubscriptionParser.Parsed.Response + assertEquals(3, resp.id) + // result MAY be JsonNull or null; accept either + val resultIsNull = resp.result == null || resp.result == JsonNull.INSTANCE + assertTrue("result must be null or JsonNull", resultIsNull) + } + + @Test + fun unknown_method_falls_through_to_Unknown() { + val input = """{"jsonrpc":"2.0","method":"server.ping"}""" + val parsed = SubscriptionParser.parseLine(input) + assertTrue(parsed is SubscriptionParser.Parsed.Unknown) + } + + @Test + fun malformed_json_throws_or_returns_Unknown() { + val input = "not json" + val result = runCatching { SubscriptionParser.parseLine(input) } + val valid = result.isFailure || (result.getOrNull() is SubscriptionParser.Parsed.Unknown) + assertTrue("must throw IllegalArgumentException or return Unknown", valid) + } +} diff --git a/android/app/src/test/java/io/raventag/app/worker/RebroadcastWorkerTest.kt b/android/app/src/test/java/io/raventag/app/worker/RebroadcastWorkerTest.kt new file mode 100644 index 0000000..ff5cd44 --- /dev/null +++ b/android/app/src/test/java/io/raventag/app/worker/RebroadcastWorkerTest.kt @@ -0,0 +1,34 @@ +package io.raventag.app.worker + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +// Wave 0 tests for RebroadcastWorker constants and delay ladder (D-25). +// Context-dependent worker scheduling tests require Robolectric or instrumented runner. + +class RebroadcastWorkerTest { + + @Test + fun delay_ladder_has_five_rungs_matching_d25_spec() { + assertEquals(5, RebroadcastWorker.DELAY_LADDER_MINUTES.size) + assertEquals(30L, RebroadcastWorker.DELAY_LADDER_MINUTES[0]) + assertEquals(60L, RebroadcastWorker.DELAY_LADDER_MINUTES[1]) + assertEquals(120L, RebroadcastWorker.DELAY_LADDER_MINUTES[2]) + assertEquals(240L, RebroadcastWorker.DELAY_LADDER_MINUTES[3]) + assertEquals(480L, RebroadcastWorker.DELAY_LADDER_MINUTES[4]) + } + + @Test + fun max_attempts_is_five() { + assertEquals(5, RebroadcastWorker.MAX_ATTEMPTS) + } + + @Test + fun delay_ladder_values_are_strictly_ascending() { + val ladder = RebroadcastWorker.DELAY_LADDER_MINUTES + for (i in 1 until ladder.size) { + assertTrue("Rung $i should be > rung ${i - 1}", ladder[i] > ladder[i - 1]) + } + } +} diff --git a/backend/package.json b/backend/package.json index 1a4fd76..06d9d61 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "lint": "eslint src --ext .ts" + "lint": "eslint src --ext .ts", + "db:explore": "tsx src/db-explore.ts" }, "dependencies": { "@types/multer": "^2.1.0", diff --git a/backend/src/__tests__/README.md b/backend/src/__tests__/README.md new file mode 100644 index 0000000..d3e4c54 --- /dev/null +++ b/backend/src/__tests__/README.md @@ -0,0 +1,51 @@ +# Logging Verification Tests + +This directory contains verification tests to ensure sensitive data is not logged. + +## Purpose + +The RavenTag backend must never log sensitive data (e.g., tag_uid, chip keys, admin keys) to prevent exfiltration via log aggregation services (DataDog, CloudWatch, etc.). + +## Verification Scripts + +### verify-no-body-logging.sh + +Verifies that the request logger (`src/middleware/logger.ts`) does NOT log request or response bodies. + +**Usage:** +```bash +./src/__tests__/verify-no-body-logging.sh +``` + +**What it checks:** +1. SECURITY comment exists in logger.ts +2. No `req.body` logging in code +3. No `res.body` logging in code +4. Metadata-only logging is documented (method, path, status, duration, ip) + +**Expected output:** +``` +=== Backend Logging Verification === + +PASS: SECURITY comment found in logger.ts +PASS: No req.body logging in logger.ts +PASS: No res.body logging in logger.ts +PASS: Metadata logging documented (method, path, status, duration, ip) + +=== All checks passed === +The request logger only logs metadata, never request bodies. +``` + +## Logging Policy + +The backend request logger (`requestLogger` middleware) follows a strict security policy: + +- **NEVER logs:** Request bodies, response bodies, sensitive parameters +- **ALWAYS logs:** HTTP method, request path, status code, duration, IP address + +This ensures that sensitive endpoints like `/api/brand/derive-chip-key` (which receives `tag_uid`) are safe from log exfiltration. + +## Related Files + +- `src/middleware/logger.ts` - Request logger implementation +- `android/app/src/main/java/io/raventag/app/wallet/AssetManager.kt` - Android client (also removes sensitive logging) diff --git a/backend/src/__tests__/logging-verification.ts b/backend/src/__tests__/logging-verification.ts new file mode 100644 index 0000000..5872ac5 --- /dev/null +++ b/backend/src/__tests__/logging-verification.ts @@ -0,0 +1,87 @@ +/** + * Logging Verification Test + * + * Manual verification that the request logger does NOT log sensitive data (e.g., tag_uid). + * + * This script: + * 1. Starts a test Express server with the requestLogger middleware + * 2. Sends a POST request with sensitive payload (tag_uid) + * 3. Captures console.log output + * 4. Verifies that tag_uid is NOT in the logs + * + * Usage: npx tsx src/__tests__/logging-verification.ts + */ + +import express from 'express' +import { requestLogger } from '../middleware/logger.js' + +const app = express() +app.use(express.json()) +app.use(requestLogger) + +app.post('/api/brand/derive-chip-key', (req, res) => { + // Simulate backend response + res.json({ success: true }) +}) + +// Capture console.log output +const originalLog = console.log +let loggedOutput = '' +console.log = (...args) => { + loggedOutput += args.join(' ') + '\n' +} + +async function runVerification() { + const PORT = 3002 + const server = app.listen(PORT) + + try { + // Wait for server to start + await new Promise(resolve => setTimeout(resolve, 100)) + + // Send request with sensitive payload + const response = await fetch(`http://localhost:${PORT}/api/brand/derive-chip-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tag_uid: 'DEADBEEF123456' }) + }) + + const responseText = await response.text() + console.log = originalLog + + // Verification checks + console.log('=== Logging Verification Results ===\n') + + if (loggedOutput.includes('tag_uid')) { + console.log('FAIL: tag_uid found in logs!') + console.log('Logged output:', loggedOutput) + process.exit(1) + } + + if (loggedOutput.includes('DEADBEEF123456')) { + console.log('FAIL: Sensitive payload value found in logs!') + console.log('Logged output:', loggedOutput) + process.exit(1) + } + + if (!loggedOutput.includes('POST /api/brand/derive-chip-key')) { + console.log('FAIL: Request metadata not logged!') + console.log('Logged output:', loggedOutput) + process.exit(1) + } + + console.log('PASS: tag_uid is NOT in logs') + console.log('PASS: Sensitive payload value is NOT in logs') + console.log('PASS: Request metadata (method, path) is logged') + console.log('\n=== All checks passed ===') + console.log('Sample log output:', loggedOutput.trim()) + } catch (error) { + console.log = originalLog + console.error('Test error:', error) + process.exit(1) + } finally { + server.close() + } +} + +runVerification() diff --git a/backend/src/__tests__/verify-no-body-logging.sh b/backend/src/__tests__/verify-no-body-logging.sh new file mode 100755 index 0000000..d9ddc95 --- /dev/null +++ b/backend/src/__tests__/verify-no-body-logging.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Logging Verification Script +# Verifies that request logger does NOT log request bodies + +echo "=== Backend Logging Verification ===" +echo "" + +# Check 1: Verify SECURITY comment exists +if ! grep -q "SECURITY: Request logger NEVER logs request bodies" src/middleware/logger.ts; then + echo "FAIL: SECURITY comment not found in logger.ts" + exit 1 +fi +echo "PASS: SECURITY comment found in logger.ts" + +# Check 2: Verify no req.body logging +if grep -q "req\.body" src/middleware/logger.ts; then + echo "FAIL: req.body found in logger.ts" + echo "Lines:" + grep -n "req\.body" src/middleware/logger.ts + exit 1 +fi +echo "PASS: No req.body logging in logger.ts" + +# Check 3: Verify no res.body logging +if grep -q "res\.body" src/middleware/logger.ts; then + echo "FAIL: res.body found in logger.ts" + echo "Lines:" + grep -n "res\.body" src/middleware/logger.ts + exit 1 +fi +echo "PASS: No res.body logging in logger.ts" + +# Check 4: Verify only metadata is logged +if ! grep -q "method, path, status, duration, ip" src/middleware/logger.ts; then + echo "FAIL: Metadata logging not documented" + exit 1 +fi +echo "PASS: Metadata logging documented (method, path, status, duration, ip)" + +echo "" +echo "=== All checks passed ===" +echo "The request logger only logs metadata, never request bodies." diff --git a/backend/src/db-explore.ts b/backend/src/db-explore.ts new file mode 100644 index 0000000..ebecee3 --- /dev/null +++ b/backend/src/db-explore.ts @@ -0,0 +1,97 @@ +/** + * Database Explorer (db-explore.ts) + * + * Read-only REPL for exploring the RavenTag SQLite database. + * Launched via `npm run db:explore`. + * + * SECURITY: Opens the database in read-only mode. No write operations + * are exposed. The database is permanent and must never be altered + * by tooling (C-01). + */ +import Database from 'better-sqlite3' +import * as readline from 'readline' +import * as path from 'path' + +const DB_PATH = process.env.DB_PATH ?? path.join(process.cwd(), 'raventag.db') + +console.log(`Opening database (read-only): ${DB_PATH}`) +const db = new Database(DB_PATH, { readonly: true }) + +const commands: Record void> = { + '.assets': () => { + const rows = db.prepare( + 'SELECT asset_name, tag_uid, nfc_pub_id, datetime(registered_at, \'unixepoch\') as registered FROM chip_registry ORDER BY registered_at DESC' + ).all() + if (rows.length === 0) { console.log('No registered chips.'); return } + console.table(rows) + }, + '.brands': () => { + const rows = db.prepare( + 'SELECT brand_name, registered_at, protocol_version FROM brand_registry ORDER BY registered_at DESC' + ).all() + if (rows.length === 0) { console.log('No registered brands.'); return } + console.table(rows) + }, + '.revoked': () => { + const rows = db.prepare( + 'SELECT asset_name, reason, datetime(revoked_at, \'unixepoch\') as revoked FROM revoked_assets ORDER BY revoked_at DESC' + ).all() + if (rows.length === 0) { console.log('No revoked assets.'); return } + console.table(rows) + }, + '.stats': () => { + const tables = [ + 'cache', 'chip_registry', 'revoked_assets', 'nfc_counters', + 'request_logs', 'rate_limit_events', 'brand_registry', 'asset_emissions' + ] + console.log('Table row counts:') + for (const t of tables) { + try { + const row = db.prepare(`SELECT COUNT(*) as n FROM ${t}`).get() as { n: number } + console.log(` ${t}: ${row.n}`) + } catch { + console.log(` ${t}: (table not found)`) + } + } + }, + '.help': () => { + console.log('') + console.log('Available commands:') + console.log(' .assets List registered chips (chip_registry)') + console.log(' .brands List registered brands (brand_registry)') + console.log(' .revoked List revoked assets (revoked_assets)') + console.log(' .stats Show row counts for all tables') + console.log(' .help Show this help') + console.log(' .exit Close database and exit') + console.log('') + } +} + +console.log('RavenTag Database Explorer (read-only)') +console.log('Type .help for available commands.') + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) +rl.setPrompt('db> ') +rl.prompt() + +rl.on('line', (line: string) => { + const cmd = line.trim() + if (cmd === '.exit' || cmd === '.quit') { + rl.close() + return + } + if (commands[cmd]) { + commands[cmd]() + } else if (cmd) { + console.log(`Unknown command: ${cmd}`) + console.log('Type .help for available commands.') + } + rl.prompt() +}).on('close', () => { + db.close() + console.log('Database closed.') + process.exit(0) +}) diff --git a/backend/src/index.ts b/backend/src/index.ts index dabcb25..ae4f565 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,7 +22,9 @@ import verifyRouter from './routes/verify.js' import adminRouter from './routes/admin.js' import brandRouter from './routes/brand.js' import registryRouter from './routes/registry.js' -import { requestLogger, logRateLimitEvent, getRequestStats } from './middleware/logger.js' +import { getDb } from './middleware/cache.js' +import { requestLogger, logRateLimitEvent, getRequestStats, startLogCleanup } from './middleware/logger.js' +import { startBackupScheduler } from './services/backup.js' import { requireAdminKey } from './middleware/auth.js' // ── CRIT-2: Docker secrets support (_FILE convention) ──────────────────────── @@ -227,9 +229,43 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres res.status(500).json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }) }) -app.listen(PORT, () => { +// ── Process-level error handlers ────────────────────────────────────────────── +// Express error middleware only catches sync errors in route handlers. +// Unhandled promise rejections and uncaught exceptions would crash the process +// without graceful cleanup. These handlers log the error and shut down cleanly +// so Docker can restart the container with a fresh state. + +process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { + const message = reason instanceof Error ? reason.message : String(reason) + const stack = reason instanceof Error ? reason.stack : undefined + console.error('[FATAL] Unhandled Rejection:', message) + if (stack) console.error(stack) + // Attempt graceful shutdown: close HTTP server first, then SQLite + try { + server.close(() => { + try { getDb().close() } catch { /* DB may not be open */ } + process.exit(1) + }) + // Force exit after 5s if graceful shutdown hangs + setTimeout(() => process.exit(1), 5000) + } catch { + process.exit(1) + } +}) + +process.on('uncaughtException', (err: Error) => { + console.error('[FATAL] Uncaught Exception:', err.message) + console.error(err.stack) + // Uncaught exceptions leave the process in an undefined state. + // Exit immediately — do not attempt graceful shutdown. + process.exit(1) +}) + +const server = app.listen(PORT, () => { console.log(`RavenTag API running on http://localhost:${PORT}`) console.log(`Protocol: RTP-1 | Env: ${process.env.NODE_ENV ?? 'development'}`) + startLogCleanup() + startBackupScheduler() }) export default app diff --git a/backend/src/middleware/cache.ts b/backend/src/middleware/cache.ts index 6b3eb37..0ab72ea 100644 --- a/backend/src/middleware/cache.ts +++ b/backend/src/middleware/cache.ts @@ -126,7 +126,16 @@ export function listRevokedAssets(): Array<{ asset_name: string; reason: string | null; burned_on_chain: number; burn_txid: string | null; revoked_at: number }> { const database = getDb() - return database.prepare('SELECT * FROM revoked_assets ORDER BY revoked_at DESC').all() as Array<{ + return database.prepare(` + SELECT + asset_name, + reason, + burned_on_chain, + burn_txid, + revoked_at + FROM revoked_assets + ORDER BY revoked_at DESC + `).all() as Array<{ asset_name: string; reason: string | null; burned_on_chain: number; burn_txid: string | null; revoked_at: number }> } @@ -246,7 +255,15 @@ export function deleteChip(assetName: string): boolean { */ export function listChips(): Array<{ asset_name: string; tag_uid: string; nfc_pub_id: string; registered_at: number }> { const database = getDb() - return database.prepare('SELECT * FROM chip_registry ORDER BY registered_at DESC').all() as Array<{ + return database.prepare(` + SELECT + asset_name, + tag_uid, + nfc_pub_id, + registered_at + FROM chip_registry + ORDER BY registered_at DESC + `).all() as Array<{ asset_name: string; tag_uid: string; nfc_pub_id: string; registered_at: number }> } diff --git a/backend/src/middleware/logger.ts b/backend/src/middleware/logger.ts index 562b8d3..b8e4889 100644 --- a/backend/src/middleware/logger.ts +++ b/backend/src/middleware/logger.ts @@ -2,8 +2,9 @@ * HTTP request logger middleware (logger.ts) * * Provides three exports: - * - requestLogger: Express middleware that logs each request to console (with ANSI - * color coding by status code) and persists it to the SQLite request_logs table. + * - requestLogger (metadata-only logging): Express middleware that logs each request to + * console (with ANSI color coding by status code) and persists it to the SQLite + * request_logs table. * - logRateLimitEvent: Persists a rate-limit hit to the rate_limit_events table. * - getRequestStats: Aggregates request metrics for the last N hours (used by * the /api/metrics endpoint). @@ -12,6 +13,13 @@ * skipped so the logger never causes a request to fail. * * The /health and /favicon.ico paths are intentionally excluded to avoid log spam. + * + * SECURITY: Request logger NEVER logs request bodies or response bodies. + * Only metadata is logged: method, path, status code, duration, IP address. + * This prevents sensitive data (e.g., tag_uid, chip keys, admin keys) from being + * persisted in log aggregation services (DataDog, CloudWatch, etc.) or log files. + * Endpoints with sensitive payloads (e.g., /api/brand/derive-chip-key) are safe because + * the logger only logs method/path/status, never the request body. */ import { Request, Response, NextFunction } from 'express' import { getDb } from './cache.js' @@ -26,7 +34,7 @@ const SKIP_PATHS = new Set(['/health', '/favicon.ico']) * so that the final status code is available. This avoids logging before the * response is complete. * - * Console format: [ISO-timestamp] METHOD /path STATUS duration_ms IP + * Console format: [ISO-timestamp] METHOD /path STATUS duration_ms IP (never request body) * Colors: green for 2xx, yellow for 4xx, red for 5xx. */ export function requestLogger(req: Request, res: Response, next: NextFunction): void { @@ -122,3 +130,36 @@ export function getRequestStats(hours = 24): object { top_paths: topPaths } } + +/** + * Start periodic cleanup of request_logs and rate_limit_events tables. + * Deletes rows older than RETENTION_DAYS. Runs once at startup and then + * every CLEANUP_INTERVAL_MS. + * + * SECURITY: nfc_counters is the NTAG 424 DNA anti-replay mechanism (HIGH-3). + * It MUST NEVER be cleaned up — deleting counters would allow tag replay attacks. + * This function intentionally excludes the nfc_counters table. + */ +export function startLogCleanup(): NodeJS.Timeout { + const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours + const RETENTION_SECONDS = 30 * 24 * 60 * 60 // 30 days + + const cleanup = () => { + try { + const db = getDb() + const threshold = Math.floor(Date.now() / 1000) - RETENTION_SECONDS + const r1 = db.prepare('DELETE FROM request_logs WHERE created_at < ?').run(threshold) + const r2 = db.prepare('DELETE FROM rate_limit_events WHERE created_at < ?').run(threshold) + if (r1.changes > 0 || r2.changes > 0) { + console.log(`[Cleanup] Removed ${r1.changes} request_logs rows, ${r2.changes} rate_limit_events rows (older than 30 days)`) + } + } catch (err) { + console.error('[Cleanup] Failed:', err) + } + } + + // Run once at startup to catch accumulated logs since last restart + cleanup() + // Then periodically + return setInterval(cleanup, CLEANUP_INTERVAL_MS) +} diff --git a/backend/src/middleware/migrations.ts b/backend/src/middleware/migrations.ts index 5ea4d9c..40f0f63 100644 --- a/backend/src/middleware/migrations.ts +++ b/backend/src/middleware/migrations.ts @@ -130,10 +130,9 @@ const MIGRATIONS: Migration[] = [ }, { id: 6, - name: 'log_retention_cleanup', - // Delete log entries older than 30 days to prevent unbounded growth. - // This migration runs once at schema upgrade time; ongoing cleanup would - // require a periodic job or SQLite triggers. + name: 'log_retention_cleanup_one_shot', + // One-shot cleanup at migration time. Periodic cleanup is handled by + // startLogCleanup() in logger.ts (runs every 24h, retains 30 days). sql: ` DELETE FROM request_logs WHERE created_at < unixepoch() - 30 * 86400; DELETE FROM rate_limit_events WHERE created_at < unixepoch() - 30 * 86400; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 4307de4..1cfe696 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -75,7 +75,16 @@ router.post('/register-tag', (req: Request, res: Response) => { */ router.get('/tags', (req: Request, res: Response) => { const db = getDb() - const tags = db.prepare('SELECT * FROM registered_tags ORDER BY created_at DESC').all() + const tags = db.prepare(` + SELECT + nfc_pub_id, + asset_name, + brand_info, + metadata_ipfs, + created_at + FROM registered_tags + ORDER BY created_at DESC + `).all() res.json({ tags, count: tags.length }) }) diff --git a/backend/src/routes/assets.ts b/backend/src/routes/assets.ts index 92da5d9..3f9297c 100644 --- a/backend/src/routes/assets.ts +++ b/backend/src/routes/assets.ts @@ -204,9 +204,18 @@ router.get('/:assetName/hierarchy', async (req: Request, res: Response) => { return } + const limit = Math.min(Math.max(Number(req.query['limit']) || 200, 1), 1000) + const offset = Math.max(Number(req.query['offset']) || 0, 0) + try { - const hierarchy = await ravencoinService.getAssetHierarchy(assetName) - res.json(hierarchy) + const hierarchy = await ravencoinService.getAssetHierarchy(assetName, limit, offset) + res.json({ + ...hierarchy, + total: hierarchy.subAssets.length, + limit, + offset, + hasMore: hierarchy.subAssets.length === limit + }) } catch (err: unknown) { console.error('[assets/:name/hierarchy]', err) res.status(502).json({ error: 'Service temporarily unavailable', code: 'NODE_ERROR' }) diff --git a/backend/src/services/backup.ts b/backend/src/services/backup.ts new file mode 100644 index 0000000..f07ec86 --- /dev/null +++ b/backend/src/services/backup.ts @@ -0,0 +1,63 @@ +/** + * SQLite backup service (backup.ts) + * + * Creates consistent database snapshots using better-sqlite3's .backup() API, + * which is safe under WAL mode concurrent writes. Encrypts output with openssl + * (preserving the existing encryption pattern from docker-compose.yml). + * + * Retention: keeps last 3 backups (18-hour rotating window at 6h intervals). + */ +import { execSync } from 'child_process' +import { unlinkSync, readdirSync } from 'fs' +import { getDb } from '../middleware/cache.js' + +const BACKUP_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours +const MAX_BACKUPS = 3 +const BACKUP_DIR = process.env.BACKUP_DIR ?? '/backups' + +export function startBackupScheduler(adminKeyPath = '/run/secrets/admin_key'): NodeJS.Timeout { + const runBackup = () => { + try { + const now = new Date() + const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}` + const tmpFile = `${BACKUP_DIR}/raventag_${timestamp}.db.tmp` + const encFile = `${BACKUP_DIR}/raventag_${timestamp}.db.enc` + + // Step 1: Use better-sqlite3 .backup() for a consistent WAL snapshot + const source = getDb() + source.backup(tmpFile).then(() => { + try { + // Step 2: Encrypt with openssl (same pattern as docker-compose backup) + execSync( + `openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass file:${adminKeyPath} -in ${tmpFile} -out ${encFile}`, + { timeout: 60000 } + ) + + // Step 3: Remove unencrypted temp file + unlinkSync(tmpFile) + + // Step 4: Prune old backups (keep last MAX_BACKUPS) + const files = readdirSync(BACKUP_DIR) + .filter(f => f.startsWith('raventag_') && f.endsWith('.db.enc')) + .sort() + while (files.length > MAX_BACKUPS) { + const oldFile = files.shift()! + unlinkSync(`${BACKUP_DIR}/${oldFile}`) + } + + console.log(`[Backup] Created: ${encFile}`) + } catch (err) { + console.error('[Backup] Encrypt/prune failed:', err) + } + }).catch((err: unknown) => { + console.error('[Backup] .backup() failed:', err) + }) + } catch (err) { + console.error('[Backup] Failed:', err) + } + } + + // First backup 30s after startup (let DB init complete) + setTimeout(runBackup, 30000) + return setInterval(runBackup, BACKUP_INTERVAL_MS) +} diff --git a/backend/src/services/ravencoin.ts b/backend/src/services/ravencoin.ts index 9318233..ccbf5e2 100644 --- a/backend/src/services/ravencoin.ts +++ b/backend/src/services/ravencoin.ts @@ -181,11 +181,11 @@ class RavencoinService { * List sub-assets and unique tokens of a parent asset. * Includes both PARENT/CHILD (sub-assets) and PARENT/CHILD#TAG (unique tokens). */ - async listSubAssets(parentAsset: string): Promise { + async listSubAssets(parentAsset: string, limit = 200, offset = 0): Promise { try { const [subs, uniques] = await Promise.all([ - this.call('listassets', [`${parentAsset}/*`, false, 200, 0]).catch(() => [] as string[]), - this.call('listassets', [`${parentAsset}/#*`, false, 200, 0]).catch(() => [] as string[]) + this.call('listassets', [`${parentAsset}/*`, false, limit, offset]).catch(() => [] as string[]), + this.call('listassets', [`${parentAsset}/#*`, false, limit, offset]).catch(() => [] as string[]) ]) return [...(subs ?? []), ...(uniques ?? [])] } catch { @@ -217,18 +217,41 @@ class RavencoinService { /** * Get full asset hierarchy (parent + subs + variants). */ - async getAssetHierarchy(parentAsset: string): Promise { - const subAssets = await this.listSubAssets(parentAsset) + async getAssetHierarchy(parentAsset: string, limit = 200, offset = 0): Promise { + const subAssets = await this.listSubAssets(parentAsset, limit, offset) const variants: Record = {} + const errors: Array<{ assetName: string; error: string }> = [] - for (const sub of subAssets) { - const subVariants = await this.listSubAssets(sub) - if (subVariants.length > 0) { - variants[sub] = subVariants - } + const CONCURRENCY = 5 + for (let i = 0; i < subAssets.length; i += CONCURRENCY) { + const chunk = subAssets.slice(i, i + CONCURRENCY) + const results = await Promise.allSettled( + chunk.map(sub => this.listSubAssets(sub)) + ) + results.forEach((result, idx) => { + if (result.status === 'fulfilled') { + if (result.value.length > 0) { + variants[chunk[idx]] = result.value + } + } else { + errors.push({ + assetName: chunk[idx], + error: result.reason instanceof Error ? result.reason.message : String(result.reason) + }) + } + }) } - return { parent: parentAsset, subAssets, variants } + const hierarchy: AssetHierarchy & { partial?: boolean; errors?: Array<{ assetName: string; error: string }> } = { + parent: parentAsset, + subAssets, + variants + } + if (errors.length > 0) { + hierarchy.partial = true + hierarchy.errors = errors + } + return hierarchy } } diff --git a/docker-compose.yml b/docker-compose.yml index 9c216f4..5bbec57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,16 +54,18 @@ services: secrets: - admin_key command: > - sh -c "apk add --no-cache openssl > /dev/null 2>&1; + sh -c "apk add --no-cache openssl sqlite > /dev/null 2>&1; while true; do TIMESTAMP=$$(date +%Y%m%d_%H%M%S); + sqlite3 /data/raventag.db \".backup /tmp/raventag_snap.db\"; openssl enc -aes-256-cbc -pbkdf2 -iter 100000 \ -pass file:/run/secrets/admin_key \ - -in /data/raventag.db \ + -in /tmp/raventag_snap.db \ -out /backups/raventag_$${TIMESTAMP}.db.enc 2>/dev/null \ && echo \"[Backup] raventag_$${TIMESTAMP}.db.enc (encrypted)\"; - ls -t /backups/raventag_*.db.enc 2>/dev/null | tail -n +8 | xargs rm -f; - sleep 86400; + rm -f /tmp/raventag_snap.db; + ls -t /backups/raventag_*.db.enc 2>/dev/null | tail -n +4 | xargs rm -f; + sleep 21600; done" depends_on: backend: