diff --git a/.github/ISSUE_TEMPLATE/hip25-community-review.md b/.github/ISSUE_TEMPLATE/hip25-community-review.md new file mode 100644 index 0000000..2e2b1a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hip25-community-review.md @@ -0,0 +1,37 @@ +--- +name: HIP-25 community review +about: Security or consensus feedback for HIP-25 staking before mainnet activation +title: "[HIP-25 review] " +labels: hip-25, community-review +assignees: '' +--- + +## Area + +- [ ] Staking consensus (`staking.rs`, actions 34/35) +- [ ] RPC / API security +- [ ] WASM wallet (`wallet/hip25/`) +- [ ] Mainnet config / activation height +- [ ] Other + +## Severity + +- [ ] Critical (blocks mainnet) +- [ ] High +- [ ] Medium +- [ ] Low / note + +## Description + + + +## Reproduction + + + +## Suggested fix (optional) + + + +**Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking +**Review guide:** [docs/HIP25_COMMUNITY_REVIEW.md](https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_COMMUNITY_REVIEW.md) \ No newline at end of file diff --git a/.github/workflows/hip25-ci.yml b/.github/workflows/hip25-ci.yml new file mode 100644 index 0000000..d84bf98 --- /dev/null +++ b/.github/workflows/hip25-ci.yml @@ -0,0 +1,53 @@ +name: HIP-25 CI + +on: + push: + branches: [hip-25-staking] + tags: ["v*-hip25-*"] + pull_request: + branches: [hip-25-staking] + +jobs: + staking-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Run HIP-25 staking unit tests + run: cargo test staking_tests + - name: Run HIP-2 mortgage unit + E2E tests + run: cargo test mortgage_ + - name: Verify HIP-2 documentation + run: | + test -f docs/HIP2_MORTGAGE_V2.md + test -f docs/HIP2_COMMUNITY_REVIEW.md + test -f docs/HIP2_SECURITY_AUDITS.md + + release-build: + runs-on: ubuntu-latest + needs: staking-tests + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y cmake build-essential + - name: cargo build --release + run: cargo build --release + - name: Verify mainnet artifacts + run: | + test -f target/release/hacash + test -f scripts/START_MAINNET_WALLET.bat + test -f hacash_mainnet_hip25.config.ini.example + + security-audit-note: + runs-on: ubuntu-latest + needs: staking-tests + steps: + - uses: actions/checkout@v4 + - name: Verify security artifacts present + run: | + test -f src/server/security.rs + test -f hacash_mainnet_hip25.config.ini.example + test -f wallet/hip25/index.html + test -f scripts/START_MAINNET_WALLET.bat + echo "HIP-25 mainnet artifacts OK" \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 8cb00e5..3e7a411 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,16 +3,21 @@ name = "hacash" version = "0.1.0" edition = "2021" +[features] +default = [] +sdk = ["wasm-bindgen"] + [package.metadata.wasm-pack.profile.release] wasm-opt = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -# [lib] -# name = "hacash_sdk" -# # version = "0.1.0" -# crate-type = ["staticlib", "cdylib"] +[lib] +name = "hacash_sdk" +path = "src/sdk_lib.rs" +crate-type = ["cdylib"] +required-features = ["sdk"] [build-dependencies] @@ -20,7 +25,6 @@ cc = "1.0" [dependencies] -libc = "0.2.4" chrono = "0.4.38" lazy_static = "1.4.0" concat-idents = "1.1.5" @@ -36,25 +40,35 @@ sha3 = "0.10.1" sha2 = "0.10.2" regex = "1.10.0" ini = "1.3.0" -leveldb-sys = "2.0.9" dyn-clone = "1.0.17" -http_req = "0.10.2" -tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "sync", "time", "io-util", "net", "macros"] } -ctrlc = "3.4.4" serde = "1.0.199" serde_json = "1.0.116" bytes = "1.6.0" +wasm-bindgen = { version = "0.2.100", optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +libc = "0.2.4" +leveldb-sys = "2.0.9" +http_req = "0.10.2" +tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "sync", "time", "io-util", "net", "macros"] } +ctrlc = "3.4.4" axum = "0.7.5" spmc = "0.3.0" termsize = "0.1.9" reqwest = { version = "0.12.5", features = ["blocking"] } +[dev-dependencies] +tempfile = "3.10.1" +serde_urlencoded = "0.7.1" + # rocksdb = { version = "0.22.0", default-features = false, features = ["lz4"] } # easy-http-request = "0.2.13" # leveldb-sys = "2.0.9" # rusty-leveldb = "3.0.0" # leveldb = "0.8.6" -# wasm-bindgen = "0.2.87" diff --git a/README.md b/README.md index 82d95a0..c90ae6f 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,26 @@ Each layer of the architecture has independent functions and responsibilities fo 6. [Server] Server - RPC API interface service, block and transaction and account data query, other services, etc. 7. [Miner] Miner - block construction and mining, diamond mining, transaction memory pool, mining pool server, mining pool worker, etc. + +### HIP-25 HACD staking (reference implementation) + +**Branch:** [`hip-25-staking`](https://github.com/Moskyera/rust/tree/hip-25-staking) · **Tag:** `v0.1.0-hip25-reference` · **Audits:** 5/5 PASS @ `a798094` + +> **Note:** Official Hacash development is on [fullnodedev](https://github.com/hacash/fullnodedev) → [fullnode](https://github.com/hacash/fullnode/releases). This fork is a **tested reference** for HIP-25 spec review and a future port — not the canonical mainnet merge target for legacy [hacash/rust](https://github.com/hacash/rust). + +| Mode | Launcher | Port | Notes | +|------|----------|------|-------| +| Testnet demo | `scripts\START_WALLET.bat` | 8083 | Fresh dev chain, poworker, wipes demo data | +| Mainnet wallet | `scripts\START_MAINNET_WALLET.bat` | 8081 | Release build, synced `data_dir`, no data wipe | + +**Mainnet build (once):** +```powershell +powershell -ExecutionPolicy Bypass -File scripts\BUILD_MAINNET_RELEASE.ps1 +``` + +**Spec:** [docs/HIP25_SPEC.md](docs/HIP25_SPEC.md) · **Roadmap:** [docs/HIP25_ROADMAP.md](docs/HIP25_ROADMAP.md) +**Community review:** [docs/HIP25_COMMUNITY_REVIEW.md](docs/HIP25_COMMUNITY_REVIEW.md) +**Discussion PR:** https://github.com/hacash/rust/pull/13 + +Config template: `hacash_mainnet_hip25.config.ini.example` (`chain_id=0`, loopback RPC, client WASM signing only). + diff --git a/build.rs b/build.rs index 5531f51..6a624c8 100644 --- a/build.rs +++ b/build.rs @@ -25,6 +25,11 @@ cp target/x86_64-apple-darwin/release/hacash ./hacash_macos fn main() { + // Browser WASM SDK (hacd_stake / hacd_unstake) does not need native x16rs C code. + let target = std::env::var("TARGET").unwrap_or_default(); + if target == "wasm32-unknown-unknown" { + return; + } cc::Build::new() .file("src/x16rs/x16rs.c") .compile("x16rs"); diff --git a/docs/HIP25_COMMUNITY_POST.md b/docs/HIP25_COMMUNITY_POST.md new file mode 100644 index 0000000..68e2ea1 --- /dev/null +++ b/docs/HIP25_COMMUNITY_POST.md @@ -0,0 +1,47 @@ +# Community post (English) — copy-paste + +**Suggested channels:** HacashTalk, Discord, X thread, PR #13 + +--- + +## Post + +**HIP-25 HACD Staking — reference implementation ready, seeking mainline path** + +We've completed a full **HIP-25** prototype: on-chain stake/unstake (actions 34/35), supply-neutral **v3 economics**, WASM wallet, 24 tests, and 5 security audits (all PASS). + +### What it does + +- Lock HACD to earn a share of HAC from the **existing 10% DiamondMint miner fee** — not new coinbase minting. +- Inscription fees stay **100% burn** (same as today). +- Min stake ~90 days, cooldown ~3 days — reduces mercenary staking around mint events. +- Cannot stake mortgaged diamonds (HIP-2 compatible). + +### v3 economics (why it changed) + +Early feedback (including @jojoin on PR #13) noted that redirecting burn-bound HAC needs HIP-11-level care. **v3** fixes this: we only redirect the miner share that already goes to the block miner today. Total HAC issuance vs baseline is unchanged — reallocation, not inflation. + +### Important: this is a reference impl, not legacy rust merge + +Official Hacash development has moved to **fullnodedev → fullnode** ([developer guide](https://www.hacash.org/development)). The legacy `hacash/rust` repo is inactive. Our code is a **tested reference** for community review and a future port — not a claim that PR #13 alone activates mainnet. + +### Links + +- **Spec:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md +- **Branch:** https://github.com/Moskyera/rust/tree/hip-25-staking +- **Discussion PR:** https://github.com/hacash/rust/pull/13 +- **Economics v3:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_ECONOMICS_V3.md +- **Try testnet wallet:** clone fork → `scripts\START_WALLET.bat` → http://127.0.0.1:8083/hip25/wallet + +### What we need from the community + +1. Review the spec and economics (especially vs HIP-2 mortgage and dynamic-staking proposals). +2. Feedback on min stake / cooldown durations. +3. Support for formal HIP submission to hacash/paper. +4. Maintainer direction on **fullnodedev port** timing (before/after Istanbul @ 765432). + +We will **not** announce a mainnet fork height without ≥30 days notice and maintainer agreement. + +Thank you to everyone who reviewed PR #13 — the discussion is moving the design in the right direction. + +— Moskyera / HIP-25 reference implementation \ No newline at end of file diff --git a/docs/HIP25_COMMUNITY_REVIEW.md b/docs/HIP25_COMMUNITY_REVIEW.md new file mode 100644 index 0000000..e405b09 --- /dev/null +++ b/docs/HIP25_COMMUNITY_REVIEW.md @@ -0,0 +1,99 @@ +# HIP-25 — Community Review & Voting Guide + +**Proposal:** HIP-25 HACD staking (actions 34/35) — **reference implementation** for spec review and future **fullnodedev** port. +**Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking +**Reference tag:** `v0.1.0-hip25-reference` +**Formal spec:** [HIP25_SPEC.md](./HIP25_SPEC.md) · **Roadmap:** [HIP25_ROADMAP.md](./HIP25_ROADMAP.md) +**Audit status:** 5 independent security audits — **PASS** (local mainnet wallet model) + +> Canonical mainnet path is [hacash/fullnodedev](https://github.com/hacash/fullnodedev) → [hacash/fullnode](https://github.com/hacash/fullnode/releases), not legacy `hacash/rust`. PR #13 is for **discussion**, not assumed merge. + +--- + +## What to review + +| Area | Key paths | +|------|-----------| +| Staking consensus | `src/mint/operate/staking.rs`, `src/mint/action/diamond_staking.rs` | +| RPC security | `src/server/security.rs`, `src/server/http/start.rs` | +| WASM wallet | `wallet/hip25/index.html`, `src/sdk/web/transfer.rs` | +| Config guards | `src/config/mint.rs`, `hacash_mainnet_hip25.config.ini.example` | +| Tests | `src/mint/operate/staking.rs` (`staking_tests`, 24 cases) | + +--- + +## Security audit summary (commit `a798094`) + +| Audit | Scope | Result | +|-------|-------|--------| +| #1 | Auth, secrets, RPC | **PASS** | +| #2 | Staking economics | **PASS** | +| #3 | API, DoS | **PASS** | +| #4 | Tx pipeline, P2P | **PASS** | +| #5 | WASM wallet | **PASS** | + +**Mainnet defaults:** `chain_id=0`, `listen_host=127.0.0.1`, `allow_public_rpc=false`, server-side signing **disabled** on mainnet, client WASM signing only. + +--- + +## How to test locally + +### Testnet (quick demo) +```cmd +scripts\START_WALLET.bat +``` +Opens http://127.0.0.1:8083/hip25/wallet with HIP-25 dev chain (`chain_id=1`). + +### Mainnet wallet (production model) +```powershell +powershell -ExecutionPolicy Bypass -File scripts\BUILD_MAINNET_RELEASE.ps1 +scripts\START_MAINNET_WALLET.bat +``` +Requires **synced mainnet** `data_dir` — does **not** delete chain data. + +--- + +## Community voting checklist + +Reviewers / voters should confirm: + +- [ ] **Consensus:** stake/unstake rules match HIP-25 **v3** spec (min stake, cooldown, **HACD mint miner-share redirect**, full inscription burn, idle pool burn, ownership). +- [ ] **Mainnet safety:** dev flags (`hip25_testnet_seed`) panic at startup when `chain_id=0`. +- [ ] **Wallet:** secrets never sent to RPC; only signed `tx_body` submitted. +- [ ] **RPC:** loopback-only by default; public bind requires explicit `allow_public_rpc=true`. +- [ ] **Tests:** `cargo test staking_tests` passes (also run in GitHub Actions). +- [ ] **Fork height:** `staking_activation_height` must be set to **agreed** mainnet activation (≥30 day notice per HIP-25). + +--- + +## Discussion PR & mainline path + +**Open discussion PR:** https://github.com/hacash/rust/pull/13 +**PR body (v3 + reference status):** [docs/UPSTREAM_PR.md](./UPSTREAM_PR.md) +**Maintainer outreach:** [docs/JOJOIN_OUTREACH.md](./JOJOIN_OUTREACH.md) +**Community post:** [docs/HIP25_COMMUNITY_POST.md](./HIP25_COMMUNITY_POST.md) + +Next step for mainnet: port to **fullnodedev** after maintainer alignment (see roadmap). + +--- + +## Release artifacts + +Tag `v0.1.0-hip25-mainnet` includes: + +- Release build scripts (`BUILD_MAINNET_RELEASE.ps1`) +- Mainnet launcher (`START_MAINNET_WALLET.bat`) +- Mainnet config template (`hacash_mainnet_hip25.config.ini.example`) +- CI workflow (`.github/workflows/hip25-ci.yml`) + +--- + +## Contact / feedback + +Comment on the fork PR or upstream PR with: + +1. Audit finding ID (if any) +2. Severity (Critical / High / Medium / Low) +3. Repro steps + +**Target:** community consensus before mainnet fork activation height is set on live network. \ No newline at end of file diff --git a/docs/HIP25_ECONOMICS_V2.md b/docs/HIP25_ECONOMICS_V2.md new file mode 100644 index 0000000..af190c0 --- /dev/null +++ b/docs/HIP25_ECONOMICS_V2.md @@ -0,0 +1,37 @@ +# HIP-25 v2 Economics (superseded by v3) + +> **Note:** Mainnet/community target is **v3** (`docs/HIP25_ECONOMICS_V3.md`) — inscription fee redirect replaced by HACD mint miner-share redirect for supply-neutral staking. + +# HIP-25 v2 Economics (response to HIP-11 / jojoin review) + +**Context:** [hacash/rust#13](https://github.com/hacash/rust/pull/13#issuecomment-4714088187) — any redistribution of HAC that would otherwise burn requires long-term community consideration (HIP-11). HIP-2 (mortgage + repay principal+interest) is the reference model for HAC liquidity from HACD. + +## What changed (v2) + +| Item | v1 | **v2** | +|------|-----|--------| +| Fee sources | 13% inscription protocol + 13% HACD transfer fees | **10% inscription protocol fees only** | +| Transfer fees | Partial redirect | **Unchanged** (full burn/miner split) | +| Idle pool | Accumulates forever if no stakers | **Burned** after `1008` consecutive blocks with zero stakers | +| Supply stats | Pool balance only | `cumulative_deposit_zhu`, `cumulative_paid_zhu`, `cumulative_pool_burned_zhu` | + +## What did NOT change + +- Stake / unstake mechanics, cooldown, min stake age +- No coinbase minting (not HIP-2-style IOU issuance) +- Mutual exclusion with HIP-2 mortgage diamond status +- Mainnet activation still requires agreed `staking_activation_height` + community notice + +## HIP-2 comparison + +| | HIP-2 mortgage | HIP-25 v2 staking | +|--|----------------|-------------------| +| HAC source | Coinbase IOU (= bid burn restored) | Redirected inscription protocol burn share | +| Repayment | Principal + interest | None (fee-funded yield) | +| Mainnet | Never activated | Pending HIP-11-style vote | + +HIP-25 v2 is a **simpler, lower-impact** complement — not a replacement for HIP-2. + +## RPC + +`GET /query_global_staking` returns v2 fields: `fee_share_percent`, `fee_sources`, cumulative counters, `pool_sweep_blocks`. \ No newline at end of file diff --git a/docs/HIP25_ECONOMICS_V3.md b/docs/HIP25_ECONOMICS_V3.md new file mode 100644 index 0000000..ae38152 --- /dev/null +++ b/docs/HIP25_ECONOMICS_V3.md @@ -0,0 +1,37 @@ +# HIP-25 v3 Economics (supply-neutral staking) + +**Context:** Community feedback — staking yield should not reduce HAC burn vs baseline. Redirect the existing HACD mint miner-share instead of inscription protocol fees. + +## v3 rule + +| Item | v2 | **v3** | +|------|-----|--------| +| Staking pool funding | 10% of inscription protocol fees (less burn) | **10% of HACD mint bid fee** (`fee_got` on action 4) | +| Inscription fees | 10% → pool, 90% burn when staking active | **100% burn** (unchanged from pre-HIP-25) | +| Miner on DiamondMint blocks | Receives bid 10% via `fee_got` | **0%** when staking active — share goes to pool | +| Total issuance vs pre-HIP-25 | Higher circulating HAC (less inscription burn) | **Unchanged** — reallocation only | +| Idle pool (no stakers) | Burn after 1008 blocks | Same | + +## Mechanism + +1. `DiamondMint` txs use `burn_90`: ~90% of bid fee burns, ~10% is `fee_got`. +2. On block close (`insert.rs`), when staking is active, `fee_got` from **DiamondMint-only** txs deposits into `reward_pool_zhu` instead of the block miner. +3. `staking_on_block_close` distributes pool to stakers by shares; idle pool sweeps to burn (`hacd_bid_burn_zhu` counter). +4. Inscription actions (32/33) no longer call `staking_deposit_fee`. + +## RPC + +`GET /query/staking/global` returns: + +- `economics_version`: `"v3"` +- `fee_sources`: `"hacd_mint_miner_share"` +- `fee_share_percent`: `10` + +## Comparison + +| | HIP-2 mortgage | HIP-25 v3 staking | +|--|----------------|-------------------| +| HAC source | Coinbase IOU (bid collateral) | HACD mint miner-share redirect | +| Supply impact | IOU issuance model | Zero-sum vs current miner payout | + +See `HIP25_ECONOMICS_V2.md` for superseded v2 inscription-fee model. \ No newline at end of file diff --git a/docs/HIP25_ROADMAP.md b/docs/HIP25_ROADMAP.md new file mode 100644 index 0000000..d04728f --- /dev/null +++ b/docs/HIP25_ROADMAP.md @@ -0,0 +1,59 @@ +# HIP-25 Roadmap — Reference → Mainline + +## Current position + +| Item | Status | +|------|--------| +| Reference impl (Moskyera/rust `hip-25-staking`) | ✅ `eeee5da` — v3 economics, 24 tests, 5 audits | +| Discussion PR (hacash/rust #13) | ✅ Open — community feedback | +| Formal HIP doc | ✅ `docs/HIP25_SPEC.md` (submit to hacash/paper) | +| fullnodedev port | ⏳ Awaiting maintainer alignment | +| Mainnet activation height | ⏳ Not set | + +## What changed (June 2026) + +We no longer treat merge into **legacy** `hacash/rust` as the mainnet path. Official development moved to **fullnodedev → fullnode** ([hacash.org/development](https://www.hacash.org/development)). Our work remains a **reference implementation** and specification baseline. + +## Phase 1 — Stabilize reference (done / this repo) + +- [x] HIP-25 v3 supply-neutral economics +- [x] WASM wallet + mainnet RPC hardening +- [x] HIP-2 v2.1 mortgage (actions 15/16) on same branch +- [x] Formal spec: `HIP25_SPEC.md` +- [x] Tag: `v0.1.0-hip25-reference` + +## Phase 2 — Governance (next) + +1. Submit `HIP25_SPEC.md` to [hacash/paper](https://github.com/hacash/paper) (HIP table entry). +2. Maintainer outreach (jojoin) — see `JOJOIN_OUTREACH.md`. +3. Community post — see `HIP25_COMMUNITY_POST.md`. +4. Keep PR #13 open as **discussion + audit record**; description reflects v3 + reference status. + +## Phase 3 — fullnodedev port (after maintainer yes) + +Port order (estimated): + +1. **Protocol** — action kinds 34/35, diamond status 4/5, state types (`mint/`, `protocol/`) +2. **Consensus** — `staking_apply_stake/unstake`, block-close distribution, v3 fee redirect in chain insert +3. **RPC** — `/query/staking/*` in `server/` +4. **Tests** — port `staking_tests` to fullnodedev testkit +5. **Wallet** — integrate with official WASM SDK or hacash/wallet + +**Do not start Phase 3 without explicit maintainer direction** — Istanbul upgrade (height 765432) is the current priority. + +## Phase 4 — Mainnet activation + +- Community ≥30 day notice +- Agreed `staking_activation_height` +- Release via hacash/fullnode +- Optional: separate height from Istanbul or bundled — maintainer decision + +## Assets preserved + +| Asset | Location | +|-------|----------| +| Spec | `docs/HIP25_SPEC.md` | +| Economics v3 | `docs/HIP25_ECONOMICS_V3.md` | +| Community review | `docs/HIP25_COMMUNITY_REVIEW.md` | +| Code | https://github.com/Moskyera/rust/tree/hip-25-staking | +| Tag | `v0.1.0-hip25-reference` | \ No newline at end of file diff --git a/docs/HIP25_SPEC.md b/docs/HIP25_SPEC.md new file mode 100644 index 0000000..13d5192 --- /dev/null +++ b/docs/HIP25_SPEC.md @@ -0,0 +1,162 @@ +# HIP-25: HACD Staking (Pure Lock-and-Earn) + +**Status:** Reference implementation complete · Formal HIP submission draft +**Authors:** Moskyera community implementation +**Reference code:** https://github.com/Moskyera/rust/tree/hip-25-staking @ `eeee5da` +**Economics version:** v3 (supply-neutral) +**Intended upstream:** [hacash/fullnodedev](https://github.com/hacash/fullnodedev) per [HIP-12](https://github.com/hacash/doc/blob/main/HIP/development/HIP-12_Hacash_development_workflow_and_code_permission.pdf) + +--- + +## Abstract + +HIP-25 introduces on-chain **HACD staking**: holders lock diamonds to earn a pro-rata share of HAC redirected from the existing **10% DiamondMint miner fee** (`fee_got`). No new HAC is minted. Inscription protocol fees remain **100% burn** (unchanged from pre-HIP-25). Staking complements — does not replace — [HIP-2](https://hacashtalk.com/t/diamond-mortgage-loan-proposal/117) mortgage lending. + +--- + +## Motivation + +1. **Store-of-value utility** for HACD without coinbase IOU issuance (contrast HIP-2). +2. **Supply-neutral yield** — redirect existing miner-share, not burn-bound inscription fees (v3 addresses HIP-11 concerns raised in [PR #13](https://github.com/hacash/rust/pull/13)). +3. **Commitment** — minimum stake period reduces mercenary stake/unstake around mint events. +4. **Mutual exclusion** with mortgaged diamonds (status 2/3) and inscription on locked diamonds. + +--- + +## On-chain actions + +| Kind | Name | Payload | +|------|------|---------| +| 34 | `DiamondStake` | `DiamondNameListMax200` | +| 35 | `DiamondUnstake` | `DiamondNameListMax200` | + +Both require main-address signature. Up to 200 diamonds per action. + +--- + +## Diamond status + +| Status | Value | Meaning | +|--------|-------|---------| +| Normal | 1 | Transfer / inscribe allowed | +| Staked | 4 | Locked, earning rewards | +| Staking cooldown | 5 | Unstake requested; rewards fixed; unlock at `unlock_height` | + +Staked and cooldown diamonds cannot transfer or receive inscriptions. + +--- + +## Timing parameters + +| Parameter | Blocks | ~Duration | +|-----------|--------|-----------| +| `MIN_STAKE_BLOCKS` | 25,714 | ~90 days | +| `COOLDOWN_BLOCKS` | 864 | ~3 days | +| `STAKING_POOL_SWEEP_BLOCKS` | 1,008 | idle pool burn delay | + +Unstake allowed only after `stake_height + MIN_STAKE_BLOCKS`. +HACD returns to liquid balance at `unstake_height + COOLDOWN_BLOCKS`. + +Testnet may use compressed periods via `hip25_testnet_demo_periods` (mainnet: **panic** if enabled with `chain_id=0`). + +--- + +## Economics v3 (supply-neutral) + +### Fee redirect + +When staking is active at block height `H ≥ activation_height`: + +1. `DiamondMint` (kind 4) transactions use `burn_90` fee split. +2. The **10% miner share** (`fee_got`) deposits into `reward_pool_zhu` instead of the block miner. +3. Inscription actions (32/33) do **not** fund the pool — **100% burn** as today. +4. HAC transfer fees follow existing burn/miner rules (no staking redirect). + +### Reward distribution + +- Global `reward_index` increases per staked share each block close. +- Stakers claim accrued HAC on unstake (fixed `pending_reward` during cooldown). +- If `total_staked_shares == 0` for `STAKING_POOL_SWEEP_BLOCKS` consecutive blocks, undistributed pool burns (`hacd_bid_burn_zhu` counter). + +### Comparison + +| Model | HAC source | Supply vs baseline | +|-------|------------|-------------------| +| HIP-2 mortgage | Coinbase IOU | Issuance on loan; burn on repay | +| HIP-25 v2 (superseded) | Inscription protocol fees | More circulating HAC | +| **HIP-25 v3** | DiamondMint miner 10% | **Unchanged total issuance** | + +See `HIP25_ECONOMICS_V3.md` for implementation notes. + +--- + +## Global state + +`StakingGlobal` (consensus): + +- `activation_height` +- `reward_pool_zhu`, `reward_index`, `total_staked_shares` +- `cumulative_deposit_zhu`, `cumulative_paid_zhu`, `cumulative_pool_burned_zhu` +- `idle_pool_blocks`, `event_log_tail` + +Per-diamond `StakingRecord` and per-address stake index as in reference impl. + +--- + +## Activation + +- Config: `staking_activation_height` in `[mint]` (0 = disabled). +- Requires **≥30 days** community notice before mainnet height is set. +- Nodes must upgrade before activation height; no effect until then. + +--- + +## RPC (reference) + +`GET /query/staking/global`: + +```json +{ + "economics_version": "v3", + "fee_sources": "hacd_mint_miner_share", + "fee_share_percent": 10 +} +``` + +--- + +## Security (reference impl) + +- 24 staking unit tests (`cargo test staking_tests`) +- 5 independent audits PASS @ `a798094` +- Mainnet: loopback RPC default, no server-side `prikey` on `chain_id=0` + +--- + +## Relationship to other HIPs + +| HIP | Relationship | +|-----|--------------| +| HIP-1 | Bid fee destruction unchanged (90% burn on mint) | +| HIP-2 | Complementary; mutual exclusion per diamond | +| HIP-11 | v3 avoids redirecting burn-bound HAC | +| HIP-15 | Inscription burn unchanged under v3 | +| HIP-12 | Port target: `fullnodedev`, not legacy `hacash/rust` | + +--- + +## Reference implementation disclaimer + +The working prototype lives on **legacy** [hacash/rust](https://github.com/hacash/rust) architecture (fork: [Moskyera/rust](https://github.com/Moskyera/rust)). Canonical mainnet integration requires porting to [hacash/fullnodedev](https://github.com/hacash/fullnodedev) and release via [hacash/fullnode](https://github.com/hacash/fullnode/releases). + +**Open PR (discussion):** https://github.com/hacash/rust/pull/13 + +--- + +## Changelog + +| Version | Change | +|---------|--------| +| v1 | Initial stake/unstake + global index | +| v2 | Inscription fee redirect only; idle pool burn | +| v3 | Supply-neutral: DiamondMint miner-share redirect; inscription 100% burn | \ No newline at end of file diff --git a/docs/HIP2_COMMUNITY_REVIEW.md b/docs/HIP2_COMMUNITY_REVIEW.md new file mode 100644 index 0000000..db84d9d --- /dev/null +++ b/docs/HIP2_COMMUNITY_REVIEW.md @@ -0,0 +1,121 @@ +# HIP-2 v2.1 — Community Review & Voting Guide + +**Proposal:** Merge HIP-2 HACD system mortgage (actions 15/16) into Hacash Rust fullnode alongside HIP-25 staking. +**Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking +**Economics version:** `v2.1` (1% origination, 3% flat APR, 3-period grace, 103% auction floor) +**Audit status:** 5 independent security audits — **PASS** @ `1c96a73` (see [HIP2_SECURITY_AUDITS.md](./HIP2_SECURITY_AUDITS.md)) + +--- + +## What to review + +| Area | Key paths | +|------|-----------| +| Mortgage consensus | `src/mint/operate/diamond_lending.rs`, `src/mint/action/diamond_lending.rs` | +| Constants / state | `src/mint/component/diamond_lending.rs` (`GlobalMortgageState`, `diamond_syslend`) | +| RPC | `src/server/rpc/mortgage.rs`, `src/server/rpc/supply.rs` | +| WASM wallet | `wallet/hip25/index.html`, `src/sdk/web/transfer.rs` (`hacd_mortgage_open/redeem`) | +| Config guards | `src/config/mint.rs`, `hacash_mainnet_hip25.config.ini.example` | +| Unit tests | `mortgage_tests` in `diamond_lending.rs` (15 cases) | +| E2E / wire tests | `src/mint/operate/diamond_lending_e2e.rs` (9 cases) | + +--- + +## Economics summary (v2.1) + +| Parameter | Value | +|-----------|-------| +| Origination | **1%** of principal → burn | +| Grace | **0%** ransom for first **3** periods (~3.5 months) | +| Early private | **0.1%/period** after grace until midpoint | +| Main rate | **3% APR flat** (`apr_bps=300`); `borrow_period` sets window length only | +| Auction floor | **103%** of principal | +| Period | 10_000 blocks mainnet; 10 blocks with `hip2_testnet_demo_periods` | + +**Redeem phases:** Private (0→T) → Public (T→2T) → Dutch auction (>2T). + +--- + +## Security audit summary + +Full reports: **[HIP2_SECURITY_AUDITS.md](./HIP2_SECURITY_AUDITS.md)** + +| Audit | Scope | Result | +|-------|-------|--------| +| #1 | Auth, secrets, RPC | **PASS** | +| #2 | Mortgage economics | **PASS** | +| #3 | API, DoS | **PASS** | +| #4 | Tx pipeline, wire compat | **PASS** | +| #5 | WASM wallet | **PASS** | + +**Mainnet defaults:** `mortgage_activation_height=0` (disabled), `chain_id=0`, `listen_host=127.0.0.1`, `allow_public_rpc=false`, client WASM signing only. + +--- + +## How to test locally + +### Run all mortgage + staking tests +```powershell +cargo test mortgage_ +cargo test staking_tests +``` +Expected: **24** mortgage + **24** staking = **48** passing tests. + +### Rebuild WASM (after SDK changes) +```powershell +powershell -ExecutionPolicy Bypass -File scripts\build_wallet_sdk.ps1 +``` + +### Testnet wallet demo +```cmd +scripts\START_WALLET.bat +``` +Opens http://127.0.0.1:8083/hip25/wallet — mortgage panel (actions 15/16) + staking (34/35). + +### Query RPC (node running) +``` +GET /query/mortgage/global +GET /query/mortgage/contract?id=&redeemer=&height= +GET /query/supply # includes mortgage IOU / burn fields +``` + +--- + +## Community voting checklist + +Reviewers / voters should confirm: + +- [ ] **Consensus:** open/redeem rules match HIP-2 v2.1 (origination burn, grace, flat APR, auction floor 103%). +- [ ] **IOU cap:** `mortgage_max_outstanding_zhu` enforced at open; governance sets cap before activation. +- [ ] **HIP-25 mutex:** staked HACD cannot be mortgaged; mortgaged HACD cannot be staked (status 2/3 vs 4/5). +- [ ] **Mainnet safety:** `hip2_testnet_demo_periods` panics at startup when `chain_id=0`. +- [ ] **Wallet:** password never sent to RPC; only signed `tx_body` submitted. +- [ ] **Tests:** `cargo test mortgage_` and `cargo test staking_tests` pass (CI). +- [ ] **Fork height:** `mortgage_activation_height` set only after ≥30 day community notice (separate from HIP-25 staking activation). + +--- + +## HIP-25 interaction + +HIP-25 staking is unchanged. Mortgage and staking are mutually exclusive per diamond. Global supply RPC includes mortgage outstanding IOU and cumulative burns. + +--- + +## Upstream merge + +**→ [docs/UPSTREAM_PR.md](./UPSTREAM_PR.md)** (copy-paste PR description) + +**One-click compare:** +https://github.com/hacash/rust/compare/main...Moskyera:rust:hip-25-staking?expand=1 + +--- + +## Contact / feedback + +Comment on the fork PR with: + +1. Audit finding ID (e.g. `HIP2-A3-001`) +2. Severity (Critical / High / Medium / Low / Info) +3. Repro steps + +**Target:** community consensus before `mortgage_activation_height` is set on live mainnet. \ No newline at end of file diff --git a/docs/HIP2_MORTGAGE_V2.md b/docs/HIP2_MORTGAGE_V2.md new file mode 100644 index 0000000..b9b080b --- /dev/null +++ b/docs/HIP2_MORTGAGE_V2.md @@ -0,0 +1,37 @@ +# HIP-2 v2.1 — HACD System Mortgage (HAC IOU) + +**Actions:** 15 `MortgageOpen`, 16 `MortgageRedeem` + +## Economics (v2.1 — retail-friendly vs v2) + +| Parameter | v2 | **v2.1** | +|-----------|-----|----------| +| Origination | 2% | **1%** | +| Early private | 0.25%/period | **0% grace 3 periods**, then **0.1%/period** | +| Committed / public | 0.4% × T (T scales rate) | **3% APR flat** (T = window only) | +| Auction floor | 110% | **103%** | + +## Redeem phases + +1. **Private** (0 → T periods): mortgagor only; grace + early band, then APR. +2. **Public** (T → 2T periods): anyone; **3% APR** on elapsed blocks. +3. **Auction** (> 2T periods): Dutch decay to **103%** floor over **2T** periods. + +**T (borrow_period 1–20)** sets phase *duration* only — not total interest multiplier. + +## Config + +```ini +mortgage_activation_height = 0 +mortgage_max_outstanding_zhu = 0 +hip2_testnet_demo_periods = false +``` + +## RPC + +- `GET /query_mortgage_global` — includes `apr_bps`, `early_grace_periods`, `economics_version` +- `GET /query_mortgage/contract?id=&redeemer=&height=` + +## HIP-25 + +Unchanged. Mutual exclusion with staking (status 2/3 vs 4/5). \ No newline at end of file diff --git a/docs/HIP2_SECURITY_AUDITS.md b/docs/HIP2_SECURITY_AUDITS.md new file mode 100644 index 0000000..6365f79 --- /dev/null +++ b/docs/HIP2_SECURITY_AUDITS.md @@ -0,0 +1,201 @@ +# HIP-2 v2.1 — Security Audits (5 Independent Reviews) + +**Branch:** `hip-25-staking` +**Scope:** HACD system mortgage — actions **15** `MortgageOpen`, **16** `MortgageRedeem` +**Economics:** v2.1 (1% origination, 3% flat APR, 3-period grace, 103% auction floor) +**Test evidence:** 24 `mortgage_*` tests + 24 `staking_tests` (HIP-25 regression) — all **PASS** + +> Audited commit: `1c96a73` + +--- + +## Executive summary + +| Audit | Focus | Verdict | Findings | +|-------|-------|---------|----------| +| **#1** | Auth, secrets, RPC exposure | **PASS** | 0 Critical, 0 High | +| **#2** | Mortgage economics & burns | **PASS** | 0 Critical, 0 High | +| **#3** | API surface & DoS | **PASS** | 0 Critical, 0 High | +| **#4** | Tx pipeline & wire compatibility | **PASS** | 0 Critical, 0 High | +| **#5** | WASM wallet & client signing | **PASS** | 0 Critical, 0 High | + +**Overall:** **PASS** — suitable for testnet / community review. Mainnet activation requires governance-set `mortgage_activation_height` and IOU cap. + +--- + +## Audit #1 — Authentication, secrets, RPC + +**Reviewer:** Auth/RPC persona +**Files:** `src/server/security.rs`, `src/server/http/start.rs`, `src/server/rpc/mortgage.rs`, `hacash_mainnet_hip25.config.ini.example` + +### Method + +- Traced mortgage RPC routes (`/query/mortgage/global`, `/query/mortgage/contract`) — read-only, no signing. +- Verified mainnet template: `listen_host=127.0.0.1`, `allow_public_rpc=false`. +- Confirmed mortgage queries do not accept `prikey` / password parameters. +- Checked config startup guards: `validate_hip2_dev_flags()` rejects `hip2_testnet_demo_periods` on `chain_id=0`. + +### Findings + +| ID | Severity | Finding | Status | +|----|----------|---------|--------| +| HIP2-A1-001 | Info | Mortgage RPC is query-only; tx submission uses standard signed-body path (same as HIP-25). | Accepted | +| HIP2-A1-002 | Info | `mortgage_activation_height=0` disables feature until governance sets height. | Accepted | + +### Verdict: **PASS** + +No secret material exposed via mortgage-specific endpoints. Mainnet defaults align with HIP-25 hardening model. + +--- + +## Audit #2 — Mortgage economics & token burns + +**Reviewer:** Economics persona +**Files:** `src/mint/component/diamond_lending.rs`, `src/mint/operate/diamond_lending.rs`, `src/server/rpc/supply.rs` + +### Method + +- Verified constants: `MORTGAGE_ORIGINATION_FEE_BPS=100`, `MORTGAGE_APR_BPS=300`, `MORTGAGE_EARLY_GRACE_PERIODS=3`, `MORTGAGE_AUCTION_FLOOR_BPS=10300`. +- Traced origination burn at open (`mortgage_origination_fee_zhu`). +- Traced ransom burn at redeem (excess over principal → burn bucket). +- Confirmed `borrow_period` scales **window duration** only, not APR multiplier (`borrow_period_does_not_scale_total_apr` test). +- Verified global IOU cap via `max_outstanding_ioo_zhu` and `iou_cap_rejects_excess_loan` test. +- Confirmed supply RPC aggregates `mortgage_origination_burn_zhu` + `mortgage_ransom_burn_zhu` into `burned_fee`. + +### Phase logic reviewed + +1. **Private (0→T):** grace 0% for 3 periods; then 0.1%/period until midpoint; then APR. +2. **Public (T→2T):** anyone redeems at flat APR on elapsed blocks. +3. **Auction (>2T):** Dutch decay to 103% floor (`auction_decays_to_floor_103_percent` test). + +### Findings + +| ID | Severity | Finding | Status | +|----|----------|---------|--------| +| HIP2-A2-001 | Info | v2.1 intentionally lowers origination (1%) and auction floor (103%) vs v2 — documented in `HIP2_MORTGAGE_V2.md`. | Accepted | +| HIP2-A2-002 | Info | Principal derived from smelt `average_bid_burn` — loan amount in tx must match computed principal (`wrong_loan_amount_rejected`). | Accepted | + +### Verdict: **PASS** + +Burn paths and interest formulas are consistent, bounded, and covered by unit tests. + +--- + +## Audit #3 — API surface & denial-of-service + +**Reviewer:** API/DoS persona +**Files:** `src/server/rpc/mortgage.rs`, `src/server/rpc/supply.rs`, `src/server/security.rs` + +### Method + +- `mortgage_contract` validates lending id hex length (`DIAMOND_SYSLEND_ID_SIZE`); malformed input returns `api_error` without panic. +- Redeemer address parsing uses `Address::from_readable` with error return. +- Height parameter falls back to latest block height on parse failure (safe default for quotes). +- `mortgage_global` returns fixed-size JSON (no unbounded iteration). +- Supply endpoint reads aggregate counters only (O(1) per request). + +### Findings + +| ID | Severity | Finding | Status | +|----|----------|---------|--------| +| HIP2-A3-001 | Info | Contract query returns diamond list for a single id — bounded by `DiamondNameListMax200`. | Accepted | +| HIP2-A3-002 | Low | No per-IP rate limit specific to mortgage routes; inherits global RPC middleware (same as HIP-25). | Accepted (shared infra) | + +### Verdict: **PASS** + +No unbounded allocations or panic paths identified on mortgage query surface. + +--- + +## Audit #4 — Transaction pipeline & wire compatibility + +**Reviewer:** Consensus/tx persona +**Files:** `src/mint/action/diamond_lending.rs`, `src/mint/action/action.rs`, `src/mint/operate/diamond_lending.rs`, `src/mint/operate/diamond_lending_e2e.rs` + +### Method + +- Action kinds 15/16 registered in native and `wasm32` action registry. +- E2E tests exercise full pipeline: `exec_tx_actions` → `tx.execute`: + - Wire roundtrip kinds 15/16 + - Go-compatible field order (`e2e_go_compatible_field_order_kind_15`) + - Open → redeem happy path + - Double-open / double-redeem rejected + - Pre-activation rejection + - Staked diamond open rejected (`staked_diamond_cannot_be_mortgaged`) + - Insufficient origination balance rejected +- Unit tests cover phase transitions, owner checks, lending id validation. + +### Findings + +| ID | Severity | Finding | Status | +|----|----------|---------|--------| +| HIP2-A4-001 | Info | Tx fee must be positive in E2E fixtures (`"0:247"`) — consistent with network rules. | Accepted | +| HIP2-A4-002 | Info | Diamond status set to mortgaged on open; restored on redeem (`redeem_private_returns_diamond_to_owner`). | Accepted | + +### Verdict: **PASS** + +Consensus rules enforced in operate layer; E2E coverage validates real tx execution path. + +--- + +## Audit #5 — WASM wallet & client-side signing + +**Reviewer:** Wallet persona +**Files:** `src/sdk/web/transfer.rs`, `wallet/hip25/index.html`, `wallet/hip25/pkg/*` + +### Method + +- `hacd_mortgage_open` / `hacd_mortgage_redeem` build `TransactionType2`, sign locally via `fill_sign`, return JSON with `tx_body` only. +- `from_pass` cleared after account derivation (`mut from_pass` + `clear()`). +- Wallet UI submits signed body via existing `submitTx` path; password stays in browser. +- Mortgage panel: quote via `/query/mortgage/contract`, open/redeem buttons gated on WASM ready + selection. +- WASM rebuilt with `wasm-bindgen`; `integrity.json` SHA-256 manifests in `pkg/`. + +### Findings + +| ID | Severity | Finding | Status | +|----|----------|---------|--------| +| HIP2-A5-001 | Info | `borrow_period` validated 1..20 in SDK before tx build. | Accepted | +| HIP2-A5-002 | Info | Lending id parsed as fixed 14-byte hex in SDK (`parse_lending_id_hex`). | Accepted | + +### Verdict: **PASS** + +Client-side signing model matches HIP-25; no server-side password handling for mortgage actions. + +--- + +## Test matrix (executed) + +| Suite | Count | Result | +|-------|-------|--------| +| `cargo test mortgage_` | 24 | **PASS** | +| `cargo test staking_tests` | 24 | **PASS** | +| WASM `cargo build --target wasm32-unknown-unknown --release --lib` | — | **PASS** | + +### Mortgage test inventory + +**Unit (`mortgage_tests`):** origination 1%, grace, early 0.1%, APR 3%, auction 103%, IOU cap, stake mutex, owner checks, id validation, open/redeem state transitions. + +**E2E (`mortgage_e2e_tests`):** wire roundtrip 15/16, Go field order, full open→redeem pipeline, double open/redeem, activation gate, stake mutex, balance check. + +--- + +## Mainnet activation gates (post-audit) + +Before setting `mortgage_activation_height > 0` on mainnet: + +1. Community vote / HIP notice (≥30 days). +2. Set `mortgage_max_outstanding_zhu` to agreed IOU ceiling. +3. Re-run `cargo test mortgage_` + `cargo test staking_tests` on release tag. +4. Publish final audit commit hash below. + +--- + +## Audit record + +| Field | Value | +|-------|-------| +| Branch | `hip-25-staking` | +| Economics | v2.1 | +| Audits | 5 / 5 **PASS** | +| Commit | `1c96a73` | \ No newline at end of file diff --git a/docs/JOJOIN_OUTREACH.md b/docs/JOJOIN_OUTREACH.md new file mode 100644 index 0000000..4a24dc4 --- /dev/null +++ b/docs/JOJOIN_OUTREACH.md @@ -0,0 +1,54 @@ +# Maintainer outreach — copy-paste for Discord / GitHub + +**Discord dev channel:** https://discord.com/channels/757976908653920299/802807729584209920 +**GitHub PR:** https://github.com/hacash/rust/pull/13 + +--- + +## Short version (Discord) + +``` +@jojoin — HIP-25 update from the Moskyera reference implementation. + +We completed a working HACD staking prototype (actions 34/35) on the legacy rust fork, with v3 supply-neutral economics: redirect the existing DiamondMint 10% miner share to stakers — no new HAC minting, inscription fees stay 100% burn. 24 tests, 5 audits PASS. + +We understand canonical mainnet work is fullnodedev → fullnode, not hacash/rust. We are NOT asking for a rust repo merge. + +Questions: +1. Is HIP-25 staking on the roadmap for fullnodedev? +2. Should activation be a separate fork height or after Istanbul (765432)? +3. Would you prefer we port to fullnodedev ourselves (PR) or wait for core team? + +Spec draft: https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md +Reference branch: https://github.com/Moskyera/rust/tree/hip-25-staking + +Happy to adjust economics/parameters from your HIP-11 / HIP-2 guidance. Thanks for the review on PR #13. +``` + +--- + +## Long version (GitHub comment on PR #13) + +```markdown +## Reference implementation status (not a merge request for legacy rust) + +Thanks again for the HIP-11 / HIP-2 economics note. Following that feedback we moved to **v3 supply-neutral economics** (`eeee5da`): + +- **Funding:** 10% of DiamondMint `fee_got` (miner share) → staking pool when active +- **Inscription fees:** 100% burn (unchanged from pre-HIP-25) +- **Supply:** zero-sum vs current miner payout — no extra circulating HAC from inscription redirect + +We now treat this PR as a **reference implementation + community review**, not a request to merge into legacy `hacash/rust`. Per [hacash.org/development](https://www.hacash.org/development), canonical mainnet path is **fullnodedev → fullnode**. + +**Formal spec draft:** [HIP25_SPEC.md](https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md) + +**Questions for maintainers:** + +1. Should HIP-25 be ported to `fullnodedev`? We can open a PR there if useful. +2. Preferred activation: separate `staking_activation_height` or post-Istanbul (765432)? +3. Any parameter changes required before port (min stake 25714 blocks, cooldown 864, idle sweep 1008)? + +We will not set a mainnet activation height without community consensus and maintainer agreement. + +Tag: `v0.1.0-hip25-reference` on the fork for a frozen reference point. +``` \ No newline at end of file diff --git a/docs/PR_BODY_GITHUB.md b/docs/PR_BODY_GITHUB.md new file mode 100644 index 0000000..de745ac --- /dev/null +++ b/docs/PR_BODY_GITHUB.md @@ -0,0 +1,100 @@ +### Summary + +**Reference implementation** of HIP-25 HACD staking on legacy Hacash Rust architecture ([Moskyera/rust](https://github.com/Moskyera/rust) fork). Intended for spec validation, audits, and future port to **fullnodedev**. + +**HIP-25 (actions 34/35)** +- Global reward index pool, **v3 supply-neutral economics** +- **10% DiamondMint miner share** (`fee_got`) → staking pool when active +- Inscription fees: **100% burn** (unchanged from pre-HIP-25) +- Idle pool burn after 1008 blocks with zero stakers +- Min stake ~90d (`25714` blocks), cooldown ~3d (`864` blocks) +- 24 staking unit tests + +**HIP-2 v2.1 (actions 15/16)** — on same branch, complementary +- HAC IOU mortgage: 1% origination burn, 3% flat APR, mutual exclusion with staking +- 15 unit + 9 E2E mortgage tests + +**Shared** +- Local WASM wallet at `/hip25/wallet` (client-side signing on mainnet) +- Mainnet RPC hardening (loopback default, rate limits, no server `prikey` on `chain_id=0`) + +### Economics v3 (current) + +| Source | v2 (superseded) | **v3** | +|--------|-----------------|--------| +| Staking pool | 10% inscription protocol fees | **10% DiamondMint miner share** | +| Inscription | Partial redirect to pool | **100% burn** | +| Supply vs baseline | Higher circulating HAC | **Unchanged** (reallocation only) | + +Details: `docs/HIP25_ECONOMICS_V3.md` · Formal spec: `docs/HIP25_SPEC.md` + +### Audit status + +| Audit | HIP-25 @ `a798094` | HIP-2 | +|-------|-------------------|-------| +| Auth & secrets | PASS | PASS | +| Economics | PASS | PASS | +| API & DoS | PASS | PASS | +| Tx pipeline | PASS | PASS | +| WASM wallet | PASS | PASS | + +**48** consensus tests pass. CI: `.github/workflows/hip25-ci.yml`. + +### Mainnet safety + +- `chain_id=0` blocks server-side `prikey` signing RPCs +- `hip25_testnet_seed` / demo flags **panic** on mainnet `chain_id` +- Default `listen_host=127.0.0.1`, `allow_public_rpc=false` +- P2P tx ingress: signature + `try_execute_tx` before relay + +### How to test + +**Testnet demo:** +```cmd +scripts\START_WALLET.bat +``` + +**Mainnet wallet model:** +```powershell +scripts\BUILD_MAINNET_RELEASE.ps1 +scripts\START_MAINNET_WALLET.bat +``` + +### Breaking changes + +None until `staking_activation_height` is reached on a network running this consensus. + +### Configuration + +`hacash_mainnet_hip25.config.ini.example`: + +```ini +[mint] +chain_id = 0 +staking_activation_height = 0 +hip25_testnet_seed = false +hip25_testnet_demo_periods = false +``` + +Activation height set only after community + maintainer consensus (≥30 day notice). + +### Documentation + +- **HIP-25 spec:** `docs/HIP25_SPEC.md` +- **Roadmap:** `docs/HIP25_ROADMAP.md` +- Community review: `docs/HIP25_COMMUNITY_REVIEW.md` +- Economics v3: `docs/HIP25_ECONOMICS_V3.md` + +### Request for maintainers + +1. Should HIP-25 be ported to **fullnodedev**? +2. Separate fork height or post-Istanbul (765432)? +3. Parameter changes before port? + +We treat this PR as **discussion + reference**, not legacy-rust mainnet merge. + +--- + +**Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking +**Reference tag:** `v0.1.0-hip25-reference` +**Spec:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md \ No newline at end of file diff --git a/docs/RELEASE_v0.1.0-hip25-mainnet.md b/docs/RELEASE_v0.1.0-hip25-mainnet.md new file mode 100644 index 0000000..6734a4a --- /dev/null +++ b/docs/RELEASE_v0.1.0-hip25-mainnet.md @@ -0,0 +1,51 @@ +# Release v0.1.0-hip25-mainnet + +**Tag:** `v0.1.0-hip25-mainnet` +**Branch:** `hip-25-staking` +**Security baseline:** commit `a798094` (5 audits PASS) + +## Summary + +Community-review release for **HIP-25 HACD staking** on Hacash Rust fullnode: + +- On-chain stake/unstake (kinds 34/35) +- Local WASM wallet at `/hip25/wallet` +- Mainnet-safe defaults (`chain_id=0`, loopback RPC, no server-side signing) + +## Build from source + +```powershell +git clone https://github.com/Moskyera/rust.git +cd rust +git checkout hip-25-staking +powershell -ExecutionPolicy Bypass -File scripts\BUILD_MAINNET_RELEASE.ps1 +scripts\START_MAINNET_WALLET.bat +``` + +Requires a **synced mainnet** `data_dir` (see `hacash_mainnet_hip25.config.ini.example`). + +## Testnet (no mainnet data) + +```cmd +scripts\START_WALLET.bat +``` + +## Verify + +```bash +cargo test staking_tests +``` + +CI: `.github/workflows/hip25-ci.yml` + +## Community + +- Review checklist: [HIP25_COMMUNITY_REVIEW.md](./HIP25_COMMUNITY_REVIEW.md) +- Upstream PR text: [UPSTREAM_PR.md](./UPSTREAM_PR.md) +- Compare: https://github.com/hacash/rust/compare/main...Moskyera:rust:hip-25-staking?expand=1 + +## Before live mainnet fork + +1. Community consensus on this release +2. Maintainer sets `staking_activation_height` (≥30 day notice per HIP-25) +3. Merge upstream PR to `hacash/rust` \ No newline at end of file diff --git a/docs/UPSTREAM_PR.md b/docs/UPSTREAM_PR.md new file mode 100644 index 0000000..2835dbf --- /dev/null +++ b/docs/UPSTREAM_PR.md @@ -0,0 +1,120 @@ +# Pull Request — HIP-25 Reference Implementation + +**Discussion PR (legacy repo):** https://github.com/hacash/rust/pull/13 + +**Canonical mainnet path:** [hacash/fullnodedev](https://github.com/hacash/fullnodedev) → [hacash/fullnode](https://github.com/hacash/fullnode/releases) per [HIP-12](https://github.com/hacash/doc/blob/main/HIP/development/HIP-12_Hacash_development_workflow_and_code_permission.pdf). + +This document is the PR body for **community review**. It is **not** a request to merge into inactive `hacash/rust` as the mainnet delivery vehicle. + +--- + +## PR Title + +``` +HIP-25 reference: HACD staking (34/35), v3 supply-neutral economics, WASM wallet +``` + +--- + +## PR Description + +### Summary + +**Reference implementation** of HIP-25 HACD staking on legacy Hacash Rust architecture ([Moskyera/rust](https://github.com/Moskyera/rust) fork). Intended for spec validation, audits, and future port to **fullnodedev**. + +**HIP-25 (actions 34/35)** +- Global reward index pool, **v3 supply-neutral economics** +- **10% DiamondMint miner share** (`fee_got`) → staking pool when active +- Inscription fees: **100% burn** (unchanged from pre-HIP-25) +- Idle pool burn after 1008 blocks with zero stakers +- Min stake ~90d (`25714` blocks), cooldown ~3d (`864` blocks) +- 24 staking unit tests + +**HIP-2 v2.1 (actions 15/16)** — on same branch, complementary +- HAC IOU mortgage: 1% origination burn, 3% flat APR, mutual exclusion with staking +- 15 unit + 9 E2E mortgage tests + +**Shared** +- Local WASM wallet at `/hip25/wallet` (client-side signing on mainnet) +- Mainnet RPC hardening (loopback default, rate limits, no server `prikey` on `chain_id=0`) + +### Economics v3 (current) + +| Source | v2 (superseded) | **v3** | +|--------|-----------------|--------| +| Staking pool | 10% inscription protocol fees | **10% DiamondMint miner share** | +| Inscription | Partial redirect to pool | **100% burn** | +| Supply vs baseline | Higher circulating HAC | **Unchanged** (reallocation only) | + +Details: `docs/HIP25_ECONOMICS_V3.md` · Formal spec: `docs/HIP25_SPEC.md` + +### Audit status + +| Audit | HIP-25 @ `a798094` | HIP-2 | +|-------|-------------------|-------| +| Auth & secrets | PASS | PASS | +| Economics | PASS | PASS | +| API & DoS | PASS | PASS | +| Tx pipeline | PASS | PASS | +| WASM wallet | PASS | PASS | + +**48** consensus tests pass. CI: `.github/workflows/hip25-ci.yml`. + +### Mainnet safety + +- `chain_id=0` blocks server-side `prikey` signing RPCs +- `hip25_testnet_seed` / demo flags **panic** on mainnet `chain_id` +- Default `listen_host=127.0.0.1`, `allow_public_rpc=false` +- P2P tx ingress: signature + `try_execute_tx` before relay + +### How to test + +**Testnet demo:** +```cmd +scripts\START_WALLET.bat +``` + +**Mainnet wallet model:** +```powershell +scripts\BUILD_MAINNET_RELEASE.ps1 +scripts\START_MAINNET_WALLET.bat +``` + +### Breaking changes + +None until `staking_activation_height` is reached on a network running this consensus. + +### Configuration + +`hacash_mainnet_hip25.config.ini.example`: + +```ini +[mint] +chain_id = 0 +staking_activation_height = 0 +hip25_testnet_seed = false +hip25_testnet_demo_periods = false +``` + +Activation height set only after community + maintainer consensus (≥30 day notice). + +### Documentation + +- **HIP-25 spec:** `docs/HIP25_SPEC.md` +- **Roadmap:** `docs/HIP25_ROADMAP.md` +- Community review: `docs/HIP25_COMMUNITY_REVIEW.md` +- Economics v3: `docs/HIP25_ECONOMICS_V3.md` + +### Request for maintainers + +1. Should HIP-25 be ported to **fullnodedev**? +2. Separate fork height or post-Istanbul (765432)? +3. Parameter changes before port? + +We treat this PR as **discussion + reference**, not legacy-rust mainnet merge. + +--- + +**Fork:** https://github.com/Moskyera/rust/tree/hip-25-staking +**Reference tag:** `v0.1.0-hip25-reference` +**Spec:** https://github.com/Moskyera/rust/blob/hip-25-staking/docs/HIP25_SPEC.md \ No newline at end of file diff --git a/docs/hip25_testnet_boot.md b/docs/hip25_testnet_boot.md new file mode 100644 index 0000000..20cd0ec --- /dev/null +++ b/docs/hip25_testnet_boot.md @@ -0,0 +1,127 @@ +# HIP-25 Public Testnet — Boot Instructions + +Local dev/testnet for **Pure HACD Staking** (HIP-25). Not a public internet testnet; run on your machine and open the wallet in a browser. + +## Prerequisites + +- Windows or Linux +- Rust toolchain (`cargo`, `rustc`) +- Built `hacash` binary: `cargo build` from repo root + +Config is loaded from the **directory containing `hacash.exe`**, typically `target/debug/`. + +## Quick start + +```powershell +cd target\debug +copy ..\..\hacash.config.ini.example hacash.config.ini +# Edit hacash.config.ini: hip25_testnet_seed = true, miner enable = true (see example) +.\hacash.exe +``` + +In a second terminal (same `target\debug` folder): + +```powershell +.\hacash.exe poworker +``` + +`poworker.config.ini` must contain `connect = 127.0.0.1:8083`. + +## Seeded test account (dev only) + +When `hip25_testnet_seed = true` in `[mint]`, block **1** seeds: + +| Field | Value | +|-------|-------| +| Password | `hip25test` | +| Address | `1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2` | +| Private key (hex) | `95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657` | +| HAC | `11` (11:244) | +| HACD | `WTYUIA`, `HXVMEK`, `VMEKBS`, `UIASHX`, `MEKUIA` | + +**Never use this key on mainnet.** + +## Wallet UI + +Open: **http://127.0.0.1:8083/hip25/wallet** + +1. Click **Fill HIP-25 testnet seed** +2. **Load portfolio** — five HACD with badges (`Available` / `Staked` / `Cooldown`) +3. Select diamonds → **Stake selected** or **Unstake selected** +4. **HIP-2 mortgage** — select **Available** HACD → loan auto-fills → **Open mortgage** (action 15); redeem with **Redeem HACD** (action 16) + +Tx modes: + +- **RPC** — `create/transaction` + `util/transaction/sign` + `submit/transaction` (works out of the box) +- **WASM SDK** — `hacd_stake` / `hacd_unstake` (requires built `pkg/hacash_sdk.js`; see below) + +## WASM SDK (optional) + +Build (Linux/macOS or Windows with `wasm32-unknown-unknown`): + +```bash +rustup target add wasm32-unknown-unknown +cargo build --release --features sdk --target wasm32-unknown-unknown --lib +# Or: wasm-pack build --target web --features sdk +``` + +Exported functions: + +- `hacd_stake(chain_id, password, "WTYUIA,HXVMEK", fee, timestamp)` → signed tx JSON +- `hacd_unstake(chain_id, password, diamonds, fee, timestamp)` → signed tx JSON +- `hacd_mortgage_open(chain_id, password, lending_id_hex, diamonds, loan, borrow_periods, fee, timestamp)` → action 15 +- `hacd_mortgage_redeem(chain_id, password, lending_id_hex, ransom, fee, timestamp)` → action 16 + +Submit `tx_body` hex via `POST /submit/transaction`. + +## Automated E2E + +From repo root: + +```powershell +.\scripts\hip25_smoke.ps1 +.\scripts\hip25_live_stake.ps1 +.\scripts\hip25_wallet_e2e.ps1 +``` + +## Staking RPC + +| Endpoint | Purpose | +|----------|---------| +| `GET /query/staking/status?diamond=WTYUIA` | Per-HACD status label | +| `GET /query/staking/summary?address=…` | Portfolio counts | +| `GET /query/staking/global` | Pool / activation height | +| `GET /query/staking/events?from=0&limit=20` | Stake/unstake events | + +Actions: **34** stake, **35** unstake. + +## Mortgage RPC (HIP-2 v2.1) + +| Endpoint | Purpose | +|----------|---------| +| `GET /query/mortgage/global` | Outstanding IOU, APR, `owner_index_max` (64) | +| `GET /query/mortgage/portfolio?address=…` | Active contracts; `indexed_count`, `owner_index_full` | +| `GET /query/mortgage/principal?diamonds=WTYUIA,HXVMEK` | Loan + origination burn quote | +| `GET /query/mortgage/contract?id=…&redeemer=…` | Min ransom / redemption phase | + +Actions: **15** mortgage open, **16** mortgage redeem. + +**64-contract limit:** each address may have at most **64** active mortgage contracts indexed on-chain (`MORTGAGE_OWNER_INDEX_MAX`). The wallet and `mortgage/portfolio` RPC surface `owner_index_full` when the limit is reached; opening another contract fails with `mortgage owner contract index full`. + +**Testnet economics example:** 1 HACD → loan `1:250`, origination burn ~`1:249` (1%); 2 HACD → loan `2:250`, burn ~`2:248`. Portfolio address and signing password must match. + +## Consensus parameters (v1) + +- Staking economics: **v3** — **10% of HACD mint bid fee** (miner share) → stakers; inscription fees **fully burn** +- `MIN_STAKE_BLOCKS = 25714` +- `COOLDOWN_BLOCKS = 864` +- `staking_activation_height` in `[mint]` (testnet: `1`) + +## One fullnode per data dir + +LevelDB locks the data directory. Stop other `hacash.exe` instances before starting a fresh testnet. + +## Reference + +- HIP: `hacash-hip25/HIP/HIP-25_Pure_HACD_Staking.md` +- Branch: `hip-25-staking` on https://github.com/Moskyera/rust \ No newline at end of file diff --git a/hacash.config.ini.example b/hacash.config.ini.example new file mode 100644 index 0000000..36cac17 --- /dev/null +++ b/hacash.config.ini.example @@ -0,0 +1,34 @@ +; HIP-25 local testnet / dev fullnode — copy to hacash.config.ini beside the binary +[default] +data_dir = hacash_hip25_testnet_data + +[server] +enable = true +listen = 8083 +; HIP-25 wallet MVP UI (same origin as RPC): http://127.0.0.1:8083/hip25/wallet +recent_blocks = false +average_fee_purity = false + +[node] +listen = 3338 +not_find_nodes = true +boots = + +[mint] +chain_id = 1 +; HIP-25 soft-fork activation height (consensus parameter, written to chain at genesis init). +; Dev/testnet: 1 (active from first block). Mainnet: set announced height (30-day timelock). +staking_activation_height = 1 +; Dev only: seed 5 HACD + 11 HAC to password-derived account at block 1 init. +; HACD: WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA — password hip25test → 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 +hip25_testnet_seed = false +hip25_testnet_seed_password = hip25test +; Unstake demo only: min_stake=5, cooldown=3 blocks (NEVER enable on mainnet) +; Requires chain_id = 1 (HIP25_DEV_CHAIN_ID) — node refuses to start otherwise. +hip25_testnet_demo_periods = false + +[miner] +enable = false + +[diamondminer] +enable = false \ No newline at end of file diff --git a/hacash_mainnet_hip25.config.ini.example b/hacash_mainnet_hip25.config.ini.example new file mode 100644 index 0000000..ff87b4d --- /dev/null +++ b/hacash_mainnet_hip25.config.ini.example @@ -0,0 +1,36 @@ +; HIP-25 mainnet fullnode template — copy beside hacash binary as hacash.config.ini +; Dev/testnet flags MUST stay false; node panics at startup if enabled with chain_id != 1. +[default] +data_dir = hacash_mainnet_data + +[server] +enable = true +listen = 8081 +listen_host = 127.0.0.1 +allow_public_rpc = false +recent_blocks = false +average_fee_purity = false + +[node] +listen = 3333 +not_find_nodes = false +boots = + +[mint] +; Hacash L1 mainnet sub-chain id (0). HIP25_DEV_CHAIN_ID = 1 is local testnet only. +chain_id = 0 +; Announce activation height ≥30 days before fork (HIP-25 timelock). Replace 0 with agreed height. +staking_activation_height = 0 +hip25_testnet_seed = false +hip25_testnet_seed_password = hip25test +hip25_testnet_demo_periods = false +; HIP-2 v2.1 mortgage — 0 = disabled until governance sets height (≥30 day notice). +mortgage_activation_height = 0 +mortgage_max_outstanding_zhu = 0 +hip2_testnet_demo_periods = false + +[miner] +enable = false + +[diamondminer] +enable = false \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..bb77af5 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.88.0" +components = ["rustfmt", "clippy"] \ No newline at end of file diff --git a/scripts/BUILD_MAINNET_RELEASE.ps1 b/scripts/BUILD_MAINNET_RELEASE.ps1 new file mode 100644 index 0000000..b864388 --- /dev/null +++ b/scripts/BUILD_MAINNET_RELEASE.ps1 @@ -0,0 +1,55 @@ +# HIP-25 mainnet release build: hacash.exe (release) + WASM SDK + integrity manifest +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$ReleaseDir = Join-Path $Root "target\release" +$PkgDir = Join-Path $ReleaseDir "pkg" +$WalletPkg = Join-Path $Root "wallet\hip25\pkg" + +Write-Host "=== HIP-25 Mainnet Release Build ===" -ForegroundColor Cyan +$cmakeBin = "C:\Program Files\CMake\bin" +if ((Test-Path $cmakeBin) -and ($env:PATH -notlike "*$cmakeBin*")) { + $env:PATH = "$cmakeBin;$env:PATH" +} +Set-Location $Root + +Write-Host "[1/3] cargo build --release ..." -ForegroundColor Yellow +cargo build --release +if ($LASTEXITCODE -ne 0) { throw "cargo build --release failed" } + +Write-Host "[2/3] WASM SDK (wasm32) ..." -ForegroundColor Yellow +$prevEap = $ErrorActionPreference +$ErrorActionPreference = "Continue" +rustup target add wasm32-unknown-unknown 2>&1 | Out-Null +$ErrorActionPreference = $prevEap +cargo build --features sdk --target wasm32-unknown-unknown --release --lib +if ($LASTEXITCODE -ne 0) { throw "WASM lib build failed" } + +$WasmSrc = Join-Path $Root "target\wasm32-unknown-unknown\release\hacash_sdk.wasm" +if (-not (Test-Path $WasmSrc)) { throw "WASM artifact missing: $WasmSrc" } + +New-Item -ItemType Directory -Force -Path $PkgDir, $WalletPkg | Out-Null +wasm-bindgen $WasmSrc --out-dir $PkgDir --target no-modules --no-typescript + +Copy-Item (Join-Path $PkgDir "hacash_sdk.js") (Join-Path $WalletPkg "hacash_sdk.js") -Force +Copy-Item (Join-Path $PkgDir "hacash_sdk_bg.wasm") (Join-Path $WalletPkg "hacash_sdk_bg.wasm") -Force + +function Get-Sha256Hex([string]$Path) { + (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +$jsHash = Get-Sha256Hex (Join-Path $PkgDir "hacash_sdk.js") +$wasmHash = Get-Sha256Hex (Join-Path $PkgDir "hacash_sdk_bg.wasm") +$manifest = @{ "hacash_sdk.js" = $jsHash; "hacash_sdk_bg.wasm" = $wasmHash } | ConvertTo-Json -Compress +$manifest | Set-Content -Encoding UTF8 (Join-Path $PkgDir "integrity.json") +$manifest | Set-Content -Encoding UTF8 (Join-Path $WalletPkg "integrity.json") + +Write-Host "[3/3] Copy mainnet config template beside binary ..." -ForegroundColor Yellow +Copy-Item (Join-Path $Root "hacash_mainnet_hip25.config.ini.example") (Join-Path $ReleaseDir "hacash_mainnet_hip25.config.ini.example") -Force + +Write-Host "" +Write-Host "OK Release ready:" -ForegroundColor Green +Write-Host " Binary: $ReleaseDir\hacash.exe" +Write-Host " WASM: $PkgDir\" +Write-Host " Config: $ReleaseDir\hacash_mainnet_hip25.config.ini.example" +Write-Host "" +Write-Host "Next: scripts\START_MAINNET_WALLET.bat" -ForegroundColor Yellow \ No newline at end of file diff --git a/scripts/OPEN_UPSTREAM_PR.ps1 b/scripts/OPEN_UPSTREAM_PR.ps1 new file mode 100644 index 0000000..edf5afc --- /dev/null +++ b/scripts/OPEN_UPSTREAM_PR.ps1 @@ -0,0 +1,5 @@ +# Opens GitHub compare page to create upstream PR (hacash/rust <- Moskyera/rust hip-25-staking) +$Url = "https://github.com/hacash/rust/compare/main...Moskyera:rust:hip-25-staking?expand=1" +Write-Host "Opening upstream PR compare:" $Url -ForegroundColor Cyan +Write-Host "Copy PR body from: docs\UPSTREAM_PR.md" -ForegroundColor Yellow +Start-Process $Url \ No newline at end of file diff --git a/scripts/START_MAINNET_WALLET.bat b/scripts/START_MAINNET_WALLET.bat new file mode 100644 index 0000000..a5a2c2a --- /dev/null +++ b/scripts/START_MAINNET_WALLET.bat @@ -0,0 +1,95 @@ +@echo off +title HIP-25 Mainnet Wallet Launcher +setlocal EnableDelayedExpansion + +set "ROOT=%~dp0.." +set "BINDIR=%ROOT%\target\release" +if not exist "%BINDIR%\hacash.exe" set "BINDIR=%ROOT%\target\debug" + +cd /d "%BINDIR%" + +if not exist hacash.exe ( + echo. + echo hacash.exe not found. Build release first: + echo cd %ROOT% + echo powershell -ExecutionPolicy Bypass -File scripts\BUILD_MAINNET_RELEASE.ps1 + echo. + pause + exit /b 1 +) + +if not exist pkg\hacash_sdk.js ( + echo. + echo WASM SDK missing in %BINDIR%\pkg + echo Run: powershell -ExecutionPolicy Bypass -File scripts\BUILD_MAINNET_RELEASE.ps1 + echo. + pause + exit /b 1 +) + +if not exist pkg\integrity.json ( + echo. + echo WARNING: pkg\integrity.json missing — run BUILD_MAINNET_RELEASE.ps1 for hash verification. + echo. +) + +taskkill /IM hacash.exe /F >nul 2>&1 +timeout /t 2 /nobreak >nul + +if not exist hacash.config.ini ( + echo Creating hacash.config.ini from mainnet template... + copy /Y "%ROOT%\hacash_mainnet_hip25.config.ini.example" hacash.config.ini >nul +) else ( + echo Using existing hacash.config.ini ^(NOT overwritten — edit for your node^). +) + +echo. +echo MAINNET SAFETY CHECK: +echo - chain_id must be 0 +echo - hip25_testnet_seed must be false +echo - listen_host must be 127.0.0.1 +echo - allow_public_rpc must be false +echo - staking_activation_height = agreed fork height ^(NOT 0 for new genesis^) +echo - data_dir must point to SYNCED mainnet chain ^(never delete^) +echo. +pause + +echo [1/2] Starting HIP25-MAINNET-FULLNODE... +start "HIP25-MAINNET-FULLNODE" cmd /k "cd /d %cd% && title HIP25-MAINNET-FULLNODE && hacash.exe" + +echo [2/2] Waiting for RPC on port 8081 ^(max 120s^)... +set /a tries=0 +:waitrpc +set /a tries+=1 +if !tries! gtr 120 goto rpcfail +timeout /t 1 /nobreak >nul +powershell -NoProfile -Command "try { $r = Invoke-RestMethod 'http://127.0.0.1:8081/query/latest' -TimeoutSec 3; if ($r.chain_id -eq 0 -and $r.hip25_dev -eq $false) { exit 0 } else { Write-Host 'WARN: chain_id=' $r.chain_id ' hip25_dev=' $r.hip25_dev; exit 2 } } catch { exit 1 }" >nul 2>&1 +if errorlevel 1 goto waitrpc +if errorlevel 2 ( + echo RPC up but NOT mainnet mode — check hacash.config.ini + goto openwallet +) +echo Mainnet RPC ready ^(chain_id=0^). + +:openwallet +start http://127.0.0.1:8081/hip25/wallet + +echo. +echo ======================================== +echo MAINNET WALLET: http://127.0.0.1:8081/hip25/wallet +echo ======================================== +echo. +echo KEEP OPEN: HIP25-MAINNET-FULLNODE window +echo NO poworker / NO testnet seed on mainnet +echo Sign stake/unstake locally via WASM only +echo. +pause +exit /b 0 + +:rpcfail +echo. +echo RPC did not start on :8081. Check HIP25-MAINNET-FULLNODE window. +echo Ensure data_dir has synced mainnet chain data. +echo. +pause +exit /b 1 \ No newline at end of file diff --git a/scripts/START_WALLET.bat b/scripts/START_WALLET.bat new file mode 100644 index 0000000..cb45008 --- /dev/null +++ b/scripts/START_WALLET.bat @@ -0,0 +1,110 @@ +@echo off +title HIP-25 Wallet Launcher +cd /d "%~dp0..\target\debug" + +if not exist hacash.exe ( + echo. + echo hacash.exe not found. Build first: + echo cd C:\Users\KQHEX\Documents\hacash-rust + echo cargo build + echo. + pause + exit /b 1 +) + +taskkill /IM hacash.exe /F >nul 2>&1 +timeout /t 2 /nobreak >nul + +if exist hacash_hip25_demo ( + echo Removing old chain data ^(fresh testnet seed^)... + rmdir /s /q hacash_hip25_demo +) + +( +echo [default] +echo data_dir = hacash_hip25_demo +echo [server] +echo enable = true +echo listen = 8083 +echo listen_host = 127.0.0.1 +echo allow_public_rpc = false +echo recent_blocks = false +echo average_fee_purity = false +echo [node] +echo listen = 3338 +echo not_find_nodes = true +echo boots = +echo [mint] +echo chain_id = 1 +echo staking_activation_height = 1 +echo hip25_testnet_seed = true +echo hip25_testnet_seed_password = hip25test +echo hip25_testnet_demo_periods = true +echo mortgage_activation_height = 1 +echo mortgage_max_outstanding_zhu = 0 +echo hip2_testnet_demo_periods = true +echo [miner] +echo enable = true +echo reward = 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 +echo message = hip25wallet +echo [diamondminer] +echo enable = false +) > hacash.config.ini + +( +echo [default] +echo connect = 127.0.0.1:8083 +echo supervene = 4 +echo nonce_max = 4294967295 +echo notice_wait = 3 +) > poworker.config.ini + +echo. +echo [1/3] Starting HIP25-FULLNODE... +start "HIP25-FULLNODE" cmd /k "cd /d %cd% && title HIP25-FULLNODE && hacash.exe" + +echo [2/3] Waiting for RPC (max 60s)... +set /a tries=0 +:waitrpc +set /a tries+=1 +if %tries% gtr 60 goto rpcfail +timeout /t 1 /nobreak >nul +powershell -NoProfile -Command "try { Invoke-RestMethod 'http://127.0.0.1:8083/query/latest' -TimeoutSec 2 | Out-Null; exit 0 } catch { exit 1 }" >nul 2>&1 +if errorlevel 1 goto waitrpc +echo RPC ready. + +echo [3/4] Starting HIP25-POWORKER... +start "HIP25-POWORKER" cmd /k "cd /d %cd% && title HIP25-POWORKER && hacash.exe poworker" + +echo [4/4] Waiting for block 1 ^(HACD seed, max 90s^)... +set /a tries=0 +:waitblock +set /a tries+=1 +if %tries% gtr 90 goto blockwarn +timeout /t 1 /nobreak >nul +powershell -NoProfile -Command "try { $r = Invoke-RestMethod 'http://127.0.0.1:8083/query/latest' -TimeoutSec 2; if ([int]$r.height -ge 1) { exit 0 } else { exit 1 } } catch { exit 1 }" >nul 2>&1 +if errorlevel 1 goto waitblock +echo Block 1 ready — 5 HACD seeded. +goto openwallet +:blockwarn +echo Block 1 not mined yet — wallet will auto-retry. +:openwallet +start http://127.0.0.1:8083/hip25/wallet + +echo. +echo ======================================== +echo WALLET: http://127.0.0.1:8083/hip25/wallet +echo ======================================== +echo. +echo KEEP OPEN: HIP25-FULLNODE + HIP25-POWORKER +echo In wallet: Fill testnet seed -^> Load portfolio +echo. +pause +exit /b 0 + +:rpcfail +echo. +echo RPC did not start. Check HIP25-FULLNODE window for errors. +echo. +pause +exit /b 1 \ No newline at end of file diff --git a/scripts/build_wallet_sdk.ps1 b/scripts/build_wallet_sdk.ps1 new file mode 100644 index 0000000..4564a07 --- /dev/null +++ b/scripts/build_wallet_sdk.ps1 @@ -0,0 +1,43 @@ +# Build HIP-25 WASM SDK and copy beside hacash.exe for /pkg/* routes +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$PkgDir = Join-Path $BinDir "pkg" +$WalletPkg = Join-Path $Root "wallet\hip25\pkg" + +Write-Host "Building hacash_sdk (wasm32)..." -ForegroundColor Cyan +Set-Location $Root +rustup target add wasm32-unknown-unknown 2>&1 | Out-Null +cargo build --features sdk --target wasm32-unknown-unknown --release --lib + +$WasmSrc = Join-Path $Root "target\wasm32-unknown-unknown\release\hacash_sdk.wasm" +if (-not (Test-Path $WasmSrc)) { throw "WASM build failed: $WasmSrc" } + +New-Item -ItemType Directory -Force -Path $PkgDir | Out-Null +New-Item -ItemType Directory -Force -Path $WalletPkg | Out-Null + +# no-modules: exposes global wasm_bindgen for /hip25/wallet classic script tag +wasm-bindgen $WasmSrc --out-dir $PkgDir --target no-modules --no-typescript + +Copy-Item (Join-Path $PkgDir "hacash_sdk.js") (Join-Path $WalletPkg "hacash_sdk.js") -Force +Copy-Item (Join-Path $PkgDir "hacash_sdk_bg.wasm") (Join-Path $WalletPkg "hacash_sdk_bg.wasm") -Force + +function Get-Sha256Hex([string]$Path) { + $hash = Get-FileHash -Path $Path -Algorithm SHA256 + return $hash.Hash.ToLowerInvariant() +} + +$jsHash = Get-Sha256Hex (Join-Path $PkgDir "hacash_sdk.js") +$wasmHash = Get-Sha256Hex (Join-Path $PkgDir "hacash_sdk_bg.wasm") +$manifest = @{ + "hacash_sdk.js" = $jsHash + "hacash_sdk_bg.wasm" = $wasmHash +} | ConvertTo-Json -Compress + +$manifest | Set-Content -Encoding UTF8 (Join-Path $PkgDir "integrity.json") +$manifest | Set-Content -Encoding UTF8 (Join-Path $WalletPkg "integrity.json") + +Write-Host "OK: pkg copied to" $PkgDir -ForegroundColor Green +Write-Host " and" $WalletPkg -ForegroundColor Green +Write-Host "integrity.json written (SHA-256 verified on serve)" -ForegroundColor Green +Write-Host "Restart fullnode, then open /hip25/wallet" -ForegroundColor Yellow \ No newline at end of file diff --git a/scripts/hip25_live_stake.ps1 b/scripts/hip25_live_stake.ps1 new file mode 100644 index 0000000..a2839ee --- /dev/null +++ b/scripts/hip25_live_stake.ps1 @@ -0,0 +1,204 @@ +# HIP-25 live testnet: mine blocks, stake seeded HACD WTYUIA, verify RPC +param( + [string]$Base = "http://127.0.0.1:8083", + [string]$DataDir = "hacash_hip25_live_data", + [string]$SeedPassword = "hip25test", + [string]$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + [string]$SeedPrikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657", + [int]$MineBlocksBeforeStake = 1, + [int]$MineBlocksAfterStake = 2 +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +Set-Location $BinDir + +function Invoke-HacGet($Path, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + return Invoke-RestMethod -Uri $uri -TimeoutSec 30 +} + +function Invoke-HacPost($Path, $Body, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + $bytes = if ($Body -is [byte[]]) { $Body } else { [System.Text.Encoding]::UTF8.GetBytes([string]$Body) } + $req = [System.Net.HttpWebRequest]::Create($uri) + $req.Method = "POST" + $req.ContentType = "application/octet-stream" + $req.ContentLength = $bytes.Length + $stream = $req.GetRequestStream() + $stream.Write($bytes, 0, $bytes.Length) + $stream.Close() + $resp = $req.GetResponse() + $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) + return ($reader.ReadToEnd() | ConvertFrom-Json) +} + +function Wait-RpcReady() { + for ($i = 0; $i -lt 30; $i++) { + try { + $null = Invoke-HacGet "query/latest" + return + } catch { + Start-Sleep -Seconds 1 + } + } + throw "RPC not ready on $Base" +} + +function Wait-Height($Target) { + for ($i = 0; $i -lt 180; $i++) { + $latest = Invoke-HacGet "query/latest" + if ($latest.height -ge $Target) { return $latest.height } + Start-Sleep -Seconds 2 + } + throw "Timeout waiting for height >= $Target" +} + +Write-Host "=== HIP-25 live stake E2E ===" -ForegroundColor Cyan + +# Fresh chain data +if (Test-Path $DataDir) { + Write-Host "Removing old data dir $DataDir ..." + Remove-Item -Recurse -Force $DataDir +} + +# Sync config beside binary (load_config uses exe directory, not cwd) +$cfg = @" +; HIP-25 live stake run +[default] +data_dir = $DataDir + +[server] +enable = true +listen = 8083 +recent_blocks = false +average_fee_purity = false + +[node] +listen = 3338 +not_find_nodes = true +boots = + +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = $SeedPassword + +[miner] +enable = true +reward = $SeedAddress +message = hip25testnet + +[diamondminer] +enable = false +"@ +Set-Content "hacash.config.ini" $cfg +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 1 + +Write-Host "Starting fullnode..." +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +Wait-RpcReady +Write-Host "[OK] fullnode RPC ready" + +Write-Host "Starting poworker..." +$miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru +Start-Sleep -Seconds 2 + +try { + Write-Host "Mining $MineBlocksBeforeStake block(s) (genesis init + seed HACD on block 1)..." + Wait-Height $MineBlocksBeforeStake | Out-Null + + # Submit stake before poworker mines block 2 (avoids mempool / miner_notice stall) + $ts = 1718496000 + $txJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":34,`"diamonds`":`"WTYUIA`"}]}" + + $built = Invoke-HacPost "create/transaction" $txJson @{ action = "true"; signature = "true" } + if ($built.ret -ne 0) { throw "create/transaction: $($built.err)" } + Write-Host "[OK] built stake tx hash=$($built.hash)" + + $txBytes = [byte[]]::new($built.body.Length / 2) + for ($i = 0; $i -lt $txBytes.Length; $i++) { + $txBytes[$i] = [Convert]::ToByte($built.body.Substring($i * 2, 2), 16) + } + $signed = Invoke-HacPost "util/transaction/sign" $txBytes @{ prikey = $SeedPrikey; signature = "true"; action = "true" } + if ($signed.ret -ne 0) { throw "transaction/sign: $($signed.err)" } + Write-Host "[OK] signed tx" + + $signedBytes = [byte[]]::new($signed.body.Length / 2) + for ($i = 0; $i -lt $signedBytes.Length; $i++) { + $signedBytes[$i] = [Convert]::ToByte($signed.body.Substring($i * 2, 2), 16) + } + $submitted = Invoke-HacPost "submit/transaction" $signedBytes @{} + if ($submitted.ret -ne 0) { throw "submit/transaction: $($submitted.err)" } + Write-Host "[OK] submitted tx hash=$($submitted.hash)" + + $global = Invoke-HacGet "query/staking/global" + if ($global.ret -ne 0) { throw "staking/global failed" } + Write-Host "[OK] activation_height=$($global.activation_height) shares=$($global.total_staked_shares)" + + $bal = Invoke-HacGet "query/balance" @{ address = $SeedAddress; diamonds = "true" } + $entry = if ($bal.list) { $bal.list[0] } else { $null } + Write-Host "[OK] seed balance HAC=$($entry.hacash) diamonds=$($entry.diamonds)" + if ($entry.diamonds.Length -lt 30) { + throw "Expected 5 seeded HACD (30 chars), got: $($entry.diamonds)" + } + + Write-Host "Waiting for on-chain stake confirmation..." + $status = $null + $lastHeight = -1 + $stuck = 0 + for ($i = 0; $i -lt 120; $i++) { + try { $null = Invoke-HacGet "query/miner/pending?stuff=true" } catch {} + $latest = Invoke-HacGet "query/latest" + $status = Invoke-HacGet "query/staking/status" @{ diamond = "WTYUIA" } + if ($status.status -eq "Staked") { + Write-Host "[OK] confirmed at height=$($latest.height)" + break + } + if ($latest.height -eq $lastHeight) { + $stuck++ + if ($stuck -ge 8) { + Write-Host "[INFO] mining stalled at height=$lastHeight, restarting poworker..." + Get-Process hacash -ErrorAction SilentlyContinue | + Where-Object { $_.Id -ne $node.Id } | + Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + $miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru + $stuck = 0 + } + } else { + $lastHeight = $latest.height + $stuck = 0 + } + Start-Sleep -Seconds 2 + } + if ($status.ret -ne 0) { throw "staking/status: $($status.err)" } + Write-Host "[OK] WTYUIA status=$($status.status) staker=$($status.staker) stake_height=$($status.stake_height)" + + if ($status.status -ne "Staked") { + throw "Expected status=Staked, got $($status.status)" + } + + $events = Invoke-HacGet "query/staking/events" @{ from = 0; limit = 10 } + Write-Host "[OK] staking events total=$($events.total)" + + Write-Host "" + Write-Host "Live stake E2E passed." -ForegroundColor Green +} finally { + if ($miner -and -not $miner.HasExited) { Stop-Process -Id $miner.Id -Force -ErrorAction SilentlyContinue } + if ($node -and -not $node.HasExited) { Stop-Process -Id $node.Id -Force -ErrorAction SilentlyContinue } +} \ No newline at end of file diff --git a/scripts/hip25_smoke.ps1 b/scripts/hip25_smoke.ps1 new file mode 100644 index 0000000..080bf7d --- /dev/null +++ b/scripts/hip25_smoke.ps1 @@ -0,0 +1,35 @@ +# HIP-25 testnet smoke test — run while fullnode listens on 8083 +# Note: hacash loads hacash.config.ini from target\debug\ (exe dir). Sync before run: +# Copy-Item ..\hacash.config.ini .\hacash.config.ini +$Base = "http://127.0.0.1:8083" +$Rqid = "hip25smoke$(Get-Random)" + +function Get-HacQuery($path) { + $uri = "$Base/query/$path" + $(if ($path -match '\?') { "&" } else { "?" }) + "rqid=$Rqid" + Invoke-RestMethod -Uri $uri -TimeoutSec 10 +} + +Write-Host "=== HIP-25 smoke test ===" -ForegroundColor Cyan + +try { + $global = Get-HacQuery "staking/global" + if ($global.ret -ne 0) { throw "staking/global ret=$($global.ret)" } + Write-Host "[OK] staking/global" + Write-Host " activation_height=$($global.activation_height) shares=$($global.total_staked_shares) pool=$($global.reward_pool_pending_zhu) events=$($global.event_count)" + + $latest = Get-HacQuery "latest" + Write-Host "[OK] latest height=$($latest.height)" + + $events = Get-HacQuery "staking/events?from=0&limit=5" + Write-Host "[OK] staking/events total=$($events.total)" + + $addr = "12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi" + $bal = Get-HacQuery "balance?address=$addr" + Write-Host "[OK] balance $addr = $($bal.balance)" + + Write-Host "" + Write-Host "Smoke RPC checks passed." -ForegroundColor Green +} catch { + Write-Host "[FAIL] $_" -ForegroundColor Red + exit 1 +} \ No newline at end of file diff --git a/scripts/hip25_unstake_demo.ps1 b/scripts/hip25_unstake_demo.ps1 new file mode 100644 index 0000000..703c0b4 --- /dev/null +++ b/scripts/hip25_unstake_demo.ps1 @@ -0,0 +1,154 @@ +# HIP-25 unstake demo: short periods (min_stake=5, cooldown=3) on fresh testnet chain +param( + [string]$Base = "http://127.0.0.1:8083", + [string]$DataDir = "hacash_hip25_unstake_demo", + [string]$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + [string]$SeedPrikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657", + [string]$Diamond = "MEKUIA" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +Set-Location $BinDir + +function Invoke-HacGet($Path, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + return Invoke-RestMethod -Uri $uri -TimeoutSec 30 +} + +function Invoke-HacPost($Path, $Body, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + $bytes = if ($Body -is [byte[]]) { $Body } else { [System.Text.Encoding]::UTF8.GetBytes([string]$Body) } + $req = [System.Net.HttpWebRequest]::Create($uri) + $req.Method = "POST" + $req.ContentType = "application/octet-stream" + $req.ContentLength = $bytes.Length + $stream = $req.GetRequestStream() + $stream.Write($bytes, 0, $bytes.Length) + $stream.Close() + $resp = $req.GetResponse() + $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) + return ($reader.ReadToEnd() | ConvertFrom-Json) +} + +function Submit-StakeTx($kind, $diamonds) { + $ts = 1718496000 + $txJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":$kind,`"diamonds`":`"$diamonds`"}]}" + $built = Invoke-HacPost "create/transaction" $txJson @{ action = "true"; signature = "true" } + if ($built.ret -ne 0) { throw "create: $($built.err)" } + $txBytes = [byte[]]::new($built.body.Length / 2) + for ($i = 0; $i -lt $txBytes.Length; $i++) { $txBytes[$i] = [Convert]::ToByte($built.body.Substring($i * 2, 2), 16) } + $signed = Invoke-HacPost "util/transaction/sign" $txBytes @{ prikey = $SeedPrikey; signature = "true"; action = "true" } + if ($signed.ret -ne 0) { throw "sign: $($signed.err)" } + $signedBytes = [byte[]]::new($signed.body.Length / 2) + for ($i = 0; $i -lt $signedBytes.Length; $i++) { $signedBytes[$i] = [Convert]::ToByte($signed.body.Substring($i * 2, 2), 16) } + $sub = Invoke-HacPost "submit/transaction" $signedBytes @{} + if ($sub.ret -ne 0) { throw "submit: $($sub.err)" } + return $sub.hash +} + +function Wait-Status($diamond, $want, $timeout = 120) { + for ($i = 0; $i -lt $timeout; $i++) { + $st = Invoke-HacGet "query/staking/status" @{ diamond = $diamond } + if ($st.status -eq $want) { return $st } + Start-Sleep -Seconds 2 + } + throw "Timeout: $diamond not $want" +} + +Write-Host "=== HIP-25 UNSTAKE DEMO ===" -ForegroundColor Cyan + +if (Test-Path $DataDir) { Remove-Item -Recurse -Force $DataDir } + +$cfg = @" +[default] +data_dir = $DataDir +[server] +enable = true +listen = 8083 +[node] +listen = 3338 +not_find_nodes = true +boots = +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = hip25test +hip25_testnet_demo_periods = true +[miner] +enable = true +reward = $SeedAddress +[diamondminer] +enable = false +"@ +Set-Content "hacash.config.ini" $cfg +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 2 + +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +for ($i = 0; $i -lt 30; $i++) { + try { $null = Invoke-HacGet "query/latest"; break } catch { Start-Sleep -Seconds 1 } +} +$miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru +Start-Sleep -Seconds 2 + +try { + for ($i = 0; $i -lt 60; $i++) { + if ((Invoke-HacGet "query/latest").height -ge 1) { break } + Start-Sleep -Seconds 2 + } + + Write-Host "[1] Stake $Diamond..." -ForegroundColor Yellow + $h1 = Submit-StakeTx 34 $Diamond + Write-Host " tx $h1" + $st = Wait-Status $Diamond "Staked" + Write-Host " badge: Staked at height $($st.stake_height) min_unstake=$($st.min_unstake_height)" -ForegroundColor Green + + Write-Host "[2] Wait until min unstake height..." -ForegroundColor Yellow + for ($i = 0; $i -lt 90; $i++) { + $h = (Invoke-HacGet "query/latest").height + $st = Invoke-HacGet "query/staking/status" @{ diamond = $Diamond } + if ($h -ge $st.min_unstake_height) { Write-Host " height=$h >= min_unstake=$($st.min_unstake_height)" -ForegroundColor Green; break } + Start-Sleep -Seconds 2 + } + + Write-Host "[3] Unstake $Diamond (kind 35)..." -ForegroundColor Yellow + $h2 = Submit-StakeTx 35 $Diamond + Write-Host " tx $h2" + $st2 = Wait-Status $Diamond "Cooldown" + Write-Host " badge: Cooldown unlock_height=$($st2.unlock_height)" -ForegroundColor Magenta + + Write-Host "[4] Wait cooldown (3 blocks)..." -ForegroundColor Yellow + for ($i = 0; $i -lt 90; $i++) { + $h = (Invoke-HacGet "query/latest").height + if ($h -ge $st2.unlock_height) { break } + Start-Sleep -Seconds 2 + } + $st3 = Wait-Status $Diamond "Available" + Write-Host " badge: Available (unstake complete)" -ForegroundColor Green + + $events = Invoke-HacGet "query/staking/events" @{ from = 0; limit = 20 } + Write-Host "[5] Events: $($events.total) total" -ForegroundColor Green + foreach ($ev in $events.events) { + if ($ev.literal -eq $Diamond) { Write-Host " $($ev.kind) h=$($ev.height)" } + } + + Write-Host "" + Write-Host "UNSTAKE DEMO PASSED. Wallet: $Base/hip25/wallet" -ForegroundColor Green +} finally { + if ($miner -and -not $miner.HasExited) { Stop-Process -Id $miner.Id -Force -EA SilentlyContinue } + if ($node -and -not $node.HasExited) { Stop-Process -Id $node.Id -Force -EA SilentlyContinue } +} \ No newline at end of file diff --git a/scripts/hip25_wallet_e2e.ps1 b/scripts/hip25_wallet_e2e.ps1 new file mode 100644 index 0000000..0739415 --- /dev/null +++ b/scripts/hip25_wallet_e2e.ps1 @@ -0,0 +1,168 @@ +# HIP-25 wallet E2E: verify seeded HACD list + stake one diamond + badge labels +param( + [string]$Base = "http://127.0.0.1:8083", + [string]$DataDir = "hacash_hip25_wallet_data", + [string]$SeedPassword = "hip25test", + [string]$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + [string]$SeedPrikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657", + [string]$StakeDiamond = "HXVMEK" +) + +$ErrorActionPreference = "Stop" +$ExpectedDiamonds = @("WTYUIA", "HXVMEK", "VMEKBS", "UIASHX", "MEKUIA") +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +Set-Location $BinDir + +function Invoke-HacGet($Path, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + return Invoke-RestMethod -Uri $uri -TimeoutSec 30 +} + +function Invoke-HacPost($Path, $Body, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + $bytes = if ($Body -is [byte[]]) { $Body } else { [System.Text.Encoding]::UTF8.GetBytes([string]$Body) } + $req = [System.Net.HttpWebRequest]::Create($uri) + $req.Method = "POST" + $req.ContentType = "application/octet-stream" + $req.ContentLength = $bytes.Length + $stream = $req.GetRequestStream() + $stream.Write($bytes, 0, $bytes.Length) + $stream.Close() + $resp = $req.GetResponse() + $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) + return ($reader.ReadToEnd() | ConvertFrom-Json) +} + +function Wait-RpcReady() { + for ($i = 0; $i -lt 30; $i++) { + try { $null = Invoke-HacGet "query/latest"; return } catch { Start-Sleep -Seconds 1 } + } + throw "RPC not ready" +} + +function Wait-Height($Target) { + for ($i = 0; $i -lt 120; $i++) { + $latest = Invoke-HacGet "query/latest" + if ($latest.height -ge $Target) { return $latest.height } + Start-Sleep -Seconds 2 + } + throw "Timeout height >= $Target" +} + +Write-Host "=== HIP-25 wallet E2E (5 HACD + labels) ===" -ForegroundColor Cyan + +if (Test-Path $DataDir) { Remove-Item -Recurse -Force $DataDir } + +$cfg = @" +[default] +data_dir = $DataDir +[server] +enable = true +listen = 8083 +recent_blocks = false +average_fee_purity = false +[node] +listen = 3338 +not_find_nodes = true +boots = +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = $SeedPassword +[miner] +enable = true +reward = $SeedAddress +message = hip25wallet +[diamondminer] +enable = false +"@ +Set-Content "hacash.config.ini" $cfg +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 1 + +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +Wait-RpcReady +$miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru +Start-Sleep -Seconds 2 + +try { + Wait-Height 1 | Out-Null + + $bal = Invoke-HacGet "query/balance" @{ address = $SeedAddress; diamonds = "true" } + $entry = $bal.list[0] + $raw = $entry.diamonds -replace "\s", "" + $found = @() + for ($i = 0; $i -lt $raw.Length; $i += 6) { $found += $raw.Substring($i, 6) } + $found = $found | Sort-Object + $expected = $ExpectedDiamonds | Sort-Object + if (($found -join ",") -ne ($expected -join ",")) { + throw "Expected diamonds $($expected -join ',') but got $($found -join ',')" + } + Write-Host "[OK] balance lists 5 seeded HACD" -ForegroundColor Green + + foreach ($d in $ExpectedDiamonds) { + $st = Invoke-HacGet "query/staking/status" @{ diamond = $d } + if ($st.status -ne "Available") { throw "$d expected Available, got $($st.status)" } + } + Write-Host "[OK] all 5 badges = Available before stake" -ForegroundColor Green + + $ts = 1718496000 + $txJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":34,`"diamonds`":`"$StakeDiamond`"}]}" + $built = Invoke-HacPost "create/transaction" $txJson @{ action = "true"; signature = "true" } + if ($built.ret -ne 0) { throw "create/transaction: $($built.err)" } + $txBody = [string]$built.body + $txBytes = [byte[]]::new($txBody.Length / 2) + for ($i = 0; $i -lt $txBytes.Length; $i++) { + $txBytes[$i] = [Convert]::ToByte($txBody.Substring($i * 2, 2), 16) + } + $signed = Invoke-HacPost "util/transaction/sign" $txBytes @{ prikey = $SeedPrikey; signature = "true"; action = "true" } + if ($signed.ret -ne 0) { throw "transaction/sign: $($signed.err)" } + $signedBody = [string]$signed.body + $signedBytes = [byte[]]::new($signedBody.Length / 2) + for ($i = 0; $i -lt $signedBytes.Length; $i++) { + $signedBytes[$i] = [Convert]::ToByte($signedBody.Substring($i * 2, 2), 16) + } + $sub = Invoke-HacPost "submit/transaction" $signedBytes @{} + if ($sub.ret -ne 0) { throw "submit/transaction: $($sub.err)" } + Write-Host "[OK] staked $StakeDiamond tx=$($sub.hash)" -ForegroundColor Green + + $staked = $false + for ($i = 0; $i -lt 90; $i++) { + $st = Invoke-HacGet "query/staking/status" @{ diamond = $StakeDiamond } + if ($st.status -eq "Staked") { $staked = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $staked) { throw "$StakeDiamond not Staked after submit" } + + $summary = Invoke-HacGet "query/staking/summary" @{ address = $SeedAddress } + if ($summary.staked_count -lt 1) { throw "summary staked_count < 1" } + Write-Host "[OK] $StakeDiamond Staked; summary staked=$($summary.staked_count)" -ForegroundColor Green + + $other = $ExpectedDiamonds | Where-Object { $_ -ne $StakeDiamond } | Select-Object -First 1 + $st2 = Invoke-HacGet "query/staking/status" @{ diamond = $other } + if ($st2.status -ne "Available") { throw "$other should stay Available" } + Write-Host "[OK] $other still Available (label isolation)" -ForegroundColor Green + + $wallet = Invoke-WebRequest -Uri "$Base/hip25/wallet" -UseBasicParsing + if ($wallet.StatusCode -ne 200) { throw "wallet page failed" } + Write-Host "[OK] GET /hip25/wallet" -ForegroundColor Green + + Write-Host "" + Write-Host "Wallet E2E passed. Open $Base/hip25/wallet" -ForegroundColor Green +} finally { + if ($miner -and -not $miner.HasExited) { Stop-Process -Id $miner.Id -Force -ErrorAction SilentlyContinue } + if ($node -and -not $node.HasExited) { Stop-Process -Id $node.Id -Force -ErrorAction SilentlyContinue } +} \ No newline at end of file diff --git a/scripts/hip2_debug_create.ps1 b/scripts/hip2_debug_create.ps1 new file mode 100644 index 0000000..c1cec0a --- /dev/null +++ b/scripts/hip2_debug_create.ps1 @@ -0,0 +1,52 @@ +$BinDir = "C:\Users\KQHEX\Documents\hacash-rust\target\debug" +Set-Location $BinDir +Get-Process hacash -EA SilentlyContinue | Stop-Process -Force +Remove-Item -Recurse -Force hacash_hip2_demo_data -EA SilentlyContinue +@' +[default] +data_dir = hacash_hip2_demo_data +[server] +enable = true +listen = 8083 +listen_host = 127.0.0.1 +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = hip25test +mortgage_activation_height = 1 +hip2_testnet_demo_periods = true +[miner] +enable = true +reward = 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 +[node] +listen = 3339 +not_find_nodes = true +'@ | Set-Content hacash.config.ini +$env:RUST_BACKTRACE = "1" +$Exe = Join-Path $BinDir "hacash.exe" +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru -RedirectStandardError "$BinDir\crash.log" +Start-Sleep 2 +Start-Process -FilePath $Exe -ArgumentList poworker -WorkingDirectory $BinDir | Out-Null +for ($i = 0; $i -lt 30; $i++) { + try { + $h = (Invoke-RestMethod "http://127.0.0.1:8083/query/latest" -TimeoutSec 2).height + if ($h -ge 1) { break } + } catch {} + Start-Sleep 1 +} +$txJson = '{"main_address":"1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2","timestamp":1718496000,"fee":"0:247","actions":[{"kind":15,"lending_id":"4801000000000000000000000032","diamonds":"WTYUIA","loan":"1:250","borrow_period":5}]}' +try { + $r = Invoke-RestMethod -Uri "http://127.0.0.1:8083/create/transaction?action=true&signature=true" -Method POST -Body $txJson -ContentType "application/json" + Write-Host "create OK ret=$($r.ret)" +} catch { + Write-Host "create ERR: $($_.Exception.Message)" +} +Start-Sleep 1 +if ($node.HasExited) { + Write-Host "NODE CRASHED" + Get-Content "$BinDir\crash.log" -Tail 50 +} else { + Write-Host "node alive" + Stop-Process -Id $node.Id -Force +} \ No newline at end of file diff --git a/scripts/hip2_debug_full.ps1 b/scripts/hip2_debug_full.ps1 new file mode 100644 index 0000000..830164f --- /dev/null +++ b/scripts/hip2_debug_full.ps1 @@ -0,0 +1,70 @@ +$BinDir = "C:\Users\KQHEX\Documents\hacash-rust\target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +$Base = "http://127.0.0.1:8083" +$Prikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657" +Set-Location $BinDir +Get-Process hacash -EA SilentlyContinue | Stop-Process -Force +Remove-Item -Recurse -Force hacash_hip2_demo_data -EA SilentlyContinue +@' +[default] +data_dir = hacash_hip2_demo_data +[server] +enable = true +listen = 8083 +listen_host = 127.0.0.1 +allow_public_rpc = false +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = hip25test +mortgage_activation_height = 1 +hip2_testnet_demo_periods = true +[miner] +enable = true +reward = 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2 +[node] +listen = 3339 +not_find_nodes = true +'@ | Set-Content hacash.config.ini +@' +[default] +connect = 127.0.0.1:8083 +supervene = 4 +'@ | Set-Content poworker.config.ini +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +Start-Sleep 3 +$miner = Start-Process -FilePath $Exe -ArgumentList poworker -WorkingDirectory $BinDir -PassThru +for ($i = 0; $i -lt 40; $i++) { + try { if ((Invoke-RestMethod "$Base/query/latest").height -ge 1) { break } } catch {} + Start-Sleep 1 +} +$txJson = '{"main_address":"1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2","timestamp":1718496000,"fee":"0:247","actions":[{"kind":15,"lending_id":"4801000000000000000000000032","diamonds":"WTYUIA","loan":"1:250","borrow_period":5}]}' +Write-Host "1 create..." +$built = Invoke-RestMethod -Uri "$Base/create/transaction?action=true&signature=true" -Method POST -Body $txJson -ContentType "application/json" +Write-Host "2 sign..." +$signJson = (@{ prikey = $Prikey; tx_body = $built.body } | ConvertTo-Json -Compress) +$signed = Invoke-RestMethod -Uri "$Base/util/transaction/sign?signature=true&action=true" -Method POST -Body $signJson -ContentType "application/json" +Write-Host "3 submit..." +$hex = $signed.body +$bytes = [byte[]]::new($hex.Length / 2) +for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16) } +$sub = Invoke-RestMethod -Uri "$Base/submit/transaction" -Method POST -Body $bytes -ContentType "application/octet-stream" +Write-Host "OPEN tx=$($sub.hash)" +Start-Sleep 3 +$mg = Invoke-RestMethod "$Base/query/mortgage/global" +Write-Host "active=$($mg.active_contracts) IOU=$($mg.outstanding_ioo_zhu)" +$q = Invoke-RestMethod "$Base/query/mortgage/contract?id=4801000000000000000000000032&redeemer=1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2" +Write-Host "quote phase=$($q.redeem_phase) ransom=$($q.min_ransom)" +$redeemJson = "{`"main_address`":`"1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2`",`"timestamp`":1718496000,`"fee`":`"0:247`",`"actions`":[{`"kind`":16,`"lending_id`":`"4801000000000000000000000032`",`"ransom`":`"$($q.min_ransom)`"}]}" +$rb = Invoke-RestMethod -Uri "$Base/create/transaction?action=true&signature=true" -Method POST -Body $redeemJson -ContentType "application/json" +$rs = Invoke-RestMethod -Uri "$Base/util/transaction/sign?signature=true&action=true" -Method POST -Body ((@{ prikey = $Prikey; tx_body = $rb.body } | ConvertTo-Json -Compress)) -ContentType "application/json" +$rh = $rs.body +$rbts = [byte[]]::new($rh.Length / 2) +for ($i = 0; $i -lt $rbts.Length; $i++) { $rbts[$i] = [Convert]::ToByte($rh.Substring($i * 2, 2), 16) } +$sub2 = Invoke-RestMethod -Uri "$Base/submit/transaction" -Method POST -Body $rbts -ContentType "application/octet-stream" +Write-Host "REDEEM tx=$($sub2.hash)" +Start-Sleep 3 +$mg2 = Invoke-RestMethod "$Base/query/mortgage/global" +Write-Host "DONE active=$($mg2.active_contracts)" +Stop-Process -Id $miner.Id,$node.Id -Force -EA SilentlyContinue \ No newline at end of file diff --git a/scripts/hip2_mortgage_demo.ps1 b/scripts/hip2_mortgage_demo.ps1 new file mode 100644 index 0000000..a202a3b --- /dev/null +++ b/scripts/hip2_mortgage_demo.ps1 @@ -0,0 +1,206 @@ +# HIP-2 v2.1 live demo: open mortgage (15) -> quote -> redeem (16) on testnet +param( + [string]$Base = "http://127.0.0.1:8083", + [string]$DataDir = "hacash_hip2_demo_data", + [string]$SeedPassword = "hip25test", + [string]$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + [string]$SeedPrikey = "95f8f5960f8d12471419d76716677cbe1764b628cf11213845b6d917a9f98657", + [string]$Diamond = "WTYUIA", + [string]$LendIdHex = "4801000000000000000000000032", + [string]$Loan = "1:250", + [int]$BorrowPeriod = 5 +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +Set-Location $BinDir + +function Invoke-HacGet($Path, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + return Invoke-RestMethod -Uri $uri -TimeoutSec 30 +} + +function Invoke-HacPost($Path, $Body, [hashtable]$Query = @{}) { + $qs = ($Query.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&" + $uri = "$Base/$Path" + $(if ($qs) { "?$qs" } else { "" }) + if ($Body -is [byte[]]) { + return Invoke-RestMethod -Uri $uri -Method POST -Body $Body -ContentType "application/octet-stream" -TimeoutSec 60 + } + if ($Body -is [string] -and $Body.TrimStart().StartsWith("{")) { + return Invoke-RestMethod -Uri $uri -Method POST -Body $Body -ContentType "application/json" -TimeoutSec 60 + } + return Invoke-RestMethod -Uri $uri -Method POST -Body $Body -ContentType "application/octet-stream" -TimeoutSec 60 +} + +function Wait-RpcReady() { + for ($i = 0; $i -lt 40; $i++) { + try { $null = Invoke-HacGet "query/latest"; return } catch { Start-Sleep -Seconds 1 } + } + throw "RPC not ready" +} + +function Wait-Height($Target) { + for ($i = 0; $i -lt 120; $i++) { + $latest = Invoke-HacGet "query/latest" + if ($latest.height -ge $Target) { return $latest.height } + Start-Sleep -Seconds 2 + } + throw "Timeout height >= $Target" +} + +function Submit-SignedTx($TxJson) { + try { + $built = Invoke-HacPost "create/transaction" $TxJson @{ action = "true"; signature = "true" } + } catch { + throw "create/transaction HTTP: $($_.Exception.Message)" + } + if ($built.ret -ne 0) { throw "create/transaction: $($built.err)" } + $txBody = [string]$built.body + $signBody = (@{ prikey = $SeedPrikey; tx_body = $txBody } | ConvertTo-Json -Compress) + try { + $signed = Invoke-HacPost "util/transaction/sign" $signBody @{ signature = "true"; action = "true" } + } catch { + throw "transaction/sign HTTP: $($_.Exception.Message)" + } + if ($signed.ret -ne 0) { throw "transaction/sign: $($signed.err)" } + $signedBody = [string]$signed.body + $signedBytes = [byte[]]::new($signedBody.Length / 2) + for ($i = 0; $i -lt $signedBytes.Length; $i++) { + $signedBytes[$i] = [Convert]::ToByte($signedBody.Substring($i * 2, 2), 16) + } + try { + $sub = Invoke-HacPost "submit/transaction" $signedBytes @{} + } catch { + throw "submit/transaction HTTP: $($_.Exception.Message)" + } + if ($sub.ret -ne 0) { throw "submit/transaction: $($sub.err)" } + return $sub.hash +} + +Write-Host "=== HIP-2 mortgage live demo ===" -ForegroundColor Cyan + +if (-not (Test-Path $Exe)) { throw "Build first: cargo build" } + +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 1 +if (Test-Path $DataDir) { Remove-Item -Recurse -Force $DataDir } + +$cfg = @" +[default] +data_dir = $DataDir +[server] +enable = true +listen = 8083 +listen_host = 127.0.0.1 +allow_public_rpc = false +recent_blocks = false +average_fee_purity = false +[node] +listen = 3339 +not_find_nodes = true +boots = +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = $SeedPassword +hip25_testnet_demo_periods = true +mortgage_activation_height = 1 +mortgage_max_outstanding_zhu = 0 +hip2_testnet_demo_periods = true +[miner] +enable = true +reward = $SeedAddress +message = hip2demo +[diamondminer] +enable = false +"@ +Set-Content "hacash.config.ini" $cfg +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +$node = Start-Process -FilePath $Exe -WorkingDirectory $BinDir -PassThru +Wait-RpcReady +$miner = Start-Process -FilePath $Exe -ArgumentList "poworker" -WorkingDirectory $BinDir -PassThru +Start-Sleep -Seconds 2 + +try { + $h = Wait-Height 1 + Write-Host "[OK] block height $h (seeded 5 HACD + smelt 100 mei each)" -ForegroundColor Green + + $mg0 = Invoke-HacGet "query/mortgage/global" + Write-Host "[OK] mortgage global: apr=$($mg0.apr_bps)bps period=$($mg0.period_blocks)blocks v=$($mg0.economics_version)" -ForegroundColor Green + + $bal0 = Invoke-HacGet "query/balance" @{ address = $SeedAddress } + Write-Host "[..] balance before: $($bal0.list[0].hacash)" -ForegroundColor DarkGray + + $ts = 1718496000 + $openJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":15,`"lending_id`":`"$LendIdHex`",`"diamonds`":`"$Diamond`",`"loan`":`"$Loan`",`"borrow_period`":$BorrowPeriod}]}" + Write-Host "[..] submit mortgage OPEN (kind 15)..." -ForegroundColor Yellow + $openHash = Submit-SignedTx $openJson + Write-Host "[OK] mortgage OPEN tx: $openHash" -ForegroundColor Green + + $confirmed = $false + for ($i = 0; $i -lt 60; $i++) { + $mg = Invoke-HacGet "query/mortgage/global" + if ($mg.active_contracts -ge 1) { $confirmed = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $confirmed) { throw "mortgage contract not confirmed on chain" } + + $mg1 = Invoke-HacGet "query/mortgage/global" + Write-Host "[OK] active=$($mg1.active_contracts) IOU=$($mg1.outstanding_ioo_zhu) orig_burn=$($mg1.cumulative_origination_burn_zhu)" -ForegroundColor Green + + $height = (Invoke-HacGet "query/latest").height + $quote = Invoke-HacGet "query/mortgage/contract" @{ + id = $LendIdHex + redeemer = $SeedAddress + height = $height + } + Write-Host "[OK] quote: phase=$($quote.redeem_phase) min_ransom=$($quote.min_ransom) principal=$($quote.loan_principal)" -ForegroundColor Green + + $bal1 = Invoke-HacGet "query/balance" @{ address = $SeedAddress } + Write-Host "[..] balance after open: $($bal1.list[0].hacash) (loan +$Loan, origination 1%)" -ForegroundColor DarkGray + + $ransom = $quote.min_ransom + $redeemJson = "{`"main_address`":`"$SeedAddress`",`"timestamp`":$ts,`"fee`":`"0:247`",`"actions`":[{`"kind`":16,`"lending_id`":`"$LendIdHex`",`"ransom`":`"$ransom`"}]}" + Write-Host "[..] submit mortgage REDEEM (kind 16) ransom=$ransom..." -ForegroundColor Yellow + $redeemHash = Submit-SignedTx $redeemJson + Write-Host "[OK] mortgage REDEEM tx: $redeemHash" -ForegroundColor Green + + $redeemed = $false + for ($i = 0; $i -lt 60; $i++) { + $mg = Invoke-HacGet "query/mortgage/global" + if ($mg.active_contracts -eq 0) { $redeemed = $true; break } + Start-Sleep -Seconds 2 + } + if (-not $redeemed) { throw "mortgage not redeemed on chain" } + + $mg2 = Invoke-HacGet "query/mortgage/global" + Write-Host "[OK] redeemed: active=$($mg2.active_contracts) IOU=$($mg2.outstanding_ioo_zhu)" -ForegroundColor Green + + $bal2 = Invoke-HacGet "query/balance" @{ address = $SeedAddress } + Write-Host "[..] balance after redeem: $($bal2.list[0].hacash)" -ForegroundColor DarkGray + + try { + $supply = Invoke-HacGet "query/supply" + Write-Host "[OK] supply mortgage_burn=$($supply.mortgage_origination_burn_zhu) v=$($supply.mortgage_economics_version)" -ForegroundColor Green + } catch { + Write-Host "[WARN] supply query skipped: $($_.Exception.Message)" -ForegroundColor Yellow + } + + Write-Host "" + Write-Host "HIP-2 DEMO PASSED - open, quote, redeem OK" -ForegroundColor Green + Write-Host "Wallet UI: $Base/hip25/wallet (password: $SeedPassword)" -ForegroundColor Cyan +} finally { + if ($miner -and -not $miner.HasExited) { Stop-Process -Id $miner.Id -Force -ErrorAction SilentlyContinue } + if ($node -and -not $node.HasExited) { Stop-Process -Id $node.Id -Force -ErrorAction SilentlyContinue } +} \ No newline at end of file diff --git a/scripts/start_hip25_wallet.ps1 b/scripts/start_hip25_wallet.ps1 new file mode 100644 index 0000000..700f78a --- /dev/null +++ b/scripts/start_hip25_wallet.ps1 @@ -0,0 +1,108 @@ +# Start HIP-25 testnet + open wallet. +# Opens TWO cmd windows (HIP25-FULLNODE + HIP25-POWORKER). Do not close them. +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$BinDir = Join-Path $Root "target\debug" +$Exe = Join-Path $BinDir "hacash.exe" +$SeedAddress = "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2" +$DataDir = "hacash_hip25_demo" +$WalletUrl = "http://127.0.0.1:8083/hip25/wallet" + +if (-not (Test-Path $Exe)) { + Write-Host "Build first: cd $Root ; cargo build" -ForegroundColor Red + exit 1 +} + +Set-Location $BinDir +Get-Process hacash -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 2 + +if (Test-Path $DataDir) { + Write-Host "Removing old chain data (fresh testnet seed)..." -ForegroundColor Yellow + Remove-Item -Recurse -Force $DataDir +} + +@" +[default] +data_dir = $DataDir +[server] +enable = true +listen = 8083 +listen_host = 127.0.0.1 +recent_blocks = false +average_fee_purity = false +[node] +listen = 3338 +not_find_nodes = true +boots = +[mint] +chain_id = 1 +staking_activation_height = 1 +hip25_testnet_seed = true +hip25_testnet_seed_password = hip25test +hip25_testnet_demo_periods = true +[miner] +enable = true +reward = $SeedAddress +message = hip25wallet +[diamondminer] +enable = false +"@ | Set-Content "hacash.config.ini" + +@" +[default] +connect = 127.0.0.1:8083 +supervene = 4 +nonce_max = 4294967295 +notice_wait = 3 +"@ | Set-Content "poworker.config.ini" + +Write-Host "[1/4] Starting fullnode (cmd window: HIP25-FULLNODE)..." -ForegroundColor Cyan +Start-Process cmd.exe -ArgumentList "/k", "cd /d $BinDir && title HIP25-FULLNODE && hacash.exe" + +Write-Host "[2/4] Waiting for RPC (max 60s)..." -ForegroundColor Cyan +$ready = $false +for ($i = 0; $i -lt 60; $i++) { + try { + $null = Invoke-RestMethod "http://127.0.0.1:8083/query/latest" -TimeoutSec 2 + $ready = $true + break + } catch { + Start-Sleep -Seconds 1 + } +} + +if (-not $ready) { + Write-Host "RPC not ready. Check HIP25-FULLNODE window." -ForegroundColor Red + exit 1 +} +Write-Host " RPC ready." -ForegroundColor Green + +Write-Host "[3/4] Starting poworker (cmd window: HIP25-POWORKER)..." -ForegroundColor Cyan +Start-Process cmd.exe -ArgumentList "/k", "cd /d $BinDir && title HIP25-POWORKER && hacash.exe poworker" + +Write-Host "[4/4] Waiting for block 1 (HACD seed, max 90s)..." -ForegroundColor Cyan +$mined = $false +for ($i = 0; $i -lt 90; $i++) { + Start-Sleep -Seconds 1 + try { + $r = Invoke-RestMethod "http://127.0.0.1:8083/query/latest" -TimeoutSec 2 + if ([int]$r.height -ge 1) { + $mined = $true + break + } + } catch {} +} + +if ($mined) { + Write-Host " Block 1 ready — 5 HACD seeded." -ForegroundColor Green +} else { + Write-Host " Block 1 not mined yet — wallet will auto-retry." -ForegroundColor Yellow +} + +Start-Process $WalletUrl + +Write-Host "" +Write-Host "Wallet: $WalletUrl" -ForegroundColor Yellow +Write-Host "Keep open: HIP25-FULLNODE and HIP25-POWORKER cmd windows." -ForegroundColor White +Write-Host "Testnet: address $SeedAddress, password hip25test" -ForegroundColor White \ No newline at end of file diff --git a/scripts/test_hacd_stake.mjs b/scripts/test_hacd_stake.mjs new file mode 100644 index 0000000..79622aa --- /dev/null +++ b/scripts/test_hacd_stake.mjs @@ -0,0 +1,50 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import vm from "vm"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pkgDir = path.join(root, "target", "debug", "pkg"); +const js = fs.readFileSync(path.join(pkgDir, "hacash_sdk.js"), "utf8"); +const wasm = fs.readFileSync(path.join(pkgDir, "hacash_sdk_bg.wasm")); + +const ctx = vm.createContext({ + console, + WebAssembly, + TextDecoder, + TextEncoder, + performance: globalThis.performance, + Date, + Math, + Reflect, + ArrayBuffer, + Uint8Array, + Int32Array, + Float64Array, + Promise, + URL, + location: { href: "http://localhost/" }, + document: { currentScript: { src: "http://localhost/hacash_sdk.js" } }, +}); +vm.runInContext(js.replace("let wasm_bindgen", "var wasm_bindgen"), ctx); +await ctx.wasm_bindgen({ module_or_path: wasm }); + +const raw = ctx.wasm_bindgen.hacd_stake(1n, "hip25test", "WTYUIA", "0:247", 1718496000n); +if (raw.startsWith("[ERROR]")) throw new Error(raw); +const tx = JSON.parse(raw.trim().startsWith("{") ? raw.trim() : `{${raw.trim()}}`); +console.log("hacd_stake OK", tx.tx_hash); + +const base = process.env.HACASH_RPC || "http://127.0.0.1:8083"; +try { + const body = Buffer.from(tx.tx_body, "hex"); + const res = await fetch(`${base}/submit/transaction`, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, + }); + const out = await res.json(); + if (out.ret !== 0) throw new Error(JSON.stringify(out)); + console.log("submit OK", out.hash); +} catch (e) { + console.log("(submit skipped — node not running)", e.message); +} \ No newline at end of file diff --git a/scripts/test_hip2_mortgage.mjs b/scripts/test_hip2_mortgage.mjs new file mode 100644 index 0000000..686f60e --- /dev/null +++ b/scripts/test_hip2_mortgage.mjs @@ -0,0 +1,72 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import vm from "vm"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pkgDir = path.join(root, "target", "debug", "pkg"); +const js = fs.readFileSync(path.join(pkgDir, "hacash_sdk.js"), "utf8"); +const wasm = fs.readFileSync(path.join(pkgDir, "hacash_sdk_bg.wasm")); + +const kind = parseInt(process.env.HIP2_KIND || "15", 10); +const pass = process.env.HIP2_PASS || "hip25test"; +const lendId = process.env.HIP2_LEND_ID || "4801000000000000000000000032"; +const diamond = process.env.HIP2_DIAMOND || "WTYUIA"; +const loan = process.env.HIP2_LOAN || "100"; +const borrowPeriod = parseInt(process.env.HIP2_BORROW_PERIOD || "5", 10); +const fee = "0:247"; +const ts = 1718496000n; +const base = process.env.HACASH_RPC || "http://127.0.0.1:8083"; + +const ctx = vm.createContext({ + console, + WebAssembly, + TextDecoder, + TextEncoder, + performance: globalThis.performance, + Date, + Math, + Reflect, + ArrayBuffer, + Uint8Array, + Int32Array, + Float64Array, + Promise, + URL, + location: { href: "http://localhost/" }, + document: { currentScript: { src: "http://localhost/hacash_sdk.js" } }, +}); +vm.runInContext(js.replace("let wasm_bindgen", "var wasm_bindgen"), ctx); +await ctx.wasm_bindgen({ module_or_path: wasm }); + +let raw; +if (kind === 15) { + raw = ctx.wasm_bindgen.hacd_mortgage_open( + 1n, pass, lendId, diamond, loan, borrowPeriod, fee, ts + ); +} else if (kind === 16) { + const qRes = await fetch( + `${base}/query/mortgage/contract?id=${lendId}&redeemer=1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2` + ); + const q = await qRes.json(); + if (q.ret !== 0) throw new Error(JSON.stringify(q)); + const ransom = q.min_ransom || q.data?.min_ransom; + if (!ransom) throw new Error("no min_ransom from quote"); + raw = ctx.wasm_bindgen.hacd_mortgage_redeem(1n, pass, lendId, ransom, fee, ts); +} else { + throw new Error(`unsupported kind ${kind}`); +} + +if (raw.startsWith("[ERROR]")) throw new Error(raw); +const tx = JSON.parse(raw.trim().startsWith("{") ? raw.trim() : `{${raw.trim()}}`); +console.error(`WASM kind ${kind} OK tx_hash=${tx.tx_hash}`); + +const body = Buffer.from(tx.tx_body, "hex"); +const res = await fetch(`${base}/submit/transaction`, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, +}); +const out = await res.json(); +if (out.ret !== 0) throw new Error(JSON.stringify(out)); +console.log(out.hash); \ No newline at end of file diff --git a/src/chain/engine/init.rs b/src/chain/engine/init.rs index 5de3f59..c5a2542 100644 --- a/src/chain/engine/init.rs +++ b/src/chain/engine/init.rs @@ -66,7 +66,11 @@ fn _do_rebuild(this: &mut BlockEngine) { // try insert let ier = this.insert_unsafe(resblk); if let Err(e) = ier { - panic!("[State Panic] rebuild block state error: {}", e); + eprintln!( + "[State Fatal] rebuild block state error at height {}: {}", + next_height, e + ); + std::process::exit(1); } // next std::io::stdout().flush().unwrap(); diff --git a/src/chain/engine/read.rs b/src/chain/engine/read.rs index 5379bf8..8869814 100644 --- a/src/chain/engine/read.rs +++ b/src/chain/engine/read.rs @@ -5,8 +5,13 @@ impl EngineRead for BlockEngine { &self.cnf } + fn try_state(&self) -> Option> { + self.get_latest_state().map(|s| s as Arc) + } + fn state(&self) -> Arc { - self.klctx.lock().unwrap().state.upgrade().unwrap() + self.try_state() + .expect("chain state unavailable during rebuild; retry shortly") } fn store(&self) -> Arc { @@ -33,17 +38,34 @@ impl EngineRead for BlockEngine { } fn try_execute_tx(&self, tx: &dyn TransactionRead) -> RetErr { + self.try_execute_txs_cumulative(&[tx]) + } + + fn try_execute_txs_cumulative(&self, txs: &[&dyn TransactionRead]) -> RetErr { let sta = self.get_latest_state(); - if let None = sta { - return errf!("block engine not yet") + if sta.is_none() { + return errf!("block engine not yet"); } let mut sub_state = fork_sub_state(sta.unwrap()); - let height = self.get_latest_height().uint() + 1; // next height - let blkhash = Hash::cons([0u8; 32]); // empty hash - // exec - exec_tx_actions(false, self.cnf.chain_id, height, blkhash, &mut sub_state, self.store.as_ref(), tx)?; - tx.execute(height, &mut sub_state) - } + let height = self.get_latest_height().uint() + 1; + let chain_id = self.cnf.chain_id; + let store = self.store.as_ref(); + let cur_time = curtimes(); + let blkhash = Hash::cons([0u8; 32]); + for tx in txs { + let tx = *tx; + if tx.timestamp().to_u64() > cur_time { + return errf!( + "tx timestamp {} cannot more than now {}", + tx.timestamp(), + cur_time + ); + } + exec_tx_actions(false, chain_id, height, blkhash, &mut sub_state, store, tx)?; + tx.execute(height, &mut sub_state)?; + } + Ok(()) + } fn recent_blocks(&self) -> Vec> { let vs = self.rctblks.lock().unwrap(); diff --git a/src/chain/execute/execute.rs b/src/chain/execute/execute.rs index e808e33..289e20d 100644 --- a/src/chain/execute/execute.rs +++ b/src/chain/execute/execute.rs @@ -55,6 +55,7 @@ fn exec_tx_actions_withvm(is_fast_sync: bool, chain_id: u64, pending_height: u64, pending_hash: Hash, bst: &mut dyn State, sto: &dyn Store, tx: &dyn TransactionRead, ) -> RetErr { - errf!("cannot exec tx with vm") + // Native mint actions (e.g. HIP-25 stake/unstake 34/35) do not require the Go HVM runtime. + exec_tx_actions_normal(is_fast_sync, chain_id, pending_height, pending_hash, bst, sto, tx) } diff --git a/src/chain/execute/insert.rs b/src/chain/execute/insert.rs index 4ce4688..3f433e1 100644 --- a/src/chain/execute/insert.rs +++ b/src/chain/execute/insert.rs @@ -110,31 +110,43 @@ pub fn do_check_insert( // ready exec let coinbase_tx = &*alltxs[0]; - let mut alltxfee = Amount::default(); + let mut miner_txfee = Amount::default(); // check state let mut sub_state = fork_sub_state(prev_state.clone()); // if init genesis status if height == 1 { // state initialize - mintk.initialize(&mut sub_state)?; + mintk.initialize(&mut sub_state, store)?; } // exec each tx let mut execn = 0; for tx in alltxs { if execn > 0 { // except coinbase tx exec_tx_actions(!not_fast_sync, cnf.chain_id, height, blkhash, &mut sub_state, store, tx.as_read())?; - alltxfee = alltxfee.add(&tx.fee_got())?; // fee_miner_received + let fee = tx.fee_got(); + if fee.is_positive() { + let mut mint_state = MintState::wrap(&mut sub_state); + let redirect = crate::mint::operate::staking_is_active_at_height(&mint_state, height) + && crate::mint::operate::staking_tx_qualifies_for_mint_fee_redirect(tx.as_read()); + if redirect { + crate::mint::operate::staking_deposit_mint_miner_share(&mut mint_state, &fee); + } else { + miner_txfee = miner_txfee.add(&fee)?; + } + } } // deduct tx fee after exec all actions tx.execute(height, &mut sub_state)?; // coinbase and other tx execn += 1; } // add miner got fee - if alltxfee.is_positive() { // amt > 0 + if miner_txfee.is_positive() { let miner = coinbase_tx.address().unwrap(); let mut corestate = CoreState::wrap(&mut sub_state); - operate::hac_add(&mut corestate, &miner, &alltxfee)?; + operate::hac_add(&mut corestate, &miner, &miner_txfee)?; } + // HIP-25: per-block staking rewards + cooldown finalization + crate::mint::operate::staking_on_block_close(&mut sub_state, height)?; // test Ok(sub_state) diff --git a/src/chain/execute/mod.rs b/src/chain/execute/mod.rs index aeb132e..b6c7520 100644 --- a/src/chain/execute/mod.rs +++ b/src/chain/execute/mod.rs @@ -22,6 +22,7 @@ use crate::core::state::*; use crate::protocol::{self, *}; use crate::protocol::transaction::*; use crate::mint::checker::*; +use crate::mint::state::MintState; use super::roller; use super::roller::*; diff --git a/src/config/config.rs b/src/config/config.rs index 2d866e0..4a5e025 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -53,11 +53,17 @@ pub fn load_config(mut cnfilestr: String) -> IniObj { let mut execdir = std::env::current_exe().unwrap().parent().unwrap().to_path_buf(); let mut cnf_file = execdir.join(&cnfilestr); - // cmd args + // cmd args: optional explicit config path (not subcommands like "poworker") let args: Vec = env::args().collect(); if args.len() == 2 { - cnfilestr = args[1].clone(); - cnf_file = PathBuf::from(&cnfilestr); + let arg1 = &args[1]; + let is_config_path = arg1.ends_with(".ini") + || arg1.contains('/') + || arg1.contains('\\'); + if is_config_path { + cnfilestr = arg1.clone(); + cnf_file = PathBuf::from(&cnfilestr); + } } // check exists diff --git a/src/config/mint.rs b/src/config/mint.rs index f8bd2c6..b59cf8b 100644 --- a/src/config/mint.rs +++ b/src/config/mint.rs @@ -1,30 +1,93 @@ -#[derive(Clone, Copy)] +/// Local dev / HIP-25 testnet only. Mainnet must use a different chain_id. +pub const HIP25_DEV_CHAIN_ID: u64 = 1; + +#[derive(Clone)] pub struct MintConf { pub chain_id: u64, // sub chain id pub difficulty_adjust_blocks: u64, // height pub each_block_target_time: u64, // secs pub _test_mul: u64, + /// HIP-25 soft-fork height; staking rules apply from this block onward. + pub staking_activation_height: u64, + /// Dev/testnet: seed one HACD + HAC to a password-derived account at genesis. + pub hip25_testnet_seed: bool, + pub hip25_testnet_seed_password: String, + /// Dev only: min_stake=5 blocks, cooldown=3 blocks (requires hip25_testnet_seed). + pub hip25_testnet_demo_periods: bool, + /// HIP-2 v2 soft-fork height; 0 = disabled until configured. + pub mortgage_activation_height: u64, + /// Global outstanding IOU cap (zhu); 0 uses protocol default. + pub mortgage_max_outstanding_zhu: u64, + /// Dev only: mortgage period = 10 blocks (requires hip25_testnet_seed). + pub hip2_testnet_demo_periods: bool, } - - impl MintConf { - pub fn new(ini: &IniObj) -> MintConf { let sec = ini_section(ini, "mint"); - let mut cnf = MintConf { + let cnf = MintConf { chain_id: ini_must_u64(&sec, "chain_id", 0), difficulty_adjust_blocks: ini_must_u64(&sec, "difficulty_adjust_blocks", 288), // 1 day each_block_target_time: ini_must_u64(&sec, "each_block_target_time", 300), // 5 mins _test_mul: ini_must_u64(&sec, "_test_mul", 1), // test + staking_activation_height: ini_must_u64(&sec, "staking_activation_height", 1), + hip25_testnet_seed: ini_must_bool(&sec, "hip25_testnet_seed", false), + hip25_testnet_seed_password: ini_must(&sec, "hip25_testnet_seed_password", "hip25test"), + hip25_testnet_demo_periods: ini_must_bool(&sec, "hip25_testnet_demo_periods", false), + mortgage_activation_height: ini_must_u64(&sec, "mortgage_activation_height", 0), + mortgage_max_outstanding_zhu: ini_must_u64(&sec, "mortgage_max_outstanding_zhu", 0), + hip2_testnet_demo_periods: ini_must_bool(&sec, "hip2_testnet_demo_periods", false), }; + if let Err(e) = cnf.validate_hip25_dev_flags() { + panic!("[Config Error] {}", e); + } + if let Err(e) = cnf.validate_hip2_dev_flags() { + panic!("[Config Error] {}", e); + } + cnf } + /// Reject dev-only HIP-25 flags on non-dev chain_id (mainnet safety). + pub fn validate_hip25_dev_flags(&self) -> Result<(), String> { + if self.hip25_testnet_demo_periods && !self.hip25_testnet_seed { + return Err( + "hip25_testnet_demo_periods requires hip25_testnet_seed = true".to_string(), + ); + } + if self.hip25_testnet_seed && self.chain_id != HIP25_DEV_CHAIN_ID { + return Err(format!( + "hip25_testnet_seed is only allowed on dev chain_id {} (configured chain_id={})", + HIP25_DEV_CHAIN_ID, self.chain_id + )); + } + if self.hip25_testnet_demo_periods && self.chain_id != HIP25_DEV_CHAIN_ID { + return Err(format!( + "hip25_testnet_demo_periods is only allowed on dev chain_id {} (configured chain_id={})", + HIP25_DEV_CHAIN_ID, self.chain_id + )); + } + Ok(()) + } -} + /// Reject dev-only HIP-2 flags on non-dev chain_id (mainnet safety). + pub fn validate_hip2_dev_flags(&self) -> Result<(), String> { + if self.hip2_testnet_demo_periods && !self.hip25_testnet_seed { + return Err( + "hip2_testnet_demo_periods requires hip25_testnet_seed = true".to_string(), + ); + } + if self.hip2_testnet_demo_periods && self.chain_id != HIP25_DEV_CHAIN_ID { + return Err(format!( + "hip2_testnet_demo_periods is only allowed on dev chain_id {} (configured chain_id={})", + HIP25_DEV_CHAIN_ID, self.chain_id + )); + } + Ok(()) + } +} \ No newline at end of file diff --git a/src/config/server.rs b/src/config/server.rs index 92bf9e0..dc1c9a2 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -1,8 +1,10 @@ -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct ServerConf { pub enable: bool, pub listen: u16, + pub listen_host: String, + pub allow_public_rpc: bool, pub multi_thread: bool, } @@ -15,6 +17,8 @@ impl ServerConf { let mut cnf = ServerConf{ enable: ini_must_bool(&sec, "enable", false), listen: ini_must_u64(&sec, "listen", 8083) as u16, + listen_host: ini_must(&sec, "listen_host", "127.0.0.1"), + allow_public_rpc: ini_must_bool(&sec, "allow_public_rpc", false), multi_thread: ini_must_bool(&sec, "multi_thread", false), }; diff --git a/src/config/util.rs b/src/config/util.rs index c54134a..779662f 100644 --- a/src/config/util.rs +++ b/src/config/util.rs @@ -73,7 +73,7 @@ pub fn ini_must_address(sec: &HashMap>, key: &str) -> Add pub fn ini_must_account(sec: &HashMap>, key: &str) -> Account { let pass = ini_must(sec, key, "123456"); let Ok(acc) = Account::create_by(&pass) else { - panic!("[Config Error] account password {} error.", &pass) + panic!("[Config Error] account key '{}' invalid password or prikey format.", key) }; acc } diff --git a/src/core/db/level/mod.rs b/src/core/db/level/mod.rs index 85987bc..ec01477 100644 --- a/src/core/db/level/mod.rs +++ b/src/core/db/level/mod.rs @@ -1,10 +1,5 @@ -use std::ptr; -use std::ffi::{ c_void, c_char, CString }; -use leveldb_sys::*; -use libc::size_t; - -include!("error.rs"); -include!("bytes.rs"); -include!("batch.rs"); -include!("db.rs"); +#[cfg(target_arch = "wasm32")] +include!("wasm_stub.rs"); +#[cfg(not(target_arch = "wasm32"))] +include!("native_level.rs"); \ No newline at end of file diff --git a/src/core/db/level/native_level.rs b/src/core/db/level/native_level.rs new file mode 100644 index 0000000..721d110 --- /dev/null +++ b/src/core/db/level/native_level.rs @@ -0,0 +1,10 @@ +use std::ffi::{c_char, c_void, CString}; +use std::ptr; + +use leveldb_sys::*; +use libc::size_t; + +include!("error.rs"); +include!("bytes.rs"); +include!("batch.rs"); +include!("db.rs"); \ No newline at end of file diff --git a/src/core/db/level/wasm_stub.rs b/src/core/db/level/wasm_stub.rs new file mode 100644 index 0000000..f165624 --- /dev/null +++ b/src/core/db/level/wasm_stub.rs @@ -0,0 +1,82 @@ +// In-memory LevelDB stubs for browser WASM SDK (signing only; no disk I/O). + +pub struct RawBytes(Vec); + +impl std::ops::Deref for RawBytes { + type Target = [u8]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for Vec { + fn from(bytes: RawBytes) -> Self { + bytes.0 + } +} + +pub struct Writebatch { + ops: Vec<(Vec, Option>)>, +} + +impl Writebatch { + pub fn new() -> Writebatch { + Writebatch { ops: Vec::new() } + } + + pub fn put(&mut self, k: &[u8], value: &[u8]) { + self.ops.push((k.to_vec(), Some(value.to_vec()))); + } + + pub fn delete(&mut self, k: &[u8]) { + self.ops.push((k.to_vec(), None)); + } +} + +pub struct LevelDB { + data: std::sync::Mutex, Vec>>, +} + +impl LevelDB { + pub fn open(_dir: &std::path::Path) -> LevelDB { + LevelDB { + data: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + + pub fn get_at(&self, k: &[u8]) -> Option { + let guard = self.data.lock().ok()?; + guard.get(k).map(|v| RawBytes(v.clone())) + } + + pub fn get(&self, k: &[u8]) -> Option> { + self.get_at(k).map(|v| v.0) + } + + pub fn put(&self, k: &[u8], value: &[u8]) { + if let Ok(mut guard) = self.data.lock() { + guard.insert(k.to_vec(), value.to_vec()); + } + } + + pub fn rm(&self, k: &[u8]) { + if let Ok(mut guard) = self.data.lock() { + guard.remove(k); + } + } + + pub fn write(&self, batch: &Writebatch) { + if let Ok(mut guard) = self.data.lock() { + for (k, v) in &batch.ops { + match v { + Some(val) => { + guard.insert(k.clone(), val.clone()); + } + None => { + guard.remove(k); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/core/field/amount.rs b/src/core/field/amount.rs index d11be51..a02049a 100644 --- a/src/core/field/amount.rs +++ b/src/core/field/amount.rs @@ -1,4 +1,4 @@ - +use std::fmt; pub const AMOUNT_MIN_SIZE: usize = 2; diff --git a/src/core/field/diamond.rs b/src/core/field/diamond.rs index 1d7dde3..e9d3f98 100644 --- a/src/core/field/diamond.rs +++ b/src/core/field/diamond.rs @@ -79,6 +79,14 @@ impl DiamondNameListMax200 { return errf!("diamond name {} is not valid", v.readable()) } } + // reject duplicates + for (i, a) in self.lists.iter().enumerate() { + for b in self.lists.iter().skip(i + 1) { + if a.as_ref() == b.as_ref() { + return errf!("duplicate diamond {} in list", a.readable()) + } + } + } // success Ok(reallen as u8) } diff --git a/src/core/field/mod.rs b/src/core/field/mod.rs index b7ffaa6..0e99b4c 100644 --- a/src/core/field/mod.rs +++ b/src/core/field/mod.rs @@ -1,4 +1,3 @@ -use std::fmt; use std::fmt::{Debug, Formatter}; use std::cmp::Ordering::{Less,Greater}; use std::cmp::{Ordering, PartialOrd, Ord}; diff --git a/src/core/state/store.rs b/src/core/state/store.rs index 306ba5b..8602f89 100644 --- a/src/core/state/store.rs +++ b/src/core/state/store.rs @@ -49,6 +49,11 @@ impl BlockStore { } } + /// Share an existing LevelDB handle (avoids duplicate LOCK on the same directory). + pub fn from_shared(ldb: Arc) -> BlockStore { + BlockStore { ldb } + } + } diff --git a/src/interface/chain/engine.rs b/src/interface/chain/engine.rs index b964c39..5ec1426 100644 --- a/src/interface/chain/engine.rs +++ b/src/interface/chain/engine.rs @@ -10,6 +10,7 @@ pub trait EngineRead: Send + Sync { fn config(&self) -> &EngineConf { panic_never_call_this!() } fn state(&self) -> Arc { panic_never_call_this!() } + fn try_state(&self) -> Option> { None } fn store(&self) -> Arc { panic_never_call_this!() } // fn confirm_state(&self) -> (Arc, Arc) { panic_never_call_this!() } @@ -20,6 +21,12 @@ pub trait EngineRead: Send + Sync { fn average_fee_purity(&self) -> u64 { 0 } // 1w zhu(shuo) / 200byte(1trs) fn try_execute_tx(&self, _: &dyn TransactionRead) -> RetErr { panic_never_call_this!() } + fn try_execute_txs_cumulative(&self, txs: &[&dyn TransactionRead]) -> RetErr { + for tx in txs { + self.try_execute_tx(*tx)?; + } + Ok(()) + } // realtime average fee purity // fn avgfee(&self) -> u32 { 0 } } diff --git a/src/interface/chain/mod.rs b/src/interface/chain/mod.rs index 88aa4f4..98a69c5 100644 --- a/src/interface/chain/mod.rs +++ b/src/interface/chain/mod.rs @@ -5,6 +5,7 @@ use crate::config::*; use crate::base::field::*; use crate::core::field::*; use crate::core::db::*; +#[cfg(not(target_arch = "wasm32"))] use crate::chain::roller::*; diff --git a/src/interface/mint/checker.rs b/src/interface/mint/checker.rs index 4b8dd25..157808e 100644 --- a/src/interface/mint/checker.rs +++ b/src/interface/mint/checker.rs @@ -8,7 +8,7 @@ pub trait MintChecker: Send + Sync + dyn_clone::DynClone { fn consensus(&self, _: &dyn Store, _: &dyn BlockRead, _: &dyn BlockRead) -> RetErr; fn coinbase(&self, _: u64, _: &dyn Transaction) -> RetErr; // do - fn initialize(&self, _: &mut dyn State) -> RetErr; + fn initialize(&self, _: &mut dyn State, _: &dyn Store) -> RetErr; // data fn genesis(&self) -> Arc; fn genesis_block(&self) -> Box; diff --git a/src/lib.rs b/src/lib.rs index b40c992..aa28ab2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![no_main] +#![cfg_attr(not(test), no_main)] // #![no_std] // #[panic_handler] diff --git a/src/main.rs b/src/main.rs index a4b792a..e607e45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,11 +30,15 @@ use crate::run::*; fn main() { - - // poworker(); // HAC PoW Miner Worker - // diaworker(); // Diamond Miner Worker - fullnode(); // Hacash Full Node - + let args: Vec = std::env::args().collect(); + if args.len() >= 2 { + match args[1].as_str() { + "poworker" => return poworker(), + "diaworker" => return diaworker(), + _ => (), + } + } + fullnode(); } diff --git a/src/mint/action/action.rs b/src/mint/action/action.rs index 24fb2a6..eba9b57 100644 --- a/src/mint/action/action.rs +++ b/src/mint/action/action.rs @@ -4,6 +4,7 @@ pub const ACTION_KIND_ID_DIAMOND_MINT: u16 = 4; /** * reg actions */ +#[cfg(not(target_arch = "wasm32"))] pubFnRegExtendActionCreates!{ ChannelOpen // 2 @@ -22,6 +23,20 @@ pub const ACTION_KIND_ID_DIAMOND_MINT: u16 = 4; DiamondInscription // 32 DiamondInscriptionClear // 33 + DiamondStake // 34 HIP-25 + DiamondUnstake // 35 HIP-25 + + MortgageOpen // 15 HIP-2 v2 + MortgageRedeem // 16 HIP-2 v2 + +} + +#[cfg(target_arch = "wasm32")] +pubFnRegExtendActionCreates!{ + DiamondStake // 34 HIP-25 + DiamondUnstake // 35 HIP-25 + MortgageOpen // 15 HIP-2 v2.1 + MortgageRedeem // 16 HIP-2 v2.1 } // reg action diff --git a/src/mint/action/diamond_insc.rs b/src/mint/action/diamond_insc.rs index a3dc185..3ae9a3a 100644 --- a/src/mint/action/diamond_insc.rs +++ b/src/mint/action/diamond_insc.rs @@ -63,10 +63,11 @@ fn diamond_inscription(this: &DiamondInscription, ctx: &dyn ExecContext, sta: &m return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + // HIP-25 v3: full protocol cost burns (no inscription fee redirect). + let pay_zhu = pcost.to_zhu_unsafe() as u64; let mut ttcount = state.total_count(); ttcount.diamond_engraved += this.diamonds.count().uint() as u64; - ttcount.diamond_insc_burn_zhu += pcost.to_zhu_unsafe() as u64; + ttcount.diamond_insc_burn_zhu += pay_zhu; state.set_total_count(&ttcount); drop(state); @@ -133,9 +134,9 @@ fn diamond_inscription_clean(this: &DiamondInscriptionClear, ctx: &dyn ExecConte return errf!("diamond inscription cost error need {} but got {}", ttcost.to_fin_string(), pcost.to_fin_string()) } - // change count + let pay_zhu = pcost.to_zhu_unsafe() as u64; let mut ttcount = state.total_count(); - ttcount.diamond_insc_burn_zhu += pcost.to_zhu_unsafe() as u64; + ttcount.diamond_insc_burn_zhu += pay_zhu; state.set_total_count(&ttcount); drop(state); diff --git a/src/mint/action/diamond_lending.rs b/src/mint/action/diamond_lending.rs new file mode 100644 index 0000000..02cdba7 --- /dev/null +++ b/src/mint/action/diamond_lending.rs @@ -0,0 +1,73 @@ + +/** + * HIP-2 v2: HACD system mortgage — open (15) and redeem (16). + */ +ActionDefine!{ + MortgageOpen : 15, ( + lending_id : DiamondSyslendId + mortgage_diamonds : DiamondNameListMax200 + loan_total_amount : Amount + borrow_period : Uint1 + ), + ACTLV_MAIN, + 21, + (self, ctx, state, store, gas), + false, + [], + { + gas += self.mortgage_diamonds.count().uint() as i64 * DiamondName::width() as i64; + mortgage_open(self, ctx, state, store) + } +} + +ActionDefine!{ + MortgageRedeem : 16, ( + lending_id : DiamondSyslendId + ransom_amount : Amount + ), + ACTLV_MAIN, + 21, + (self, ctx, state, store, gas), + false, + [], + mortgage_redeem(self, ctx, state, store) +} + +fn mortgage_open( + this: &MortgageOpen, + ctx: &dyn ExecContext, + sta: &mut dyn State, + sto: &dyn Store, +) -> Ret> { + let owner = ctx.main_address(); + let height = ctx.pending_height(); + mortgage_apply_open( + sta, + sto, + owner, + &this.lending_id, + &this.mortgage_diamonds, + &this.loan_total_amount, + this.borrow_period.uint() as u8, + height, + )?; + Ok(vec![]) +} + +fn mortgage_redeem( + this: &MortgageRedeem, + ctx: &dyn ExecContext, + sta: &mut dyn State, + _sto: &dyn Store, +) -> Ret> { + let redeemer = ctx.main_address(); + let height = ctx.pending_height(); + mortgage_apply_redeem( + sta, + redeemer, + &this.lending_id, + &this.ransom_amount, + height, + )?; + Ok(vec![]) +} \ No newline at end of file diff --git a/src/mint/action/diamond_staking.rs b/src/mint/action/diamond_staking.rs new file mode 100644 index 0000000..da6ae34 --- /dev/null +++ b/src/mint/action/diamond_staking.rs @@ -0,0 +1,59 @@ + +/** + * HIP-25: Diamond Stake / Unstake actions + */ +ActionDefine!{ + DiamondStake : 34, ( + diamonds : DiamondNameListMax200 + ), + ACTLV_MAIN, + 21, + (self, ctx, state, store, gas), + true, + [], + { + gas += self.diamonds.count().uint() as i64 * DiamondName::width() as i64; + diamond_stake(self, ctx, state, store) + } +} + +ActionDefine!{ + DiamondUnstake : 35, ( + diamonds : DiamondNameListMax200 + ), + ACTLV_MAIN, + 21, + (self, ctx, state, store, gas), + true, + [], + { + gas += self.diamonds.count().uint() as i64 * DiamondName::width() as i64; + diamond_unstake(self, ctx, state, store) + } +} + +fn diamond_stake( + this: &DiamondStake, + ctx: &dyn ExecContext, + sta: &mut dyn State, + _sto: &dyn Store, +) -> Ret> { + let staker = ctx.main_address(); + let height = ctx.pending_height(); + let mut state = MintState::wrap(sta); + staking_apply_stake(&mut state, staker, &this.diamonds, height)?; + Ok(vec![]) +} + +fn diamond_unstake( + this: &DiamondUnstake, + ctx: &dyn ExecContext, + sta: &mut dyn State, + _sto: &dyn Store, +) -> Ret> { + let staker = ctx.main_address(); + let height = ctx.pending_height(); + let mut state = MintState::wrap(sta); + staking_apply_unstake(&mut state, staker, &this.diamonds, height, ctx.chain_id())?; + Ok(vec![]) +} \ No newline at end of file diff --git a/src/mint/action/mod.rs b/src/mint/action/mod.rs index 12d3498..f1c4ffe 100644 --- a/src/mint/action/mod.rs +++ b/src/mint/action/mod.rs @@ -25,6 +25,9 @@ include!("satoshi.rs"); include!("diamond.rs"); include!("diamond_mint.rs"); include!("diamond_insc.rs"); +include!("diamond_staking.rs"); +include!("diamond_lending.rs"); +#[cfg(not(target_arch = "wasm32"))] include!("channel.rs"); include!("action.rs"); diff --git a/src/mint/checker/check.rs b/src/mint/checker/check.rs index a7b76f6..4efc386 100644 --- a/src/mint/checker/check.rs +++ b/src/mint/checker/check.rs @@ -43,9 +43,9 @@ impl MintChecker for BlockMintChecker { impl_coinbase(self, height, cbtx) } - fn initialize(&self, state: &mut dyn State) -> RetErr { - impl_initialize(self, state) - } + fn initialize(&self, state: &mut dyn State, store: &dyn Store) -> RetErr { + impl_initialize(self, state, store) + } fn genesis(&self) -> Arc { genesis_block_ptr() diff --git a/src/mint/checker/initialize.rs b/src/mint/checker/initialize.rs index 638ca5f..6286932 100644 --- a/src/mint/checker/initialize.rs +++ b/src/mint/checker/initialize.rs @@ -1,6 +1,93 @@ +use crate::core::account::Account; +use crate::interface::chain::Store; +use crate::mint::component::*; +use crate::mint::operate::diamond_owned_push_one; +use crate::mint::state::{MintState, MintStoreDisk}; + +/// Demo bid-burn collateral per seeded HACD (100 HAC loan principal each). +const HIP25_SEED_DIAMOND_BURN_MEI: u16 = 100; + +fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State, store: &dyn Store) -> RetErr { + + { + let mut mint_state = MintState::wrap(db); + let mut global = mint_state.staking_global(); + global.activation_height = BlockHeight::from(this.cnf.staking_activation_height); + if this.cnf.hip25_testnet_seed && this.cnf.hip25_testnet_demo_periods { + global.demo_min_stake_blocks = Uint5::from(5); + global.demo_cooldown_blocks = Uint5::from(3); + println!( + "[HIP-25 testnet demo] short periods: min_stake=5 blocks, cooldown=3 blocks" + ); + } + mint_state.set_staking_global(&global); + } + + { + let mut mint_state = MintState::wrap(db); + let mut mg = mint_state.mortgage_global(); + mg.activation_height = BlockHeight::from(this.cnf.mortgage_activation_height); + let cap = this.cnf.mortgage_max_outstanding_zhu; + mg.max_outstanding_ioo_zhu = Uint8::from(if cap > 0 { + cap + } else { + MORTGAGE_DEFAULT_MAX_OUTSTANDING_ZHU + }); + if this.cnf.hip25_testnet_seed && this.cnf.hip2_testnet_demo_periods { + mg.demo_period_blocks = Uint5::from(10); + println!("[HIP-2 testnet demo] mortgage period = 10 blocks"); + } + mint_state.set_mortgage_global(&mg); + } + + if this.cnf.hip25_testnet_seed { + let acc = Account::create_by_password(&this.cnf.hip25_testnet_seed_password) + .map_err(|e| e.to_string())?; + let owner = Address::cons(*acc.address()); + let seed_diamonds: [&[u8; 6]; 5] = [ + b"WTYUIA", b"HXVMEK", b"VMEKBS", b"UIASHX", b"MEKUIA", + ]; + let fee_hac = Amount::new_small(11, 244); + let mut mint_state = MintState::wrap(db); + let mint_store = MintStoreDisk::wrap(store); + for name in seed_diamonds { + let dianame = DiamondName::cons(*name); + let dia = DiamondSto { + status: DIAMOND_STATUS_NORMAL, + address: owner.clone(), + prev_engraved_height: BlockHeight::from(0), + inscripts: Inscripts::default(), + }; + mint_state.set_diamond(&dianame, &dia); + diamond_owned_push_one(&mut mint_state, &owner, &dianame); + let smelt = DiamondSmelt { + diamond: dianame.clone(), + number: DiamondNumber::from(1), + born_height: BlockHeight::from(1), + born_hash: Hash::default(), + prev_hash: Hash::default(), + miner_address: owner.clone(), + bid_fee: Amount::default(), + nonce: Fixed8::default(), + average_bid_burn: Uint2::from(HIP25_SEED_DIAMOND_BURN_MEI), + life_gene: Hash::default(), + }; + mint_store.put_diamond_smelt(&dianame, &smelt); + } + let mut core = CoreState::wrap(db); + let mut bal = Balance::hacash(fee_hac); + bal.diamond = DiamondNumberAuto::from(seed_diamonds.len() as u64); + core.set_balance(&owner, &bal); + println!( + "[HIP-25 testnet seed] 5 HACD (WTYUIA,HXVMEK,VMEKBS,UIASHX,MEKUIA) + 11 HAC -> {} (see docs for dev password)", + owner.readable() + ); + println!( + "[HIP-2 testnet seed] smelt bid-burn {} mei per HACD (mortgage principal)", + HIP25_SEED_DIAMOND_BURN_MEI + ); + } -fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State) -> RetErr { - let addr1 = Address::from_readable("12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi").unwrap(); let addr2 = Address::from_readable("1LsQLqkd8FQDh3R7ZhxC5fndNf92WfhM19").unwrap(); let addr3 = Address::from_readable("1NUgKsTgM6vQ5nxFHGz1C4METaYTPgiihh").unwrap(); @@ -17,4 +104,4 @@ fn impl_initialize(this: &BlockMintChecker, db: &mut dyn State) -> RetErr { // ok Ok(()) -} +} \ No newline at end of file diff --git a/src/mint/component/diamond_lending.rs b/src/mint/component/diamond_lending.rs new file mode 100644 index 0000000..430fa1f --- /dev/null +++ b/src/mint/component/diamond_lending.rs @@ -0,0 +1,163 @@ + +/** + * HIP-2 v2.1: HACD system mortgage loan (HAC IOU against bid-burn collateral). + */ + +/// ~35 days per committed period at mainnet cadence (~5 min/block). +pub const MORTGAGE_PERIOD_BLOCKS: u64 = 10_000; + +/// ~1 year of blocks at 5 min/block (365 × 24 × 12). +pub const MORTGAGE_BLOCKS_PER_YEAR: u64 = 105_120; + +/// 1% origination fee on loan principal → burn (v2.1). +pub const MORTGAGE_ORIGINATION_FEE_BPS: u64 = 100; + +/// Private grace: zero ransom interest for this many elapsed periods (~3.5 months). +pub const MORTGAGE_EARLY_GRACE_PERIODS: u64 = 3; + +/// After grace, before private midpoint: 0.1% of principal per elapsed period. +pub const MORTGAGE_EARLY_INTEREST_BPS_PER_PERIOD: u64 = 10; + +/// Flat annual rate (3%); borrow_period T sets windows only, not the rate multiplier. +pub const MORTGAGE_APR_BPS: u64 = 300; + +/// Dutch auction floor: 103% of principal (v2.1). +pub const MORTGAGE_AUCTION_FLOOR_BPS: u64 = 10_300; + +/// Default max outstanding IOU (zhu); governance may lower before mainnet. +pub const MORTGAGE_DEFAULT_MAX_OUTSTANDING_ZHU: u64 = 800_000_000_000_000; // 8M HAC @ 8 decimals scale + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MortgageRedeemPhase { + Private, + Public, + Auction, +} + +impl MortgageRedeemPhase { + pub fn label(self) -> &'static str { + match self { + Self::Private => "private", + Self::Public => "public", + Self::Auction => "auction", + } + } +} + +StructFieldStruct!(DiamondSystemLending, + is_ransomed : Uint1 + create_block_height : BlockHeight + main_address : Address + mortgage_diamonds : DiamondNameListMax200 + loan_principal : Amount + borrow_period : Uint1 + ransom_block_height : BlockHeight + ransom_address : Address +); + +impl DiamondSystemLending { + pub fn redeemed(&self) -> bool { + self.is_ransomed.uint() != 0 + } + + pub fn mark_ransomed(&mut self, height: u64, redeemer: &Address) { + self.is_ransomed = Uint1::from(1); + self.ransom_block_height = BlockHeight::from(height); + self.ransom_address = redeemer.clone(); + } +} + +StructFieldStruct!(GlobalMortgageState, + outstanding_ioo_zhu : Uint8 + cumulative_loan_zhu : Uint8 + cumulative_ransom_burn_zhu : Uint8 + cumulative_origination_burn_zhu : Uint8 + active_contracts : Uint5 + activation_height : BlockHeight + max_outstanding_ioo_zhu : Uint8 + demo_period_blocks : Uint5 +); + +impl GlobalMortgageState { + pub fn is_active_at(&self, height: u64) -> bool { + let act = self.activation_height.uint(); + act > 0 && height >= act + } + + pub fn effective_period_blocks(&self) -> u64 { + let demo = self.demo_period_blocks.uint(); + if demo > 0 { + demo + } else { + MORTGAGE_PERIOD_BLOCKS + } + } +} + +/// Per-owner index of active mortgage lending ids (for wallet / RPC portfolio). +StructFieldStruct!(MortgageOwnerIndex, + ids : BytesW4 +); + +/// Max active mortgage contracts indexed per owner (wallet portfolio RPC). +pub const MORTGAGE_OWNER_INDEX_MAX: usize = 64; + +impl MortgageOwnerIndex { + pub fn iter_ids(&self) -> Vec { + let w = DIAMOND_SYSLEND_ID_SIZE; + let bytes = self.ids.as_ref(); + let mut out = Vec::new(); + for chunk in bytes.chunks(w) { + if chunk.len() == w { + out.push(DiamondSyslendId::cons(chunk.try_into().unwrap())); + } + } + out + } + + pub fn push_id(&mut self, id: &DiamondSyslendId) -> Ret<()> { + mortgage_validate_lending_id(id)?; + let w = DIAMOND_SYSLEND_ID_SIZE; + if self.ids.length() / w >= MORTGAGE_OWNER_INDEX_MAX { + return errf!("mortgage owner contract index full"); + } + for existing in self.iter_ids() { + if existing == *id { + return Ok(()); + } + } + let mut bytes = id.as_ref().to_vec(); + self.ids.append(&mut bytes); + Ok(()) + } + + pub fn remove_id(&mut self, id: &DiamondSyslendId) -> Ret<()> { + let w = DIAMOND_SYSLEND_ID_SIZE; + let target = id.as_ref(); + let mut kept = Vec::new(); + let mut removed = false; + for existing in self.iter_ids() { + if existing.as_ref() == target { + removed = true; + } else { + kept.extend_from_slice(existing.as_ref()); + } + } + if !removed { + return Ok(()); + } + self.ids = BytesW4::from_vec(kept); + Ok(()) + } +} + +pub fn mortgage_validate_lending_id(id: &DiamondSyslendId) -> Ret<()> { + let bytes = id.as_ref(); + if bytes.len() != DIAMOND_SYSLEND_ID_SIZE { + return errf!("mortgage lending id length must be {}", DIAMOND_SYSLEND_ID_SIZE); + } + if bytes[0] == 0 || bytes[bytes.len() - 1] == 0 { + return errf!("mortgage lending id format error"); + } + Ok(()) +} \ No newline at end of file diff --git a/src/mint/component/mod.rs b/src/mint/component/mod.rs index 1fe867b..32e1b0c 100644 --- a/src/mint/component/mod.rs +++ b/src/mint/component/mod.rs @@ -22,6 +22,8 @@ include!("genesis.rs"); include!("total.rs"); include!("balance.rs"); include!("diamond.rs"); +include!("diamond_lending.rs"); +include!("staking.rs"); include!("channel.rs"); include!("tx.rs"); include!("block.rs"); diff --git a/src/mint/component/staking.rs b/src/mint/component/staking.rs new file mode 100644 index 0000000..e848f46 --- /dev/null +++ b/src/mint/component/staking.rs @@ -0,0 +1,194 @@ + +/** + * HIP-25: Pure HACD Staking + * + * Constants, status values, and on-chain state types. + * See HIP-25 for the full protocol specification. + */ + +/// HIP-25 v3: HACD mint bid fee miner share (`fee_got` on DiamondMint) → reward pool when staking active. +pub const STAKING_FEE_SHARE_PERCENT: u64 = 10; + +/// On-chain economics label returned by staking global RPC. +pub const STAKING_ECONOMICS_VERSION: &str = "v3"; + +/// v3 fee source identifier for RPC (`fee_sources` field). +pub const STAKING_FEE_SOURCES: &str = "hacd_mint_miner_share"; + +/// Consecutive blocks with zero stakers and a non-empty pool before undistributed fees are burned. +pub const STAKING_POOL_SWEEP_BLOCKS: u64 = 1008; + +/// ~3 days cooldown after unstake (1000 blocks ≈ 3.5 days per HIP-15) +pub const COOLDOWN_BLOCKS: u64 = 864; + +/// ~90 days / 3 months minimum stake before unstake is allowed +pub const MIN_STAKE_BLOCKS: u64 = 25714; + +/// HVM external action opcode (HIP-21) +pub const STAKE_HACD_VMKIND: u8 = 0x01; + +/// HVM external action opcode (HIP-21) +pub const UNSTAKE_HACD_VMKIND: u8 = 0x02; + +/// On-chain staking event kinds (HIP-25 Events section) +pub const STAKING_EVENT_STAKED: Uint1 = Uint1::from(1); +pub const STAKING_EVENT_UNSTAKE_REQUESTED: Uint1 = Uint1::from(2); +pub const STAKING_EVENT_UNSTAKED: Uint1 = Uint1::from(3); +pub const STAKING_EVENT_REWARD_DISTRIBUTED: Uint1 = Uint1::from(4); +pub const STAKING_EVENT_POOL_SWEPT: Uint1 = Uint1::from(5); + +/// Diamond is locked and earning rewards +pub const DIAMOND_STATUS_STAKED: Uint1 = Uint1::from(4); + +/// Diamond is in post-unstake cooldown; HACD and rewards release at unlock_height +pub const DIAMOND_STATUS_STAKING_COOLDOWN: Uint1 = Uint1::from(5); + +pub fn diamond_status_allows_transfer(status: &Uint1) -> bool { + *status == DIAMOND_STATUS_NORMAL +} + +pub fn diamond_status_allows_inscription(status: &Uint1) -> bool { + *status == DIAMOND_STATUS_NORMAL +} + +pub fn diamond_status_is_staking_locked(status: &Uint1) -> bool { + *status == DIAMOND_STATUS_STAKED || *status == DIAMOND_STATUS_STAKING_COOLDOWN +} + +pub fn staking_status_label(status: &Uint1) -> &'static str { + if *status == DIAMOND_STATUS_STAKED { + return "Staked"; + } + if *status == DIAMOND_STATUS_STAKING_COOLDOWN { + return "Cooldown"; + } + if *status == DIAMOND_STATUS_NORMAL { + return "Available"; + } + "unknown" +} + +/// Wallet / RPC label including HIP-2 mortgage collateral. +pub fn diamond_wallet_status_label(status: &Uint1) -> &'static str { + if *status == DIAMOND_STATUS_LENDING_TO_SYSTEM { + return "Mortgaged"; + } + if *status == DIAMOND_STATUS_LENDING_TO_USER { + return "Lent"; + } + staking_status_label(status) +} + +pub fn staking_event_kind_label(kind: &Uint1) -> &'static str { + if *kind == STAKING_EVENT_STAKED { + return "Staked"; + } + if *kind == STAKING_EVENT_UNSTAKE_REQUESTED { + return "UnstakeRequested"; + } + if *kind == STAKING_EVENT_UNSTAKED { + return "Unstaked"; + } + if *kind == STAKING_EVENT_REWARD_DISTRIBUTED { + return "RewardDistributed"; + } + if *kind == STAKING_EVENT_POOL_SWEPT { + return "PoolSweptBurn"; + } + "unknown" +} + +/** + * Global staking pool and reward index. + * Singleton key: &[2, 3] in MintState (see state/def.rs). + */ +StructFieldStruct!(GlobalStakingState, + total_staked_shares : Uint5 + global_reward_index : Uint8 + reward_pool_zhu : Uint8 + paused : Uint1 + unlock_queue_head : Uint5 + unlock_queue_tail : Uint5 + activation_height : BlockHeight + event_log_tail : Uint5 + demo_min_stake_blocks : Uint5 + demo_cooldown_blocks : Uint5 + cumulative_deposit_zhu : Uint8 + cumulative_paid_zhu : Uint8 + cumulative_pool_burned_zhu : Uint8 + zero_staker_blocks : Uint5 +); + +impl GlobalStakingState { + pub fn is_paused(&self) -> bool { + self.paused.uint() != 0 + } + + pub fn is_active_at(&self, height: u64) -> bool { + height >= self.activation_height.uint() + } + + pub fn effective_min_stake_blocks(&self, chain_id: u64) -> u64 { + if chain_id == crate::config::HIP25_DEV_CHAIN_ID { + let v = self.demo_min_stake_blocks.uint(); + if v > 0 { + return v; + } + } + MIN_STAKE_BLOCKS + } + + pub fn effective_cooldown_blocks(&self, chain_id: u64) -> u64 { + if chain_id == crate::config::HIP25_DEV_CHAIN_ID { + let v = self.demo_cooldown_blocks.uint(); + if v > 0 { + return v; + } + } + COOLDOWN_BLOCKS + } +} + +/** + * Per-HACD staking metadata. + * Keyed by DiamondName while status is Staked or Cooldown. + * Removed when diamond returns to Normal. + */ +StructFieldStruct!(StakingRecord, + stake_height : BlockHeight + unlock_height : BlockHeight + reward_index : Uint8 + pending_reward : Amount +); + +impl StakingRecord { + pub fn is_active_stake(&self) -> bool { + self.unlock_height.uint() == 0 + } +} + +/** + * FIFO unlock queue entry. + * Appended on unstake; consumed at block close when unlock_height <= block height. + * Keyed by monotonic Uint5 entry id. + */ +StructFieldStruct!(StakingUnlockEntry, + unlock_height : BlockHeight + diamond : DiamondName + staker : Address + reward : Amount +); + +/** + * Append-only HIP-25 event log entry. + * Keyed by monotonic Uint5 id in MintState (see state/def.rs). + */ +StructFieldStruct!(StakingEvent, + kind : Uint1 + height : BlockHeight + diamond : DiamondName + staker : Address + unlock_height : BlockHeight + reward : Amount + shares : Uint5 +); \ No newline at end of file diff --git a/src/mint/mod.rs b/src/mint/mod.rs index 25a2b9f..b8cd603 100644 --- a/src/mint/mod.rs +++ b/src/mint/mod.rs @@ -1,9 +1,12 @@ pub mod state; pub mod component; +#[cfg(not(target_arch = "wasm32"))] pub mod coinbase; +#[cfg(not(target_arch = "wasm32"))] pub mod difficulty; pub mod operate; pub mod action; +#[cfg(not(target_arch = "wasm32"))] pub mod checker; diff --git a/src/mint/operate/diamond.rs b/src/mint/operate/diamond.rs index 35d0149..0976a11 100644 --- a/src/mint/operate/diamond.rs +++ b/src/mint/operate/diamond.rs @@ -18,9 +18,15 @@ pub fn check_diamond_status(state: &mut MintState, addr_from: &Address, hacd_nam let diaitem = must_have!( format!("diamond {}", hacd_name.readable()), state.diamond(hacd_name)); - if diaitem.status != DIAMOND_STATUS_NORMAL { + if diaitem.status == DIAMOND_STATUS_LENDING_TO_SYSTEM || diaitem.status == DIAMOND_STATUS_LENDING_TO_USER { return errf!("diamond {} has been mortgaged and cannot be transferred", hacd_name.readable()) } + if diamond_status_is_staking_locked(&diaitem.status) { + return errf!("diamond {} is staked or in unstake cooldown", hacd_name.readable()) + } + if diaitem.status != DIAMOND_STATUS_NORMAL { + return errf!("diamond {} status {} does not allow this operation", hacd_name.readable(), diaitem.status.uint()) + } if *addr_from != diaitem.address { return errf!("diamond {} not belong to address {}", hacd_name.readable(), addr_from.readable()) } diff --git a/src/mint/operate/diamond_lending.rs b/src/mint/operate/diamond_lending.rs new file mode 100644 index 0000000..115d91f --- /dev/null +++ b/src/mint/operate/diamond_lending.rs @@ -0,0 +1,881 @@ + +fn mortgage_principal_mei_from_smelts( + store: &MintStoreDisk, + diamonds: &DiamondNameListMax200, +) -> Ret { + let mut total_mei = 0u64; + for dian in diamonds.list() { + let smelt = must_have!( + format!("diamond smelt {}", dian.readable()), + store.diamond_smelt(&dian) + ); + total_mei = total_mei.saturating_add(smelt.average_bid_burn.uint() as u64); + } + if total_mei == 0 { + return errf!("mortgage loan principal must be positive"); + } + Ok(total_mei) +} + +pub fn mortgage_compute_principal( + store: &MintStoreDisk, + diamonds: &DiamondNameListMax200, +) -> Ret { + let mei = mortgage_principal_mei_from_smelts(store, diamonds)?; + Amount::from_mei(mei as i64) +} + +fn mortgage_principal_zhu(principal: &Amount) -> Ret { + let zhu = principal.to_zhu_unsafe(); + if zhu <= 0.0 { + return errf!("mortgage principal zhu must be positive"); + } + Ok(zhu as u64) +} + +fn mortgage_origination_fee_zhu(principal_zhu: u64) -> u64 { + principal_zhu * MORTGAGE_ORIGINATION_FEE_BPS / 10_000 +} + +pub fn mortgage_origination_burn(principal: &Amount) -> Ret { + let zhu = mortgage_principal_zhu(principal)?; + let orig_zhu = mortgage_origination_fee_zhu(zhu); + if orig_zhu == 0 { + return Ok(Amount::default()); + } + Amount::from_zhu(orig_zhu as i64) +} + +/// Elapsed full periods since contract creation. +fn mortgage_elapsed_periods(create_height: u64, height: u64, period_blocks: u64) -> u64 { + if height <= create_height || period_blocks == 0 { + return 0; + } + (height - create_height) / period_blocks +} + +/// Interest bps on principal from flat APR over elapsed blocks. +pub fn mortgage_apr_interest_bps(elapsed_blocks: u64) -> u64 { + MORTGAGE_APR_BPS + .saturating_mul(elapsed_blocks) + / MORTGAGE_BLOCKS_PER_YEAR.max(1) +} + +/// Private-phase interest before public window. +fn mortgage_private_interest_bps( + create_height: u64, + height: u64, + period_blocks: u64, + borrow_period: u64, +) -> u64 { + let elapsed_periods = mortgage_elapsed_periods(create_height, height, period_blocks); + let half = borrow_period / 2; + + if elapsed_periods <= MORTGAGE_EARLY_GRACE_PERIODS { + return 0; + } + if elapsed_periods <= half { + let chargeable = elapsed_periods - MORTGAGE_EARLY_GRACE_PERIODS; + return MORTGAGE_EARLY_INTEREST_BPS_PER_PERIOD * chargeable; + } + + let elapsed_blocks = height.saturating_sub(create_height); + mortgage_apr_interest_bps(elapsed_blocks) +} + +/// Ransom amount from principal zhu and interest bps (principal × (10000 + bps) / 10000). +fn mortgage_ransom_from_bps(principal_zhu: u64, interest_bps: u64) -> Ret { + let numerator = principal_zhu.saturating_mul(10_000 + interest_bps); + let zhu = (numerator / 10_000) as i64; + Amount::from_zhu(zhu) +} + + + +pub fn mortgage_redeem_phase( + contract: &DiamondSystemLending, + redeemer: &Address, + height: u64, + period_blocks: u64, +) -> Ret { + if contract.redeemed() { + return errf!("mortgage contract already redeemed"); + } + let t = contract.borrow_period.uint() as u64; + let create = contract.create_block_height.uint(); + let window = t.saturating_mul(period_blocks); + let private_end = create.saturating_add(window); + if height <= private_end { + if *redeemer != contract.main_address { + return errf!( + "mortgage private redeem only by mortgagor {} until height {}", + contract.main_address.readable(), + private_end + ); + } + return Ok(MortgageRedeemPhase::Private); + } + let public_end = private_end.saturating_add(window); + if height <= public_end { + return Ok(MortgageRedeemPhase::Public); + } + Ok(MortgageRedeemPhase::Auction) +} + +/// Minimum valid ransom for the current block height and redeemer. +pub fn mortgage_calc_ransom( + contract: &DiamondSystemLending, + redeemer: &Address, + height: u64, + period_blocks: u64, +) -> Ret<(MortgageRedeemPhase, Amount)> { + let phase = mortgage_redeem_phase(contract, redeemer, height, period_blocks)?; + let principal_zhu = mortgage_principal_zhu(&contract.loan_principal)?; + let t = contract.borrow_period.uint() as u64; + let create = contract.create_block_height.uint(); + let window = t.saturating_mul(period_blocks); + let private_end = create.saturating_add(window); + let public_end = private_end.saturating_add(window); + + let interest_bps = match phase { + MortgageRedeemPhase::Private => { + mortgage_private_interest_bps(create, height, period_blocks, t) + } + MortgageRedeemPhase::Public => { + mortgage_apr_interest_bps(height.saturating_sub(create)) + } + MortgageRedeemPhase::Auction => { + let max_bps = mortgage_apr_interest_bps(public_end.saturating_sub(create)); + let max_zhu = principal_zhu.saturating_mul(10_000 + max_bps) / 10_000; + let floor_zhu = + principal_zhu.saturating_mul(MORTGAGE_AUCTION_FLOOR_BPS) / 10_000; + let auction_elapsed = mortgage_elapsed_periods(public_end, height, period_blocks); + let auction_duration = t.saturating_mul(2); + let ransom_zhu = if auction_elapsed >= auction_duration { + floor_zhu + } else { + let span = max_zhu.saturating_sub(floor_zhu); + let decay = span.saturating_mul(auction_elapsed) / auction_duration.max(1); + max_zhu.saturating_sub(decay).max(floor_zhu) + }; + return Amount::from_zhu(ransom_zhu as i64).map(|a| (phase, a)); + } + }; + + mortgage_ransom_from_bps(principal_zhu, interest_bps).map(|a| (phase, a)) +} + +pub fn mortgage_is_active_at_height(state: &MintState, height: u64) -> bool { + state.mortgage_global().is_active_at(height) +} + +fn mortgage_record_burn(state: &mut MintState, zhu: u64) { + if zhu == 0 { + return; + } + let mut ttcount = state.total_count(); + ttcount.diamond_insc_burn_zhu = + Uint8::from(ttcount.diamond_insc_burn_zhu.uint().saturating_add(zhu)); + state.set_total_count(&ttcount); +} + +pub fn check_diamond_mortgageable( + state: &MintState, + owner: &Address, + hacd_name: &DiamondName, +) -> Ret { + let diaitem = must_have!( + format!("diamond {}", hacd_name.readable()), + state.diamond(hacd_name) + ); + if !diamond_status_allows_transfer(&diaitem.status) { + return errf!( + "diamond {} cannot be mortgaged while status is {}", + hacd_name.readable(), + diaitem.status.uint() + ); + } + if *owner != diaitem.address { + return errf!( + "diamond {} not belong to address {}", + hacd_name.readable(), + owner.readable() + ); + } + Ok(diaitem) +} + +pub fn mortgage_apply_open( + sta: &mut dyn State, + sto: &dyn Store, + owner: &Address, + lending_id: &DiamondSyslendId, + diamonds: &DiamondNameListMax200, + loan_amount: &Amount, + borrow_period: u8, + height: u64, +) -> Ret<()> { + let store = MintStoreDisk::wrap(sto); + mortgage_validate_lending_id(lending_id)?; + diamonds.check()?; + + let mut mint = MintState::wrap(sta); + let global = mint.mortgage_global(); + if !global.is_active_at(height) { + return errf!("HACD system mortgage is not active at height {}", height); + } + if mint.diamond_syslend(lending_id).is_some() { + return errf!("mortgage lending id already exists"); + } + + let t = borrow_period as u64; + if t < 1 || t > 20 { + return errf!("borrow period must be between 1 and 20"); + } + + let computed = mortgage_compute_principal(&store, diamonds)?; + if computed != *loan_amount { + return errf!( + "loan amount must be {} but got {}", + computed.to_fin_string(), + loan_amount.to_fin_string() + ); + } + + let principal_zhu = mortgage_principal_zhu(loan_amount)?; + let origination_zhu = mortgage_origination_fee_zhu(principal_zhu); + let new_outstanding = global.outstanding_ioo_zhu.uint().saturating_add(principal_zhu); + let cap = global.max_outstanding_ioo_zhu.uint(); + if cap > 0 && new_outstanding > cap { + return errf!( + "mortgage IOU cap exceeded: outstanding {} + loan {} > max {}", + global.outstanding_ioo_zhu.uint(), + principal_zhu, + cap + ); + } + + drop(mint); + + if origination_zhu > 0 { + let fee_amt = Amount::from_zhu(origination_zhu as i64)?; + let mut core = CoreState::wrap(sta); + hac_sub(&mut core, owner, &fee_amt)?; + } + + let mut mint = MintState::wrap(sta); + for dian in diamonds.list() { + let mut diaitem = check_diamond_mortgageable(&mint, owner, &dian)?; + diaitem.status = DIAMOND_STATUS_LENDING_TO_SYSTEM; + mint.set_diamond(&dian, &diaitem); + } + mortgage_drop_owned(&mut mint, owner, diamonds)?; + + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(height), + main_address: owner.clone(), + mortgage_diamonds: diamonds.clone(), + loan_principal: loan_amount.clone(), + borrow_period: Uint1::from(borrow_period), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + mint.set_diamond_syslend(lending_id, &contract); + + let mut owner_index = mint + .mortgage_owner_index(owner) + .unwrap_or_default(); + owner_index.push_id(lending_id)?; + mint.set_mortgage_owner_index(owner, &owner_index); + + let mut global = mint.mortgage_global(); + global.outstanding_ioo_zhu = Uint8::from(new_outstanding); + global.cumulative_loan_zhu = + Uint8::from(global.cumulative_loan_zhu.uint().saturating_add(principal_zhu)); + global.cumulative_origination_burn_zhu = Uint8::from( + global + .cumulative_origination_burn_zhu + .uint() + .saturating_add(origination_zhu), + ); + global.active_contracts = Uint5::from(global.active_contracts.uint() + 1); + mint.set_mortgage_global(&global); + + mortgage_record_burn(&mut mint, origination_zhu); + drop(mint); + + let dianum = diamonds.count().uint() as u32; + let mut core = CoreState::wrap(sta); + hacd_sub(&mut core, owner, &DiamondNumber::from(dianum))?; + hac_add(&mut core, owner, loan_amount)?; + Ok(()) +} + +pub fn mortgage_apply_redeem( + sta: &mut dyn State, + redeemer: &Address, + lending_id: &DiamondSyslendId, + ransom_amount: &Amount, + height: u64, +) -> Ret<()> { + mortgage_validate_lending_id(lending_id)?; + + let mut mint = MintState::wrap(sta); + let global = mint.mortgage_global(); + if !global.is_active_at(height) { + return errf!("HACD system mortgage is not active at height {}", height); + } + let period_blocks = global.effective_period_blocks(); + + let mut contract = must_have!( + format!("mortgage contract {:?}", lending_id), + mint.diamond_syslend(lending_id) + ); + if contract.redeemed() { + return errf!("mortgage contract already redeemed"); + } + + let (phase, min_ransom) = + mortgage_calc_ransom(&contract, redeemer, height, period_blocks)?; + if ransom_amount.less_than(&min_ransom) { + return errf!( + "ransom must be at least {} (phase {}) but got {}", + min_ransom.to_fin_string(), + phase.label(), + ransom_amount.to_fin_string() + ); + } + + drop(mint); + + let mut core = CoreState::wrap(sta); + hac_sub(&mut core, redeemer, ransom_amount)?; + drop(core); + + let mut mint = MintState::wrap(sta); + let mut contract = must_have!( + format!("mortgage contract {:?}", lending_id), + mint.diamond_syslend(lending_id) + ); + + let diamonds = contract.mortgage_diamonds.clone(); + diamonds.check()?; + for dian in diamonds.list() { + let mut diaitem = must_have!( + format!("diamond {}", dian.readable()), + mint.diamond(&dian) + ); + if diaitem.status != DIAMOND_STATUS_LENDING_TO_SYSTEM { + return errf!( + "diamond {} expected mortgage-to-system status", + dian.readable() + ); + } + diaitem.status = DIAMOND_STATUS_NORMAL; + diaitem.address = redeemer.clone(); + mint.set_diamond(&dian, &diaitem); + } + + contract.mark_ransomed(height, redeemer); + mint.set_diamond_syslend(lending_id, &contract); + + let principal_zhu = mortgage_principal_zhu(&contract.loan_principal)?; + let ransom_zhu = ransom_amount.to_zhu_unsafe().max(0.0) as u64; + + let mut global = mint.mortgage_global(); + global.outstanding_ioo_zhu = Uint8::from( + global + .outstanding_ioo_zhu + .uint() + .saturating_sub(principal_zhu), + ); + global.cumulative_ransom_burn_zhu = Uint8::from( + global + .cumulative_ransom_burn_zhu + .uint() + .saturating_add(ransom_zhu), + ); + global.active_contracts = Uint5::from(global.active_contracts.uint().saturating_sub(1)); + mint.set_mortgage_global(&global); + + let owner = contract.main_address.clone(); + let mut owner_index = mint + .mortgage_owner_index(&owner) + .unwrap_or_default(); + owner_index.remove_id(lending_id)?; + if owner_index.ids.length() > 0 { + mint.set_mortgage_owner_index(&owner, &owner_index); + } else { + mint.del_mortgage_owner_index(&owner); + } + + mortgage_record_burn(&mut mint, ransom_zhu); + diamond_owned_push_batch(&mut mint, redeemer, &diamonds)?; + drop(mint); + + let dianum = diamonds.count().uint() as u32; + let mut core = CoreState::wrap(sta); + hacd_add(&mut core, redeemer, &DiamondNumber::from(dianum))?; + Ok(()) +} + +fn mortgage_drop_owned( + state: &mut MintState, + owner: &Address, + diamonds: &DiamondNameListMax200, +) -> Ret<()> { + let mut owned = must_have!( + format!("diamond owned for {}", owner.readable()), + state.diamond_owned(owner) + ); + let remaining = owned.drop(diamonds)?; + if remaining > 0 { + state.set_diamond_owned(owner, &owned); + } else { + state.del_diamond_owned(owner); + } + Ok(()) +} + +fn diamond_owned_push_batch( + state: &mut MintState, + owner: &Address, + diamonds: &DiamondNameListMax200, +) -> Ret<()> { + let mut owned = state.diamond_owned(owner).unwrap_or_default(); + owned.push(diamonds); + state.set_diamond_owned(owner, &owned); + Ok(()) +} + +#[cfg(test)] +mod mortgage_tests { + use super::*; + use crate::core::state::{BlockStore, ChainState}; + use tempfile::TempDir; + + fn test_state() -> (TempDir, ChainState, BlockStore) { + let dir = TempDir::new().unwrap(); + let path = dir.path(); + let state = ChainState::open(path); + let store = BlockStore::from_shared(state.copy_ldb()); + (dir, state, store) + } + + fn test_owner() -> Address { + Address::from_readable("12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi").unwrap() + } + + fn test_other() -> Address { + Address::from_readable("1LsQLqkd8FQDh3R7ZhxC5fndNf92WfhM19").unwrap() + } + + fn test_lend_id(tag: u8) -> DiamondSyslendId { + let mut bytes = [0u8; 14]; + bytes[0] = b'M'; + bytes[1] = tag; + bytes[13] = b'Z'; + DiamondSyslendId::cons(bytes) + } + + fn lit(name: &[u8; 6]) -> DiamondName { + DiamondName::cons(*name) + } + + fn one_diamond_list(name: &str) -> DiamondNameListMax200 { + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(name.as_bytes().try_into().unwrap())) + .unwrap(); + list + } + + fn activate_mortgage(state: &mut ChainState, demo_periods: bool) { + let mut mint = MintState::wrap(state); + let mut global = mint.mortgage_global(); + global.activation_height = BlockHeight::from(1); + global.max_outstanding_ioo_zhu = + Uint8::from(MORTGAGE_DEFAULT_MAX_OUTSTANDING_ZHU); + if demo_periods { + global.demo_period_blocks = Uint5::from(10); + } + mint.set_mortgage_global(&global); + } + + fn seed_diamond_with_smelt( + state: &mut ChainState, + store: &BlockStore, + name: &str, + owner: &Address, + average_bid_burn_mei: u16, + ) -> DiamondName { + let dian = DiamondName::cons(name.as_bytes().try_into().unwrap()); + let dia = DiamondSto { + status: DIAMOND_STATUS_NORMAL, + address: owner.clone(), + prev_engraved_height: BlockHeight::from(0), + inscripts: Inscripts::default(), + }; + let smelt = DiamondSmelt { + diamond: dian.clone(), + number: DiamondNumber::from(1), + born_height: BlockHeight::from(1), + born_hash: Hash::default(), + prev_hash: Hash::default(), + miner_address: owner.clone(), + bid_fee: Amount::default(), + nonce: Fixed8::default(), + average_bid_burn: Uint2::from(average_bid_burn_mei), + life_gene: Hash::default(), + }; + let mut mint = MintState::wrap(state); + mint.set_diamond(&dian, &dia); + diamond_owned_push_one(&mut mint, owner, &dian); + let mint_store = MintStoreDisk::wrap(store); + mint_store.put_diamond_smelt(&dian, &smelt); + dian + } + + fn fund_owner(state: &mut ChainState, owner: &Address, hac_mei: i64, hacd_count: u32) { + let mut core = CoreState::wrap(state); + let mut bal = Balance::hacash(Amount::from_mei(hac_mei).unwrap()); + bal.diamond = DiamondNumberAuto::from(hacd_count as u64); + core.set_balance(owner, &bal); + } + + fn hac_balance(state: &ChainState, addr: &Address) -> Amount { + let core = CoreStateDisk::wrap(state); + core.balance(addr) + .map(|b| b.hacash.clone()) + .unwrap_or_default() + } + + #[test] + fn lending_id_validation_rejects_bad_format() { + let mut bytes = [0u8; 14]; + let id = DiamondSyslendId::cons(bytes); + assert!(mortgage_validate_lending_id(&id).is_err()); + bytes[0] = 1; + bytes[13] = 0; + let id2 = DiamondSyslendId::cons(bytes); + assert!(mortgage_validate_lending_id(&id2).is_err()); + } + + #[test] + fn origination_fee_is_one_percent() { + assert_eq!(mortgage_origination_fee_zhu(10_000), 100); + assert_eq!(mortgage_origination_fee_zhu(1_000_000), 10_000); + } + + #[test] + fn origination_burn_amount_for_testnet_principal() { + let principal = Amount::from_mei(200).unwrap(); + let burn = mortgage_origination_burn(&principal).unwrap(); + assert_eq!(burn.to_fin_string(), "2:248"); + } + + #[test] + fn private_early_redeem_zero_interest_at_open() { + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(100), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(100).unwrap(), + borrow_period: Uint1::from(10), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let (_, ransom) = mortgage_calc_ransom(&contract, &test_owner(), 100, 10).unwrap(); + assert_eq!(ransom, Amount::from_mei(100).unwrap()); + } + + #[test] + fn grace_period_zero_interest_for_three_periods() { + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(100), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(1000).unwrap(), + borrow_period: Uint1::from(10), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let (_, ransom) = mortgage_calc_ransom(&contract, &test_owner(), 130, 10).unwrap(); + assert_eq!(ransom, Amount::from_mei(1000).unwrap()); + } + + #[test] + fn private_early_redeem_scales_tenth_percent_after_grace() { + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(100), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(1000).unwrap(), + borrow_period: Uint1::from(10), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let (_, ransom) = mortgage_calc_ransom(&contract, &test_owner(), 140, 10).unwrap(); + // 4 elapsed periods: grace 3 + 1 chargeable × 0.1% = 0.1% + let principal_zhu = mortgage_principal_zhu(&contract.loan_principal).unwrap(); + let expected = mortgage_ransom_from_bps(principal_zhu, 10).unwrap(); + assert_eq!(ransom.to_fin_string(), expected.to_fin_string()); + } + + #[test] + fn apr_accrues_three_percent_per_year() { + assert_eq!(mortgage_apr_interest_bps(MORTGAGE_BLOCKS_PER_YEAR), 300); + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(0), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(1000).unwrap(), + borrow_period: Uint1::from(10), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let (_, ransom) = + mortgage_calc_ransom(&contract, &test_owner(), MORTGAGE_BLOCKS_PER_YEAR, 10) + .unwrap(); + let principal_zhu = mortgage_principal_zhu(&contract.loan_principal).unwrap(); + let expected = mortgage_ransom_from_bps(principal_zhu, 300).unwrap(); + assert_eq!(ransom.to_fin_string(), expected.to_fin_string()); + } + + #[test] + fn public_redeem_allows_anyone_at_apr() { + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(0), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(500).unwrap(), + borrow_period: Uint1::from(5), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let period = 10u64; + let public_h = 5 * period + MORTGAGE_BLOCKS_PER_YEAR; + let (_, ransom) = + mortgage_calc_ransom(&contract, &test_other(), public_h, period).unwrap(); + let principal_zhu = mortgage_principal_zhu(&contract.loan_principal).unwrap(); + let bps = mortgage_apr_interest_bps(public_h); + let expected = mortgage_ransom_from_bps(principal_zhu, bps).unwrap(); + assert_eq!(ransom.to_fin_string(), expected.to_fin_string()); + } + + #[test] + fn auction_decays_to_floor_103_percent() { + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(0), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(1000).unwrap(), + borrow_period: Uint1::from(2), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let period = 10u64; + let public_end = 2 * 2 * period; + let floor_h = public_end + 2 * 2 * period; + let (_, ransom) = + mortgage_calc_ransom(&contract, &test_other(), floor_h, period).unwrap(); + let expected = Amount::from_mei(1030).unwrap(); + assert_eq!(ransom.to_fin_string(), expected.to_fin_string()); + } + + #[test] + fn borrow_period_does_not_scale_total_apr() { + let short = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(0), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(1000).unwrap(), + borrow_period: Uint1::from(5), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let long = DiamondSystemLending { + borrow_period: Uint1::from(20), + ..short.clone() + }; + let h = MORTGAGE_BLOCKS_PER_YEAR; + let (_, r_short) = mortgage_calc_ransom(&short, &test_owner(), h, 10).unwrap(); + let (_, r_long) = mortgage_calc_ransom(&long, &test_owner(), h, 10).unwrap(); + assert_eq!(r_short.to_fin_string(), r_long.to_fin_string()); + } + + #[test] + fn private_redeem_rejects_non_owner() { + let contract = DiamondSystemLending { + is_ransomed: Uint1::from(0), + create_block_height: BlockHeight::from(0), + main_address: test_owner(), + mortgage_diamonds: DiamondNameListMax200::default(), + loan_principal: Amount::from_mei(100).unwrap(), + borrow_period: Uint1::from(2), + ransom_block_height: BlockHeight::from(0), + ransom_address: Address::default(), + }; + let err = mortgage_calc_ransom(&contract, &test_other(), 5, 10).unwrap_err(); + assert!(format!("{}", err).contains("private redeem")); + } + + #[test] + fn open_mortgage_credits_loan_and_burns_origination() { + let (_dir, mut state, store) = test_state(); + activate_mortgage(&mut state, true); + let owner = test_owner(); + seed_diamond_with_smelt(&mut state, &store, "WTYUIA", &owner, 100); + fund_owner(&mut state, &owner, 50, 1); + + let list = one_diamond_list("WTYUIA"); + let principal = Amount::from_mei(100).unwrap(); + let lend_id = test_lend_id(1); + + mortgage_apply_open( + &mut state, + &store, + &owner, + &lend_id, + &list, + &principal, + 5, + 10, + ) + .unwrap(); + + let mint = MintStateDisk::wrap(&state); + let dia = mint.diamond(&lit(b"WTYUIA")).unwrap(); + assert_eq!(dia.status, DIAMOND_STATUS_LENDING_TO_SYSTEM); + assert_eq!(mint.mortgage_global().outstanding_ioo_zhu.uint(), 100_0000_0000); + assert_eq!(mint.mortgage_global().cumulative_origination_burn_zhu.uint(), 1_0000_0000); + // 50 HAC start - 1 HAC origination + 100 HAC loan = 149 HAC + let bal = hac_balance(&state, &owner); + assert_eq!(bal, Amount::from_mei(149).unwrap()); + } + + #[test] + fn redeem_private_returns_diamond_to_owner() { + let (_dir, mut state, store) = test_state(); + activate_mortgage(&mut state, true); + let owner = test_owner(); + seed_diamond_with_smelt(&mut state, &store, "WTYUIA", &owner, 200); + fund_owner(&mut state, &owner, 10, 1); + let list = one_diamond_list("WTYUIA"); + let principal = Amount::from_mei(200).unwrap(); + let lend_id = test_lend_id(2); + + mortgage_apply_open( + &mut state, + &store, + &owner, + &lend_id, + &list, + &principal, + 4, + 10, + ) + .unwrap(); + + let (_, min_ransom) = { + let mint = MintStateDisk::wrap(&state); + let c = mint.diamond_syslend(&lend_id).unwrap(); + mortgage_calc_ransom(&c, &owner, 10, 10).unwrap() + }; + + mortgage_apply_redeem(&mut state, &owner, &lend_id, &min_ransom, 10).unwrap(); + + let mint = MintStateDisk::wrap(&state); + assert!(mint.diamond_syslend(&lend_id).unwrap().redeemed()); + assert_eq!(mint.diamond(&lit(b"WTYUIA")).unwrap().status, DIAMOND_STATUS_NORMAL); + assert_eq!(mint.mortgage_global().outstanding_ioo_zhu.uint(), 0); + } + + #[test] + fn iou_cap_rejects_excess_loan() { + let (_dir, mut state, store) = test_state(); + activate_mortgage(&mut state, true); + let owner = test_owner(); + seed_diamond_with_smelt(&mut state, &store, "WTYUIA", &owner, 1000); + fund_owner(&mut state, &owner, 500, 1); + + let mut mint = MintState::wrap(&mut state); + let mut global = mint.mortgage_global(); + global.max_outstanding_ioo_zhu = Uint8::from(50_0000_0000); // 50 HAC + mint.set_mortgage_global(&global); + + let list = one_diamond_list("WTYUIA"); + let principal = Amount::from_mei(1000).unwrap(); + let err = mortgage_apply_open( + &mut state, + &store, + &owner, + &test_lend_id(3), + &list, + &principal, + 3, + 10, + ) + .unwrap_err(); + assert!(format!("{}", err).contains("IOU cap")); + } + + #[test] + fn staked_diamond_cannot_be_mortgaged() { + let (_dir, mut state, store) = test_state(); + activate_mortgage(&mut state, true); + let owner = test_owner(); + seed_diamond_with_smelt(&mut state, &store, "WTYUIA", &owner, 100); + fund_owner(&mut state, &owner, 20, 1); + + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let mut global = mint.staking_global(); + global.activation_height = BlockHeight::from(1); + mint.set_staking_global(&global); + staking_apply_stake(&mut mint, &owner, &list, 10).unwrap(); + drop(mint); + + let err = mortgage_apply_open( + &mut state, + &store, + &owner, + &test_lend_id(4), + &list, + &Amount::from_mei(100).unwrap(), + 2, + 10, + ) + .unwrap_err(); + assert!(format!("{}", err).contains("cannot be mortgaged")); + } + + #[test] + fn wrong_loan_amount_rejected() { + let (_dir, mut state, store) = test_state(); + activate_mortgage(&mut state, true); + let owner = test_owner(); + seed_diamond_with_smelt(&mut state, &store, "WTYUIA", &owner, 100); + fund_owner(&mut state, &owner, 20, 1); + + let err = mortgage_apply_open( + &mut state, + &store, + &owner, + &test_lend_id(5), + &one_diamond_list("WTYUIA"), + &Amount::from_mei(99).unwrap(), + 2, + 10, + ) + .unwrap_err(); + assert!(format!("{}", err).contains("loan amount must")); + } +} \ No newline at end of file diff --git a/src/mint/operate/diamond_lending_e2e.rs b/src/mint/operate/diamond_lending_e2e.rs new file mode 100644 index 0000000..4d5b6c7 --- /dev/null +++ b/src/mint/operate/diamond_lending_e2e.rs @@ -0,0 +1,351 @@ +// Full transaction pipeline tests: sign → exec_tx_actions → tx.execute (HIP-2). + +#[cfg(test)] +mod mortgage_e2e_tests { + use super::*; + use crate::chain::execute::exec_tx_actions; + use crate::core::account::Account; + use crate::core::state::{BlockStore, ChainState}; + use crate::interface::protocol::{Transaction, TransactionRead, TxExec}; + use crate::mint::action::{self, MortgageOpen, MortgageRedeem}; + use crate::protocol::action::create as parse_action; + use crate::protocol::transaction::TransactionType2; + use tempfile::TempDir; + + fn e2e_state() -> (TempDir, ChainState, BlockStore, Account) { + action::init_reg(); + let dir = TempDir::new().unwrap(); + let path = dir.path(); + let state = ChainState::open(path); + let store = BlockStore::from_shared(state.copy_ldb()); + let acc = Account::create_by_password("hip2e2etest").unwrap(); + (dir, state, store, acc) + } + + fn e2e_owner(acc: &Account) -> Address { + Address::cons(*acc.address()) + } + + fn e2e_lend_id(tag: u8) -> DiamondSyslendId { + let mut bytes = [0u8; 14]; + bytes[0] = b'H'; + bytes[1] = tag; + bytes[13] = b'2'; + DiamondSyslendId::cons(bytes) + } + + fn e2e_activate(state: &mut ChainState) { + let mut mint = MintState::wrap(state); + let mut mg = mint.mortgage_global(); + mg.activation_height = BlockHeight::from(1); + mg.max_outstanding_ioo_zhu = Uint8::from(MORTGAGE_DEFAULT_MAX_OUTSTANDING_ZHU); + mg.demo_period_blocks = Uint5::from(10); + mint.set_mortgage_global(&mg); + } + + fn e2e_seed( + state: &mut ChainState, + store: &BlockStore, + owner: &Address, + name: &str, + burn_mei: u16, + hac_mei: i64, + ) -> DiamondName { + let dian = DiamondName::cons(name.as_bytes().try_into().unwrap()); + let dia = DiamondSto { + status: DIAMOND_STATUS_NORMAL, + address: owner.clone(), + prev_engraved_height: BlockHeight::from(0), + inscripts: Inscripts::default(), + }; + let smelt = DiamondSmelt { + diamond: dian.clone(), + number: DiamondNumber::from(1), + born_height: BlockHeight::from(1), + born_hash: Hash::default(), + prev_hash: Hash::default(), + miner_address: owner.clone(), + bid_fee: Amount::default(), + nonce: Fixed8::default(), + average_bid_burn: Uint2::from(burn_mei), + life_gene: Hash::default(), + }; + let mut mint = MintState::wrap(state); + mint.set_diamond(&dian, &dia); + diamond_owned_push_one(&mut mint, owner, &dian); + drop(mint); + let mut core = CoreState::wrap(state); + let mut bal = Balance::hacash(Amount::from_mei(hac_mei).unwrap()); + bal.diamond = DiamondNumberAuto::from(1); + core.set_balance(owner, &bal); + MintStoreDisk::wrap(store).put_diamond_smelt(&dian, &smelt); + dian + } + + fn e2e_fee() -> Amount { + Amount::from_string_unsafe("0:247").unwrap() + } + + fn e2e_run_tx( + state: &mut ChainState, + store: &BlockStore, + tx: &TransactionType2, + height: u64, + ) { + let blkhash = Hash::default(); + exec_tx_actions(false, 0, height, blkhash, state, store, tx.as_read()).unwrap(); + tx.execute(height, state).unwrap(); + } + + fn e2e_build_open_tx( + acc: &Account, + lend_id: &DiamondSyslendId, + diamonds: &DiamondNameListMax200, + principal: &Amount, + borrow_period: u8, + ) -> TransactionType2 { + let mut tx = TransactionType2::build(e2e_owner(acc), e2e_fee()); + tx.timestamp = Timestamp::from(1_700_000_000); + let mut act = MortgageOpen::new(); + act.lending_id = lend_id.clone(); + act.mortgage_diamonds = diamonds.clone(); + act.loan_total_amount = principal.clone(); + act.borrow_period = Uint1::from(borrow_period); + tx.push_action(Box::new(act)).unwrap(); + tx.fill_sign(acc).unwrap(); + tx + } + + fn e2e_build_redeem_tx( + acc: &Account, + lend_id: &DiamondSyslendId, + ransom: &Amount, + ) -> TransactionType2 { + let mut tx = TransactionType2::build(e2e_owner(acc), e2e_fee()); + tx.timestamp = Timestamp::from(1_700_000_000); + let mut act = MortgageRedeem::new(); + act.lending_id = lend_id.clone(); + act.ransom_amount = ransom.clone(); + tx.push_action(Box::new(act)).unwrap(); + tx.fill_sign(acc).unwrap(); + tx + } + + #[test] + fn e2e_open_action_wire_roundtrip_kind_15() { + action::init_reg(); + let mut act = MortgageOpen::new(); + act.lending_id = e2e_lend_id(9); + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(*b"WTYUIA")).unwrap(); + act.mortgage_diamonds = list; + act.loan_total_amount = Amount::from_mei(50).unwrap(); + act.borrow_period = Uint1::from(5); + let wire = act.serialize(); + assert_eq!(wire.len() >= 2, true); + assert_eq!(u16::from_be_bytes([wire[0], wire[1]]), 15); + let (parsed, sk) = parse_action(&wire).unwrap(); + assert_eq!(parsed.kind(), 15); + assert_eq!(sk, wire.len()); + } + + #[test] + fn e2e_redeem_action_wire_roundtrip_kind_16() { + action::init_reg(); + let mut act = MortgageRedeem::new(); + act.lending_id = e2e_lend_id(8); + act.ransom_amount = Amount::from_mei(51).unwrap(); + let wire = act.serialize(); + assert_eq!(u16::from_be_bytes([wire[0], wire[1]]), 16); + let (parsed, _) = parse_action(&wire).unwrap(); + assert_eq!(parsed.kind(), 16); + } + + #[test] + fn e2e_go_compatible_field_order_kind_15() { + // Matches hacash/core Action_15: kind | lending_id(14) | diamond_list | amount | borrow_period(1) + action::init_reg(); + let mut act = MortgageOpen::new(); + act.lending_id = e2e_lend_id(1); + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(*b"ABCDEF")).unwrap(); + act.mortgage_diamonds = list; + act.loan_total_amount = Amount::from_mei(10).unwrap(); + act.borrow_period = Uint1::from(3); + let wire = act.serialize(); + let mut seek = 2usize; + let mut lid = DiamondSyslendId::default(); + seek = lid.parse(&wire, seek).unwrap(); + let mut dlist = DiamondNameListMax200::default(); + seek = dlist.parse(&wire, seek).unwrap(); + let mut amt = Amount::default(); + seek = amt.parse(&wire, seek).unwrap(); + let mut bp = Uint1::default(); + seek = bp.parse(&wire, seek).unwrap(); + assert_eq!(seek, wire.len()); + assert_eq!(amt, act.loan_total_amount); + assert_eq!(bp.uint(), 3); + } + + #[test] + fn e2e_full_tx_open_then_redeem_via_pipeline() { + let (_dir, mut state, store, acc) = e2e_state(); + let owner = e2e_owner(&acc); + e2e_activate(&mut state); + e2e_seed(&mut state, &store, &owner, "WTYUIA", 100, 20); + + let list = { + let mut l = DiamondNameListMax200::default(); + l.push(DiamondName::cons(*b"WTYUIA")).unwrap(); + l + }; + let lend_id = e2e_lend_id(1); + let principal = Amount::from_mei(100).unwrap(); + let open_tx = e2e_build_open_tx(&acc, &lend_id, &list, &principal, 5); + e2e_run_tx(&mut state, &store, &open_tx, 10); + + let mint = MintStateDisk::wrap(&state); + assert!(mint.diamond_syslend(&lend_id).is_some()); + assert_eq!( + mint.diamond(&DiamondName::cons(*b"WTYUIA")) + .unwrap() + .status, + DIAMOND_STATUS_LENDING_TO_SYSTEM + ); + + let contract = mint.diamond_syslend(&lend_id).unwrap(); + let (_, min_ransom) = mortgage_calc_ransom(&contract, &owner, 10, 10).unwrap(); + let redeem_tx = e2e_build_redeem_tx(&acc, &lend_id, &min_ransom); + e2e_run_tx(&mut state, &store, &redeem_tx, 10); + + let mint = MintStateDisk::wrap(&state); + assert!(mint.diamond_syslend(&lend_id).unwrap().redeemed()); + assert_eq!( + mint.diamond(&DiamondName::cons(*b"WTYUIA")) + .unwrap() + .status, + DIAMOND_STATUS_NORMAL + ); + assert_eq!(mint.mortgage_global().outstanding_ioo_zhu.uint(), 0); + } + + #[test] + fn e2e_open_tx_rejected_before_activation() { + let (_dir, mut state, store, acc) = e2e_state(); + let owner = e2e_owner(&acc); + e2e_seed(&mut state, &store, &owner, "WTYUIA", 100, 20); + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(*b"WTYUIA")).unwrap(); + let tx = e2e_build_open_tx( + &acc, + &e2e_lend_id(2), + &list, + &Amount::from_mei(100).unwrap(), + 3, + ); + let err = exec_tx_actions(false, 0, 5, Hash::default(), &mut state, &store, tx.as_read()) + .unwrap_err(); + assert!(format!("{}", err).contains("not active")); + } + + #[test] + fn e2e_double_open_same_id_rejected() { + let (_dir, mut state, store, acc) = e2e_state(); + let owner = e2e_owner(&acc); + e2e_activate(&mut state); + e2e_seed(&mut state, &store, &owner, "WTYUIA", 100, 20); + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(*b"WTYUIA")).unwrap(); + let lend_id = e2e_lend_id(3); + let principal = Amount::from_mei(100).unwrap(); + let tx1 = e2e_build_open_tx(&acc, &lend_id, &list, &principal, 3); + e2e_run_tx(&mut state, &store, &tx1, 10); + let tx2 = e2e_build_open_tx(&acc, &lend_id, &list, &principal, 3); + let err = exec_tx_actions(false, 0, 11, Hash::default(), &mut state, &store, tx2.as_read()) + .unwrap_err(); + assert!(format!("{}", err).contains("already exists")); + } + + #[test] + fn e2e_redeem_twice_rejected() { + let (_dir, mut state, store, acc) = e2e_state(); + let owner = e2e_owner(&acc); + e2e_activate(&mut state); + e2e_seed(&mut state, &store, &owner, "HXVMEK", 50, 10); + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(*b"HXVMEK")).unwrap(); + let lend_id = e2e_lend_id(4); + let principal = Amount::from_mei(50).unwrap(); + e2e_run_tx( + &mut state, + &store, + &e2e_build_open_tx(&acc, &lend_id, &list, &principal, 2), + 10, + ); + let contract = MintStateDisk::wrap(&state).diamond_syslend(&lend_id).unwrap(); + let (_, ransom) = mortgage_calc_ransom(&contract, &owner, 10, 10).unwrap(); + e2e_run_tx( + &mut state, + &store, + &e2e_build_redeem_tx(&acc, &lend_id, &ransom), + 10, + ); + let err = exec_tx_actions( + false, + 0, + 10, + Hash::default(), + &mut state, + &store, + e2e_build_redeem_tx(&acc, &lend_id, &ransom).as_read(), + ) + .unwrap_err(); + assert!(format!("{}", err).contains("already redeemed")); + } + + #[test] + fn e2e_staked_diamond_open_tx_rejected() { + let (_dir, mut state, store, acc) = e2e_state(); + let owner = e2e_owner(&acc); + e2e_activate(&mut state); + e2e_seed(&mut state, &store, &owner, "WTYUIA", 100, 20); + let mut mint = MintState::wrap(&mut state); + let mut sg = mint.staking_global(); + sg.activation_height = BlockHeight::from(1); + mint.set_staking_global(&sg); + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(*b"WTYUIA")).unwrap(); + staking_apply_stake(&mut mint, &owner, &list, 10).unwrap(); + drop(mint); + let tx = e2e_build_open_tx( + &acc, + &e2e_lend_id(5), + &list, + &Amount::from_mei(100).unwrap(), + 2, + ); + let err = exec_tx_actions(false, 0, 10, Hash::default(), &mut state, &store, tx.as_read()) + .unwrap_err(); + assert!(format!("{}", err).contains("cannot be mortgaged")); + } + + #[test] + fn e2e_insufficient_origination_balance_rejected() { + let (_dir, mut state, store, acc) = e2e_state(); + let owner = e2e_owner(&acc); + e2e_activate(&mut state); + e2e_seed(&mut state, &store, &owner, "WTYUIA", 100, 0); + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(*b"WTYUIA")).unwrap(); + let tx = e2e_build_open_tx( + &acc, + &e2e_lend_id(6), + &list, + &Amount::from_mei(100).unwrap(), + 2, + ); + let err = exec_tx_actions(false, 0, 10, Hash::default(), &mut state, &store, tx.as_read()) + .unwrap_err(); + assert!(format!("{}", err).contains("not enough")); + } +} \ No newline at end of file diff --git a/src/mint/operate/mod.rs b/src/mint/operate/mod.rs index a003a5f..2d0e86b 100644 --- a/src/mint/operate/mod.rs +++ b/src/mint/operate/mod.rs @@ -13,11 +13,17 @@ use crate::protocol::operate::*; use super::state::*; use super::component::*; +#[cfg(not(target_arch = "wasm32"))] use super::coinbase::*; +#[cfg(not(target_arch = "wasm32"))] include!("channel.rs"); include!("diamond.rs"); +include!("staking.rs"); +include!("diamond_lending.rs"); +#[cfg(test)] +include!("diamond_lending_e2e.rs"); diff --git a/src/mint/operate/staking.rs b/src/mint/operate/staking.rs new file mode 100644 index 0000000..5fd72d5 --- /dev/null +++ b/src/mint/operate/staking.rs @@ -0,0 +1,890 @@ + use crate::mint::action::ACTION_KIND_ID_DIAMOND_MINT; + +fn staking_accrued_zhu(global_index: &Uint8, snapshot: &Uint8) -> u64 { + global_index.uint().saturating_sub(snapshot.uint()) +} + +pub fn staking_accrued_amount(global_index: &Uint8, snapshot: &Uint8) -> Ret { + let zhu = staking_accrued_zhu(global_index, snapshot) as i64; + if zhu <= 0 { + return Ok(Amount::default()); + } + Amount::from_zhu(zhu) +} + +/// Live accrual while staked; fixed `pending_reward` during cooldown (HIP-25 v1). +pub fn staking_display_accrued_reward( + global_index: &Uint8, + record: &StakingRecord, +) -> Ret { + if record.is_active_stake() { + staking_accrued_amount(global_index, &record.reward_index) + } else { + Ok(record.pending_reward.clone()) + } +} + +pub fn staking_is_active_at_height(state: &MintState, height: u64) -> bool { + state.staking_global().is_active_at(height) +} + +/// HIP-25 v3: miner-visible share of a burn_90 tx fee (10% — matches `Transaction::fee_got`). +pub fn staking_mint_miner_share_zhu(fee_zhu: u64) -> u64 { + fee_zhu * STAKING_FEE_SHARE_PERCENT / 100 +} + +/// True when tx fee miner share should fund the staking pool (DiamondMint bid only). +pub fn staking_tx_qualifies_for_mint_fee_redirect(tx: &dyn TransactionRead) -> bool { + if !tx.burn_90() { + return false; + } + for act in tx.actions() { + if act.kind() == ACTION_KIND_ID_DIAMOND_MINT { + return true; + } + } + false +} + +/// Deposit HACD mint miner-share into the staking reward pool (v3). +pub fn staking_deposit_mint_miner_share(state: &mut MintState, fee: &Amount) { + if !fee.is_positive() { + return; + } + let zhu = fee.to_zhu_unsafe().max(0.0) as u64; + staking_deposit_fee(state, zhu); +} + +fn staking_push_event(state: &mut MintState, event: &StakingEvent) { + let mut global = state.staking_global(); + let id = global.event_log_tail.uint(); + state.set_staking_event(&Uint5::from(id), event); + global.event_log_tail = Uint5::from(id + 1); + state.set_staking_global(&global); +} + +pub fn staking_deposit_fee(state: &mut MintState, fee_zhu: u64) { + if fee_zhu == 0 { + return; + } + let mut global = state.staking_global(); + global.reward_pool_zhu = Uint8::from(global.reward_pool_zhu.uint() + fee_zhu); + global.cumulative_deposit_zhu = + Uint8::from(global.cumulative_deposit_zhu.uint() + fee_zhu); + state.set_staking_global(&global); +} + +/// When no stakers exist, burn undistributed pool after `STAKING_POOL_SWEEP_BLOCKS` (HIP-11 alignment). +pub fn staking_sweep_idle_pool(state: &mut MintState, height: u64) -> Ret<()> { + let mut global = state.staking_global(); + let shares = global.total_staked_shares.uint(); + let pool = global.reward_pool_zhu.uint(); + if shares > 0 || pool == 0 { + global.zero_staker_blocks = Uint5::from(0); + state.set_staking_global(&global); + return Ok(()); + } + let idle = global.zero_staker_blocks.uint() + 1; + global.zero_staker_blocks = Uint5::from(idle); + if idle < STAKING_POOL_SWEEP_BLOCKS { + state.set_staking_global(&global); + return Ok(()); + } + global.reward_pool_zhu = Uint8::from(0); + global.zero_staker_blocks = Uint5::from(0); + global.cumulative_pool_burned_zhu = + Uint8::from(global.cumulative_pool_burned_zhu.uint() + pool); + state.set_staking_global(&global); + let mut ttcount = state.total_count(); + ttcount.hacd_bid_burn_zhu = Uint8::from(ttcount.hacd_bid_burn_zhu.uint() + pool); + state.set_total_count(&ttcount); + staking_push_event( + state, + &StakingEvent { + kind: STAKING_EVENT_POOL_SWEPT, + height: BlockHeight::from(height), + diamond: DiamondName::default(), + staker: Address::default(), + unlock_height: BlockHeight::from(0), + reward: Amount::from_zhu(pool as i64).unwrap_or_default(), + shares: Uint5::from(0), + }, + ); + Ok(()) +} + +pub fn staking_distribute_rewards(state: &mut MintState, height: u64) -> Ret<()> { + let mut global = state.staking_global(); + let shares = global.total_staked_shares.uint(); + let pool = global.reward_pool_zhu.uint(); + if shares == 0 || pool == 0 { + return Ok(()); + } + let increment = pool / shares; + if increment > 0 { + global.global_reward_index = + Uint8::from(global.global_reward_index.uint() + increment); + } + // dust stays in pool when increment rounds to zero + let distributed = increment * shares; + if distributed >= pool { + global.reward_pool_zhu = Uint8::from(0); + } else { + global.reward_pool_zhu = Uint8::from(pool - distributed); + } + state.set_staking_global(&global); + if distributed > 0 { + let reward = Amount::from_zhu(distributed as i64).unwrap_or_default(); + staking_push_event( + state, + &StakingEvent { + kind: STAKING_EVENT_REWARD_DISTRIBUTED, + height: BlockHeight::from(height), + diamond: DiamondName::default(), + staker: Address::default(), + unlock_height: BlockHeight::from(0), + reward, + shares: Uint5::from(shares), + }, + ); + } + Ok(()) +} + +fn staking_enqueue_unlock(state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { + let mut global = state.staking_global(); + let id = global.unlock_queue_tail.uint(); + let key = Uint5::from(id); + state.set_staking_unlock_entry(&key, entry); + global.unlock_queue_tail = Uint5::from(id + 1); + state.set_staking_global(&global); + Ok(()) +} + +fn staking_finalize_unlock(mint_state: &mut MintState, entry: &StakingUnlockEntry) -> Ret<()> { + let dianame = &entry.diamond; + let mut diaitem = must_have!( + format!("diamond {}", dianame.readable()), + mint_state.diamond(dianame) + ); + if diaitem.status != DIAMOND_STATUS_STAKING_COOLDOWN { + return errf!( + "diamond {} unlock failed: expected cooldown status", + dianame.readable() + ); + } + diaitem.status = DIAMOND_STATUS_NORMAL; + mint_state.set_diamond(dianame, &diaitem); + mint_state.del_staking_record(dianame); + + staking_push_event( + mint_state, + &StakingEvent { + kind: STAKING_EVENT_UNSTAKED, + height: entry.unlock_height.clone(), + diamond: entry.diamond.clone(), + staker: entry.staker.clone(), + unlock_height: entry.unlock_height.clone(), + reward: entry.reward.clone(), + shares: Uint5::from(0), + }, + ); + + Ok(()) +} + +pub fn staking_process_unlock_queue(base_state: &mut dyn State, height: u64) -> Ret<()> { + let mut pending: Vec<(Uint5, StakingUnlockEntry)> = Vec::new(); + + { + let mut mint_state = MintState::wrap(base_state); + let mut global = mint_state.staking_global(); + let mut head = global.unlock_queue_head.uint(); + let tail = global.unlock_queue_tail.uint(); + + while head < tail { + let key = Uint5::from(head); + let entry = match mint_state.staking_unlock_entry(&key) { + Some(e) => e, + None => { + return errf!( + "staking unlock queue corrupted: missing entry {} (head {} tail {})", + head, + head, + tail + ); + } + }; + if entry.unlock_height.uint() > height { + break; + } + pending.push((key, entry)); + head += 1; + } + + global.unlock_queue_head = Uint5::from(head); + mint_state.set_staking_global(&global); + } + + for (key, entry) in pending { + let reward = entry.reward.clone(); + let staker = entry.staker.clone(); + if reward.is_positive() { + let mut core_state = CoreState::wrap(base_state); + hac_add(&mut core_state, &staker, &reward)?; + let mut mint_state = MintState::wrap(base_state); + let mut global = mint_state.staking_global(); + let paid = reward.to_zhu_unsafe().max(0.0) as u64; + global.cumulative_paid_zhu = + Uint8::from(global.cumulative_paid_zhu.uint() + paid); + mint_state.set_staking_global(&global); + } + { + let mut mint_state = MintState::wrap(base_state); + staking_finalize_unlock(&mut mint_state, &entry)?; + mint_state.del_staking_unlock_entry(&key); + } + } + + Ok(()) +} + +pub fn staking_on_block_close(base_state: &mut dyn State, height: u64) -> Ret<()> { + let mint_state = MintState::wrap(base_state); + if !staking_is_active_at_height(&mint_state, height) { + return Ok(()); + } + drop(mint_state); + { + let mut mint_state = MintState::wrap(base_state); + staking_sweep_idle_pool(&mut mint_state, height)?; + staking_distribute_rewards(&mut mint_state, height)?; + } + staking_process_unlock_queue(base_state, height)?; + Ok(()) +} + +pub fn check_diamond_stakeable( + state: &MintState, + staker: &Address, + hacd_name: &DiamondName, +) -> Ret { + let diaitem = must_have!( + format!("diamond {}", hacd_name.readable()), + state.diamond(hacd_name) + ); + if !diamond_status_allows_transfer(&diaitem.status) { + return errf!( + "diamond {} cannot be staked while status is {}", + hacd_name.readable(), + diaitem.status.uint() + ); + } + if *staker != diaitem.address { + return errf!( + "diamond {} not belong to address {}", + hacd_name.readable(), + staker.readable() + ); + } + Ok(diaitem) +} + +/// Parse HVM / HIP-25 diamond list wire format: `Uint1 count` + `count × 6` literal bytes. +pub fn staking_parse_hvm_diamonds(raw: &[u8]) -> Ret { + if raw.is_empty() { + return errf!("diamond list empty"); + } + let mut list = DiamondNameListMax200::default(); + list.parse(raw, 0)?; + list.check()?; + Ok(list) +} + +/// Execute HIP-25 HVM external opcode (`0x01` stake, `0x02` unstake) against Mint state. +pub fn staking_exec_hvm_external( + opcode: u8, + payload: &[u8], + staker: &Address, + height: u64, + chain_id: u64, + base_state: &mut dyn State, +) -> Ret<()> { + let diamonds = staking_parse_hvm_diamonds(payload)?; + let mut mint_state = MintState::wrap(base_state); + match opcode { + STAKE_HACD_VMKIND => staking_apply_stake(&mut mint_state, staker, &diamonds, height), + UNSTAKE_HACD_VMKIND => { + staking_apply_unstake(&mut mint_state, staker, &diamonds, height, chain_id) + } + _ => errf!("unknown HIP-25 HVM opcode {}", opcode), + } +} + +pub fn staking_set_paused(state: &mut MintState, paused: bool) { + let mut global = state.staking_global(); + global.paused = Uint1::from(if paused { 1 } else { 0 }); + state.set_staking_global(&global); +} + +pub fn staking_apply_stake( + state: &mut MintState, + staker: &Address, + diamonds: &DiamondNameListMax200, + height: u64, +) -> Ret<()> { + diamonds.check()?; + let mut global = state.staking_global(); + if !global.is_active_at(height) { + return errf!("HACD staking is not active at height {}", height); + } + if global.is_paused() { + return errf!("HACD staking is paused"); + } + let reward_index = global.global_reward_index.clone(); + + for dianame in diamonds.list() { + let mut diaitem = check_diamond_stakeable(state, staker, &dianame)?; + diaitem.status = DIAMOND_STATUS_STAKED; + state.set_diamond(&dianame, &diaitem); + + let record = StakingRecord { + stake_height: BlockHeight::from(height), + unlock_height: BlockHeight::from(0), + reward_index: reward_index.clone(), + pending_reward: Amount::default(), + }; + state.set_staking_record(&dianame, &record); + global.total_staked_shares = + Uint5::from(global.total_staked_shares.uint() + 1); + + staking_push_event( + state, + &StakingEvent { + kind: STAKING_EVENT_STAKED, + height: BlockHeight::from(height), + diamond: dianame.clone(), + staker: staker.clone(), + unlock_height: BlockHeight::from(0), + reward: Amount::default(), + shares: global.total_staked_shares.clone(), + }, + ); + } + + let mut final_global = state.staking_global(); + final_global.total_staked_shares = global.total_staked_shares; + state.set_staking_global(&final_global); + Ok(()) +} + +pub fn staking_apply_unstake( + state: &mut MintState, + staker: &Address, + diamonds: &DiamondNameListMax200, + height: u64, + chain_id: u64, +) -> Ret<()> { + diamonds.check()?; + let global = state.staking_global(); + let reward_index = global.global_reward_index.clone(); + + for dianame in diamonds.list() { + let mut diaitem = must_have!( + format!("diamond {}", dianame.readable()), + state.diamond(&dianame) + ); + if diaitem.status != DIAMOND_STATUS_STAKED { + return errf!("diamond {} is not staked", dianame.readable()); + } + if *staker != diaitem.address { + return errf!( + "diamond {} not belong to staker {}", + dianame.readable(), + staker.readable() + ); + } + + let record = must_have!( + format!("staking record for {}", dianame.readable()), + state.staking_record(&dianame) + ); + let stake_height = record.stake_height.uint(); + let global_snap = state.staking_global(); + let min_stake = global_snap.effective_min_stake_blocks(chain_id); + let cooldown = global_snap.effective_cooldown_blocks(chain_id); + if height < stake_height + min_stake { + return errf!( + "diamond {} must remain staked for at least {} blocks", + dianame.readable(), + min_stake + ); + } + + let reward = staking_accrued_amount(&reward_index, &record.reward_index)?; + + diaitem.status = DIAMOND_STATUS_STAKING_COOLDOWN; + state.set_diamond(&dianame, &diaitem); + + let unlock_height = height + cooldown; + let cooldown_record = StakingRecord { + stake_height: record.stake_height.clone(), + unlock_height: BlockHeight::from(unlock_height), + reward_index: reward_index.clone(), + pending_reward: reward.clone(), + }; + state.set_staking_record(&dianame, &cooldown_record); + + let mut global = state.staking_global(); + global.total_staked_shares = + Uint5::from(global.total_staked_shares.uint().saturating_sub(1)); + state.set_staking_global(&global); + + let entry = StakingUnlockEntry { + unlock_height: BlockHeight::from(unlock_height), + diamond: dianame.clone(), + staker: staker.clone(), + reward, + }; + staking_enqueue_unlock(state, &entry)?; + + staking_push_event( + state, + &StakingEvent { + kind: STAKING_EVENT_UNSTAKE_REQUESTED, + height: BlockHeight::from(height), + diamond: dianame.clone(), + staker: staker.clone(), + unlock_height: BlockHeight::from(unlock_height), + reward: entry.reward.clone(), + shares: Uint5::from(0), + }, + ); + } + + Ok(()) +} + +#[cfg(test)] +mod staking_tests { + use super::*; + use crate::core::state::ChainState; + use crate::mint::operate::hacd_move_one_diamond; + use tempfile::TempDir; + + fn test_state() -> (TempDir, ChainState) { + let dir = TempDir::new().unwrap(); + let state = ChainState::open(dir.path()); + (dir, state) + } + + fn test_staker() -> Address { + Address::from_readable("12vi7DEZjh6KrK5PVmmqSgvuJPCsZMmpfi").unwrap() + } + + fn test_other() -> Address { + Address::from_readable("1LsQLqkd8FQDh3R7ZhxC5fndNf92WfhM19").unwrap() + } + + fn seed_diamond(state: &mut ChainState, name: &str, owner: &Address) -> DiamondName { + let dian = DiamondName::cons(name.as_bytes().try_into().unwrap()); + let dia = DiamondSto { + status: DIAMOND_STATUS_NORMAL, + address: owner.clone(), + prev_engraved_height: BlockHeight::from(0), + inscripts: Inscripts::default(), + }; + let mut mint = MintState::wrap(state); + mint.set_diamond(&dian, &dia); + dian + } + + fn one_diamond_list(name: &str) -> DiamondNameListMax200 { + let mut list = DiamondNameListMax200::default(); + list.push(DiamondName::cons(name.as_bytes().try_into().unwrap())) + .unwrap(); + list + } + + fn hac_balance(state: &ChainState, addr: &Address) -> Amount { + let core = CoreStateDisk::wrap(state); + core.balance(addr) + .map(|b| b.hacash.clone()) + .unwrap_or_default() + } + + fn lit(name: &[u8; 6]) -> DiamondName { + DiamondName::cons(*name) + } + + #[test] + fn hip25_testnet_seed_password_address() { + use crate::core::account::Account; + let acc = Account::create_by_password("hip25test").unwrap(); + eprintln!("HIP25_TESTNET_ADDRESS={}", acc.readable()); + let prikey = hex::encode(acc.secret_key().serialize()); + eprintln!("HIP25_TESTNET_PRIKEY={}", prikey); + } + + #[test] + fn mint_miner_share_is_ten_percent_of_bid_fee() { + assert_eq!(staking_mint_miner_share_zhu(2300), 230); + assert_eq!(staking_mint_miner_share_zhu(1000), 100); + } + + #[test] + fn min_stake_blocks_is_three_months_scale() { + assert!(MIN_STAKE_BLOCKS > 20000); + assert!(COOLDOWN_BLOCKS < 1000); + } + + #[test] + fn stake_owned_hacd_sets_staked_status() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + let dian = seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + let dia = mint.diamond(&dian).unwrap(); + assert_eq!(dia.status, DIAMOND_STATUS_STAKED); + assert_eq!(mint.staking_global().total_staked_shares.uint(), 1); + } + + #[test] + fn transfer_staked_hacd_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + let other = test_other(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + let dian = lit(b"WTYUIA"); + let err = hacd_move_one_diamond(&mut mint, &staker, &other, &dian).unwrap_err(); + assert!(format!("{}", err).contains("staked")); + } + + #[test] + fn unstake_before_min_stake_age_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let stake_h = 1000u64; + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + let too_early = stake_h + MIN_STAKE_BLOCKS - 1; + let err = staking_apply_unstake(&mut mint, &staker, &list, too_early, 0).unwrap_err(); + assert!(format!("{}", err).contains("at least")); + } + + #[test] + fn unstake_cooldown_unlock_pays_reward() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let stake_h = 1000u64; + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + staking_deposit_fee(&mut mint, 1000); + staking_distribute_rewards(&mut mint, stake_h).unwrap(); + let unstake_h = stake_h + MIN_STAKE_BLOCKS; + staking_apply_unstake(&mut mint, &staker, &list, unstake_h, 0).unwrap(); + let unlock_h = unstake_h + COOLDOWN_BLOCKS; + staking_on_block_close(&mut state, unlock_h).unwrap(); + let mint = MintStateDisk::wrap(&state); + let dian = lit(b"WTYUIA"); + let dia = mint.diamond(&dian).unwrap(); + assert_eq!(dia.status, DIAMOND_STATUS_NORMAL); + assert!(mint.staking_record(&dian).is_none()); + assert!(hac_balance(&state, &staker).is_positive()); + } + + #[test] + fn two_stakers_split_rewards_proportionally() { + let (_dir, mut state) = test_state(); + let s1 = test_staker(); + let s2 = test_other(); + seed_diamond(&mut state, "WTYUIA", &s1); + seed_diamond(&mut state, "HXVMEK", &s2); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &s1, &one_diamond_list("WTYUIA"), 100).unwrap(); + staking_apply_stake(&mut mint, &s2, &one_diamond_list("HXVMEK"), 100).unwrap(); + staking_deposit_fee(&mut mint, 1000); + staking_distribute_rewards(&mut mint, 100).unwrap(); + let g = mint.staking_global(); + assert_eq!(g.global_reward_index.uint(), 500); + let r1 = mint.staking_record(&lit(b"WTYUIA")).unwrap(); + staking_apply_unstake( + &mut mint, + &s1, + &one_diamond_list("WTYUIA"), + 100 + MIN_STAKE_BLOCKS, + 0, + ) + .unwrap(); + let pending = r1.reward_index.uint(); + let accrued = g.global_reward_index.uint().saturating_sub(pending); + assert_eq!(accrued, 500); + } + + #[test] + fn pause_rejects_stake_allows_unstake() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + seed_diamond(&mut state, "HXVMEK", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + staking_set_paused(&mut mint, true); + let err = + staking_apply_stake(&mut mint, &staker, &one_diamond_list("HXVMEK"), 2000).unwrap_err(); + assert!(format!("{}", err).contains("paused")); + staking_apply_unstake(&mut mint, &staker, &list, 1000 + MIN_STAKE_BLOCKS, 0).unwrap(); + } + + #[test] + fn batch_over_200_rejected() { + let mut list = DiamondNameListMax200::default(); + let chars = b"WTYUIAHXVMEKBSZN"; + for i in 0..201usize { + let mut bytes = [b'W'; 6]; + for j in 0..6 { + bytes[j] = chars[(i + j) % chars.len()]; + } + list.push(DiamondName::cons(bytes)).unwrap(); + } + let err = list.check().unwrap_err(); + assert!(format!("{}", err).contains("200")); + } + + #[test] + fn idle_pool_swept_to_burn_after_sweep_blocks() { + let (_dir, mut state) = test_state(); + let mut mint = MintState::wrap(&mut state); + staking_deposit_fee(&mut mint, 5000); + for h in 1..=STAKING_POOL_SWEEP_BLOCKS { + staking_sweep_idle_pool(&mut mint, h).unwrap(); + } + assert_eq!(mint.staking_global().reward_pool_zhu.uint(), 0); + assert_eq!(mint.staking_global().cumulative_pool_burned_zhu.uint(), 5000); + assert_eq!(mint.total_count().hacd_bid_burn_zhu.uint(), 5000); + } + + #[test] + fn idle_pool_not_swept_before_threshold() { + let (_dir, mut state) = test_state(); + let mut mint = MintState::wrap(&mut state); + staking_deposit_fee(&mut mint, 3000); + for h in 1..STAKING_POOL_SWEEP_BLOCKS { + staking_sweep_idle_pool(&mut mint, h).unwrap(); + } + assert_eq!(mint.staking_global().reward_pool_zhu.uint(), 3000); + assert_eq!(mint.staking_global().zero_staker_blocks.uint(), STAKING_POOL_SWEEP_BLOCKS - 1); + } + + #[test] + fn cooldown_display_reward_uses_pending_not_live_index() { + let (_dir, mut state) = test_state(); + let s1 = test_staker(); + let s2 = test_other(); + seed_diamond(&mut state, "WTYUIA", &s1); + seed_diamond(&mut state, "HXVMEK", &s2); + let list1 = one_diamond_list("WTYUIA"); + let stake_h = 1000u64; + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &s1, &list1, stake_h).unwrap(); + staking_apply_stake(&mut mint, &s2, &one_diamond_list("HXVMEK"), stake_h).unwrap(); + staking_deposit_fee(&mut mint, 1000); + staking_distribute_rewards(&mut mint, stake_h).unwrap(); + let pending_at_unstake = staking_accrued_amount( + &mint.staking_global().global_reward_index, + &mint.staking_record(&lit(b"WTYUIA")).unwrap().reward_index, + ) + .unwrap(); + let unstake_h = stake_h + MIN_STAKE_BLOCKS; + staking_apply_unstake(&mut mint, &s1, &list1, unstake_h, 0).unwrap(); + staking_deposit_fee(&mut mint, 2000); + staking_distribute_rewards(&mut mint, unstake_h).unwrap(); + let rec = mint.staking_record(&lit(b"WTYUIA")).unwrap(); + let displayed = staking_display_accrued_reward( + &mint.staking_global().global_reward_index, + &rec, + ) + .unwrap(); + assert_eq!(displayed, pending_at_unstake); + let live = staking_accrued_amount( + &mint.staking_global().global_reward_index, + &rec.reward_index, + ) + .unwrap(); + assert!(live > displayed); + } + + #[test] + fn stake_before_activation_height_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let mut global = mint.staking_global(); + global.activation_height = BlockHeight::from(5000); + mint.set_staking_global(&global); + let err = staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap_err(); + assert!(format!("{}", err).contains("not active")); + } + + #[test] + fn stake_emits_staked_on_chain_event() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, 1000).unwrap(); + assert_eq!(mint.staking_global().event_log_tail.uint(), 1); + let ev = mint.staking_event(&Uint5::from(0)).unwrap(); + assert_eq!(ev.kind, STAKING_EVENT_STAKED); + assert_eq!(ev.diamond.readable(), "WTYUIA"); + assert_eq!(ev.staker, staker); + } + + #[test] + fn script_execute_stake_via_hvm_wire() { + use crate::vm::exec_staking_script; + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let mut wire = vec![STAKE_HACD_VMKIND]; + wire.extend(one_diamond_list("WTYUIA").serialize()); + exec_staking_script(&wire, &staker, 5000, 0, &mut state).unwrap(); + let mint = MintStateDisk::wrap(&state); + assert_eq!(mint.diamond(&lit(b"WTYUIA")).unwrap().status, DIAMOND_STATUS_STAKED); + } + + #[test] + fn hvm_opcode_stake_and_unstake_via_bridge() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let wire = one_diamond_list("WTYUIA").serialize(); + staking_exec_hvm_external(STAKE_HACD_VMKIND, &wire, &staker, 5000, 0, &mut state).unwrap(); + let mint = MintStateDisk::wrap(&state); + let dian = lit(b"WTYUIA"); + assert_eq!(mint.diamond(&dian).unwrap().status, DIAMOND_STATUS_STAKED); + staking_exec_hvm_external( + UNSTAKE_HACD_VMKIND, + &wire, + &staker, + 5000 + MIN_STAKE_BLOCKS, + 0, + &mut state, + ) + .unwrap(); + let mint = MintStateDisk::wrap(&state); + assert_eq!(mint.diamond(&dian).unwrap().status, DIAMOND_STATUS_STAKING_COOLDOWN); + } + + #[test] + fn unlock_queue_missing_entry_fails_hard() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let stake_h = 1000u64; + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + staking_apply_unstake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS, 0).unwrap(); + mint.del_staking_unlock_entry(&Uint5::from(0)); + let err = staking_process_unlock_queue(&mut state, stake_h + MIN_STAKE_BLOCKS + COOLDOWN_BLOCKS) + .unwrap_err(); + assert!(format!("{}", err).contains("unlock queue corrupted")); + } + + #[test] + fn non_owner_stake_rejected() { + let (_dir, mut state) = test_state(); + let owner = test_staker(); + let other = test_other(); + seed_diamond(&mut state, "WTYUIA", &owner); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let err = staking_apply_stake(&mut mint, &other, &list, 1000).unwrap_err(); + assert!(format!("{}", err).contains("not belong")); + } + + #[test] + fn non_owner_unstake_rejected() { + let (_dir, mut state) = test_state(); + let owner = test_staker(); + let other = test_other(); + seed_diamond(&mut state, "WTYUIA", &owner); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + staking_apply_stake(&mut mint, &owner, &list, 1000).unwrap(); + let err = + staking_apply_unstake(&mut mint, &other, &list, 1000 + MIN_STAKE_BLOCKS, 0).unwrap_err(); + assert!(format!("{}", err).contains("not belong")); + } + + #[test] + fn stake_during_cooldown_rejected() { + let (_dir, mut state) = test_state(); + let staker = test_staker(); + seed_diamond(&mut state, "WTYUIA", &staker); + let list = one_diamond_list("WTYUIA"); + let mut mint = MintState::wrap(&mut state); + let stake_h = 1000u64; + staking_apply_stake(&mut mint, &staker, &list, stake_h).unwrap(); + staking_apply_unstake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS, 0).unwrap(); + let err = staking_apply_stake(&mut mint, &staker, &list, stake_h + MIN_STAKE_BLOCKS + 1) + .unwrap_err(); + assert!(format!("{}", err).contains("cannot be staked")); + } + + #[test] + fn duplicate_diamond_in_batch_rejected() { + let mut list = DiamondNameListMax200::default(); + list.push(lit(b"WTYUIA")).unwrap(); + list.push(lit(b"WTYUIA")).unwrap(); + let err = list.check().unwrap_err(); + assert!(format!("{}", err).contains("duplicate")); + } + + #[test] + fn demo_periods_ignored_on_mainnet_chain_id() { + let (_dir, mut state) = test_state(); + let mut mint = MintState::wrap(&mut state); + let mut global = mint.staking_global(); + global.demo_min_stake_blocks = Uint5::from(5); + global.demo_cooldown_blocks = Uint5::from(3); + mint.set_staking_global(&global); + let g = mint.staking_global(); + assert_eq!(g.effective_min_stake_blocks(0), MIN_STAKE_BLOCKS); + assert_eq!(g.effective_cooldown_blocks(0), COOLDOWN_BLOCKS); + assert_eq!(g.effective_min_stake_blocks(crate::config::HIP25_DEV_CHAIN_ID), 5); + } + + #[test] + fn hip25_dev_flags_rejected_on_mainnet_chain_id() { + use crate::config::{HIP25_DEV_CHAIN_ID, MintConf}; + let mut cnf = MintConf { + chain_id: HIP25_DEV_CHAIN_ID + 99, + difficulty_adjust_blocks: 288, + each_block_target_time: 300, + _test_mul: 1, + staking_activation_height: 1, + hip25_testnet_seed: true, + hip25_testnet_seed_password: "hip25test".to_string(), + hip25_testnet_demo_periods: false, + mortgage_activation_height: 0, + mortgage_max_outstanding_zhu: 0, + hip2_testnet_demo_periods: false, + }; + assert!(cnf.validate_hip25_dev_flags().is_err()); + } +} \ No newline at end of file diff --git a/src/mint/state/def.rs b/src/mint/state/def.rs index db4b220..5db3fcd 100644 --- a/src/mint/state/def.rs +++ b/src/mint/state/def.rs @@ -20,14 +20,21 @@ defineChainStateOperationInstance!{ State, MintState, ( - &[2, 1], total_count , TotalCount - &[2, 2], latest_diamond , DiamondSmelt + &[2, 1], total_count , TotalCount + &[2, 2], latest_diamond , DiamondSmelt + &[2, 3], staking_global , GlobalStakingState + &[2, 4], mortgage_global , GlobalMortgageState ) ( &[2, 21], diamond_ptr , DiamondNumber , DiamondName &[2, 22], diamond , DiamondName , DiamondSto &[2, 23], diamond_owned , Address , DiamondOwnedForm - &[2, 24], channel , ChannelId , ChannelSto + &[2, 24], channel , ChannelId , ChannelSto + &[2, 25], staking_record , DiamondName , StakingRecord + &[2, 26], staking_unlock_entry, Uint5 , StakingUnlockEntry + &[2, 27], staking_event , Uint5 , StakingEvent + &[2, 28], diamond_syslend , DiamondSyslendId , DiamondSystemLending + &[2, 29], mortgage_owner_index , Address , MortgageOwnerIndex ) } diff --git a/src/node/handler/handler.rs b/src/node/handler/handler.rs index d6ad2fb..d5da441 100644 --- a/src/node/handler/handler.rs +++ b/src/node/handler/handler.rs @@ -71,7 +71,11 @@ impl MsgHandler { // println!("on_message peer={} ty={} len={}", peer.nick(), ty, body.len()); match ty { - MSG_TX_SUBMIT => { self.blktx.send(BlockTxArrive::Tx(Some(peer.clone()), body)).await; }, + MSG_TX_SUBMIT => { + if body.len() <= TX_SUBMIT_MAX_BYTES { + self.blktx.send(BlockTxArrive::Tx(Some(peer.clone()), body)).await; + } + }, MSG_BLOCK_DISCOVER => { self.blktx.send(BlockTxArrive::Block(Some(peer.clone()), body)).await; }, MSG_BLOCK_HASH => { self.receive_hashs(peer, body).await; }, MSG_REQ_BLOCK_HASH => { self.send_hashs(peer, body).await; }, diff --git a/src/node/handler/msg.rs b/src/node/handler/msg.rs index 2a5efed..d8062f0 100644 --- a/src/node/handler/msg.rs +++ b/src/node/handler/msg.rs @@ -11,6 +11,8 @@ pub const MSG_REQ_BLOCK: u16 = 5; pub const MSG_BLOCK: u16 = 6; pub const MSG_TX_SUBMIT: u16 = 7; // new tx arrived +/// Max serialized tx size accepted from P2P (aligned with RPC body limit). +pub const TX_SUBMIT_MAX_BYTES: usize = 256 * 1024; pub const MSG_BLOCK_DISCOVER: u16 = 8; // new block arrived // msg stuff diff --git a/src/node/handler/txblock.rs b/src/node/handler/txblock.rs index 434f528..e03af7d 100644 --- a/src/node/handler/txblock.rs +++ b/src/node/handler/txblock.rs @@ -1,31 +1,31 @@ async fn handle_new_tx(this: Arc, peer: Option>, body: Vec) { - // println!("1111111 handle_txblock_arrive Tx, peer={} len={}", peer.nick(), body.clone().len()); + if body.len() > TX_SUBMIT_MAX_BYTES { + return; + } let engcnf = this.engine.config(); - // parse let txpkg = transaction::create_pkg(BytesW4::from_vec(body)); - if let Err(e) = txpkg { - return // parse tx error + if let Err(_) = txpkg { + return; } let txpkg = txpkg.unwrap(); - // tx hash with fee + let txread = txpkg.objc().as_ref().as_read(); + if txread.verify_signature().is_err() { + return; + } + if this.engine.try_execute_tx(txread).is_err() { + return; + } let hxfe = txpkg.objc().hash_with_fee(); let (already, knowkey) = check_know(&this.knows, &hxfe, peer.clone()); if already { - return // alreay know it + return; } - // println!("p2p recv new tx: {}, {}", txpkg.objc().hash().half(), hxfe.nonce()); let txdatas = txpkg.body().clone().into_vec(); if engcnf.is_open_miner() { - // try execute tx - if let Err(..) = this.engine.try_execute_tx(txpkg.objc().as_ref().as_read()) { - return // tx execute fail - } - // add to pool - this.txpool.insert(txpkg); + let _ = this.txpool.insert(txpkg); } - // broadcast let p2p = this.p2pmng.lock().unwrap(); let p2p = p2p.as_ref().unwrap(); p2p.broadcast_message(0/*not delay*/, knowkey, MSG_TX_SUBMIT, txdatas); @@ -140,9 +140,10 @@ fn drain_all_block_txs(eng: Arc, txpool: Arc, txs: V // clean_ fn clean_invalid_normal_txs(eng: Arc, txpool: Arc, blkhei: u64) { - // already minted hacd number - let sta = eng.state(); - let ldn = MintStateDisk::wrap(sta.as_ref()).latest_diamond().number.uint(); + let Some(sta) = eng.try_state() else { + return; + }; + let _ldn = MintStateDisk::wrap(sta.as_ref()).latest_diamond().number.uint(); txpool.drain_filter_at(&|a: &Box| { match eng.try_execute_tx( a.objc().as_read() ) { Err(..) => true, // delete @@ -154,8 +155,9 @@ fn clean_invalid_normal_txs(eng: Arc, txpool: Arc, b // clean_ fn clean_invalid_diamond_mint_txs(eng: Arc, txpool: Arc, blkhei: u64) { - // already minted hacd number - let sta = eng.state(); + let Some(sta) = eng.try_state() else { + return; + }; let curdn = MintStateDisk::wrap(sta.as_ref()).latest_diamond().number.uint(); txpool.drain_filter_at(&|a: &Box| { let tx = a.objc().as_read(); diff --git a/src/node/node/hnode.rs b/src/node/node/hnode.rs index 45ec9d1..c2c598e 100644 --- a/src/node/node/hnode.rs +++ b/src/node/node/hnode.rs @@ -3,12 +3,15 @@ impl HNode for HacashNode { fn submit_transaction(&self, txpkg: &Box, in_async: bool) -> RetErr { - // check signature let txread = txpkg.objc().as_ref().as_read(); txread.verify_signature()?; - // try execute tx self.engine.try_execute_tx(txread)?; - // add to pool + if self.engine.config().is_open_miner() { + let txbody = txpkg.body().clone().into_vec(); + if let Ok(pkg) = crate::protocol::transaction::create_pkg(BytesW4::from_vec(txbody)) { + let _ = self.txpool.insert(pkg); + } + } let msghdl = self.msghdl.clone(); let txbody = txpkg.body().clone().into_vec(); let runobj = async move { @@ -16,7 +19,7 @@ impl HNode for HacashNode { }; if in_async { tokio::spawn(runobj); - }else{ + } else { new_current_thread_tokio_rt().block_on(runobj); } Ok(()) diff --git a/src/protocol/action/script.rs b/src/protocol/action/script.rs index 9bde636..cc453e1 100644 --- a/src/protocol/action/script.rs +++ b/src/protocol/action/script.rs @@ -16,16 +16,22 @@ (self, ctx, state, store, gas), // params true, // burn 90 [], // req sign - { - errf!("not support") - /* - let addr = Fixed21{ bytes: [0u8; 21] }; - let codes = [74u8,89]; - // ctx.vm()?.main_call(&addr, &codes) - Ok(vec![]) - */ + { + script_execute_staking(self, ctx, state) } } +fn script_execute_staking( + this: &ScriptExecute, + ctx: &dyn ExecContext, + sta: &mut dyn State, +) -> Ret> { + let staker = ctx.main_address(); + let height = ctx.pending_height(); + let codes = this.codes.as_ref(); + vm::exec_staking_script(codes, staker, height, ctx.chain_id(), sta)?; + Ok(vec![]) +} + diff --git a/src/protocol/transaction/mod.rs b/src/protocol/transaction/mod.rs index 1ae1614..bb9fa0e 100644 --- a/src/protocol/transaction/mod.rs +++ b/src/protocol/transaction/mod.rs @@ -1,4 +1,4 @@ -use std::fmt::*; +use std::fmt::{Debug, Display, Formatter, Result as FmtResult}; use std::collections::{ HashMap, HashSet }; use crate::x16rs; diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs index 18705a0..591c432 100644 --- a/src/sdk/mod.rs +++ b/src/sdk/mod.rs @@ -1,3 +1,3 @@ -// pub mod web; +pub mod web; diff --git a/src/sdk/web/account.rs b/src/sdk/web/account.rs index 3ef58f2..32d1c67 100644 --- a/src/sdk/web/account.rs +++ b/src/sdk/web/account.rs @@ -4,9 +4,7 @@ pub fn create_account_by(s: String) -> String { let acc = or_return!{ "create account", Account::create_by(&s) }; // ok let accstr = acc.readable(); - let acckey = hex::encode(acc.secret_key().serialize()); let accpub = hex::encode(acc.public_key().serialize_compressed()); - // format!("{},{},{}", acckey, accpub, accstr) - let ok = format!(r##""private_key":"{}","public_key":"{}","address":"{}""##, acckey, accpub, accstr); + let ok = format!(r##""public_key":"{}","address":"{}""##, accpub, accstr); format!("{{{}}}", ok) } diff --git a/src/sdk/web/mod.rs b/src/sdk/web/mod.rs index f20413d..eefc0e6 100644 --- a/src/sdk/web/mod.rs +++ b/src/sdk/web/mod.rs @@ -1,17 +1,17 @@ // We need the trait in scope to use Utc::timestamp(). -use chrono::{TimeZone, Utc, Duration}; +use chrono::{TimeZone, Utc}; use wasm_bindgen::prelude::*; -use crate::core::field_bnk; -use crate::core::field_bnk::*; -use crate::core::interface::field::*; -use crate::core::interface::transaction::*; -use crate::core::protocol::action; -use crate::core::protocol::action::*; -use crate::core::protocol::transaction; +use crate::base::field::*; +use crate::core::field::*; +use crate::interface::field::*; +use crate::interface::protocol::{Transaction, TransactionRead}; +use crate::protocol::action; +use crate::protocol::action::*; +use crate::protocol::transaction; /******** sdk ********/ @@ -30,5 +30,4 @@ macro_rules! or_return { include!{"amount.rs"} include!{"account.rs"} include!{"sign.rs"} -include!{"transfer.rs"} - +include!{"transfer.rs"} \ No newline at end of file diff --git a/src/sdk/web/sign.rs b/src/sdk/web/sign.rs index 3bb085d..e25404a 100644 --- a/src/sdk/web/sign.rs +++ b/src/sdk/web/sign.rs @@ -1,4 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] pub fn sign(acckey: String, msg: String) -> String { let acc = or_return!{ "create account", Account::create_by(&acckey) }; diff --git a/src/sdk/web/transfer.rs b/src/sdk/web/transfer.rs index 1a774ac..b477067 100644 --- a/src/sdk/web/transfer.rs +++ b/src/sdk/web/transfer.rs @@ -1,29 +1,105 @@ -static mut API_RETURN_JSON: bool = true; +use std::sync::Once; +use crate::core::account::Account; +use crate::core::field::DiamondNameListMax200; +use crate::interface::field::Field; +use crate::mint::action::{DiamondStake, DiamondUnstake, MortgageOpen, MortgageRedeem}; +static SDK_INIT: Once = Once::new(); +fn ensure_sdk_init() { + SDK_INIT.call_once(|| { + crate::mint::action::init_reg(); + }); +} +use crate::protocol::action::{HacToTransfer, SubChainID}; +use crate::protocol::transaction::TransactionType2; -/* -#[no_mangle] -pub extern fn trs_test(x: i32) -> i32 { - let mut bts = vec![1,0,5,1,1,1,1,1,1,1]; - bts[1] = x; - if x > 100 { - panic!("error more 100") +fn if_add_chain_id(chain_id: u64, tx: &mut TransactionType2) { + if chain_id > 0 { + let mut act = SubChainID::new(); + act.chain_id = Uint8::from(chain_id); + let _ = tx.push_action(Box::new(act)); } - let mut res = 0; - for v in bts { - res += v; +} + +fn get_time_set(timestamp: i64) -> i64 { + let mut time_set = timestamp; + if time_set <= 0 { + time_set = Utc::now().timestamp(); } - res + 10 + time_set } -*/ +fn parse_diamond_list(diamond_name_list: String) -> Result { + DiamondNameListMax200::from_readable(&diamond_name_list).map_err(|e| e.to_string()) +} + +fn stake_tx_json( + tx: &TransactionType2, + dlist: &DiamondNameListMax200, + fee: &Amount, + acc: &Account, + time_set: i64, + action_label: &str, +) -> String { + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","action":"{}","diamond_count":{},"diamonds":"{}","fee":"{}","main_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + action_label, + dlist.count().uint(), + dlist.readable(), + fee.to_fin_string(), + acc.readable(), + time_set + ); + format!("{{{}}}", ok) +} +fn build_signed_stake_tx( + chain_id: u64, + mut from_pass: String, + diamond_name_list: String, + fee: String, + timestamp: i64, + stake: bool, +) -> String { + ensure_sdk_init(); + let time_set = get_time_set(timestamp); + let dlist = or_return! { "Diamond Name parse", parse_diamond_list(diamond_name_list) }; + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + from_pass.clear(); + let addr = or_return! { "From Address", Address::from_readable(acc.readable()) }; + let mut tx = TransactionType2::build(addr, fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); + if_add_chain_id(chain_id, &mut tx); + if stake { + let mut act = DiamondStake::new(); + act.diamonds = dlist.clone(); + if let Err(e) = tx.push_action(Box::new(act)) { + return format!("[ERROR] push stake action: {}", e); + } + } else { + let mut act = DiamondUnstake::new(); + act.diamonds = dlist.clone(); + if let Err(e) = tx.push_action(Box::new(act)) { + return format!("[ERROR] push unstake action: {}", e); + } + } + use crate::interface::protocol::Transaction; + if let Err(e) = tx.fill_sign(&acc) { + return format!("[ERROR] fill_sign: {}", e); + } + let label = if stake { "stake" } else { "unstake" }; + stake_tx_json(&tx, &dlist, &fee, &acc, time_set, label) +} -#[no_mangle] -pub extern fn trs_test(x: i32) -> usize { - let mut bt = field_bnk::Fixed4::default(); +#[cfg(not(target_arch = "wasm32"))] +#[wasm_bindgen] +pub fn trs_test(x: i32) -> usize { + let mut bt = Fixed4::default(); let data = vec![x as u8 + 1, x as u8 + 2, x as u8 + 3, x as u8 + 4]; let mut res = bt.parse(&data, 0).unwrap(); let vals = bt.serialize(); @@ -33,211 +109,342 @@ pub extern fn trs_test(x: i32) -> usize { let vvs = bt.hex().into_bytes(); res += vvs[2] as usize; res - - // x as usize + data[0] as usize - // x as usize + 1 } -use crate::core::account::Account; - -#[no_mangle] -pub extern fn create_acc_random() -> usize { +#[cfg(not(target_arch = "wasm32"))] +#[wasm_bindgen] +pub fn create_acc_random() -> usize { let acc = Account::create_by_password(&"123456".to_string()); - if let Err(e) = acc { - return 0 - } + if let Err(_) = acc { + return 0; + } let accstr = acc.unwrap().readable().clone(); let bts = accstr.as_bytes(); - bts[1] as usize - -} - - -////////////////////////////// -#[no_mangle] -pub extern fn set_api_return_json() { -} - - - - - - -fn if_add_chain_id(chain_id: u64, tx: &mut impl Transaction) { - // act - if chain_id > 0 { - let mut act = action::new_CheckChainID(); - act.chain_id = Uint8::from(chain_id); - tx.append_action(Box::new(act)); - } -} - -fn get_time_set(timestamp: i64) -> i64 { - let mut time_set = timestamp; - if time_set <= 0 { - time_set = Utc::now().timestamp(); - } - time_set } +#[wasm_bindgen] +pub fn set_api_return_json() {} +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] -pub fn general_transfer(chain_id: u64, from_pass: String, to_addr: String, amountex: String, fee: String, timestamp: i64) -> String { - let amount = amountex.clone().to_uppercase().replace(" ",""); - // HACD - let res1 = DiamondListMax200::parse_from_list(amount.clone()); - if let Ok(diamonds) = res1 { - return hacd_transfer(chain_id, from_pass.clone(), from_pass.clone(), to_addr, amount, fee, timestamp); - } - // SAT - let res2 = amount.find("SAT"); // SAT, SATS, SATOSHI, SATOSHIS - if let Some(_) = res2 { - let v = amount.replace("S","").replace("AT","").replace("OHI",""); +pub fn general_transfer( + chain_id: u64, + from_pass: String, + to_addr: String, + amountex: String, + fee: String, + timestamp: i64, +) -> String { + let amount = amountex.clone().to_uppercase().replace(" ", ""); + if DiamondNameListMax200::from_readable(&amount).is_ok() { + return hacd_transfer( + chain_id, + from_pass.clone(), + from_pass.clone(), + to_addr, + amount, + fee, + timestamp, + ); + } + let res2 = amount.find("SAT"); + if res2.is_some() { + let v = amount.replace("S", "").replace("AT", "").replace("OHI", ""); if let Ok(sat) = v.parse::() { - return sat_transfer(chain_id, from_pass.clone(), from_pass.clone(), to_addr, sat, fee, timestamp); + return sat_transfer( + chain_id, + from_pass.clone(), + from_pass.clone(), + to_addr, + sat, + fee, + timestamp, + ); } } - // HAC - let res3 = Amount::from_string_unsafe(&amount); - if let Ok(hac) = res3 { + if Amount::from_string_unsafe(&amount).is_ok() { return hac_transfer(chain_id, from_pass.clone(), to_addr, amount, fee, timestamp); } - - // AMOUNT ERROR - or_return!{"Amount format", Err(amount)}; - - return "[ERROR]".to_string() + or_return! { "Amount format", Err(amount) }; + "[ERROR]".to_string() } - - - +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] -pub fn hac_transfer(chain_id: u64, from_pass: String, to_addr: String, amount: String, fee: String, timestamp: i64) -> String { +pub fn hac_transfer( + chain_id: u64, + from_pass: String, + to_addr: String, + amount: String, + fee: String, + timestamp: i64, +) -> String { let time_set = get_time_set(timestamp); - // amount - let amt = or_return!{ "Amount parse", Amount::from_string_unsafe(&amount) }; - let fee = or_return!{ "Fee parse", Amount::from_string_unsafe(&fee) }; - let acc = or_return!{ "From Account", Account::create_by(&from_pass) }; - let toaddr = or_return!{ "To Address", Address::from_readable(&to_addr) }; - // tx - let mut tx = transaction::new_type_2(acc.address(), &fee, time_set); - // chain id + let amt = or_return! { "Amount parse", Amount::from_string_unsafe(&amount) }; + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + let toaddr = or_return! { "To Address", Address::from_readable(&to_addr) }; + let mut tx = TransactionType2::build(*acc.address(), fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); if_add_chain_id(chain_id, &mut tx); - // actions - let act = action_create!{ HacTransfer, - to_address: toaddr.clone(), - amount: amt.clone() - }; - tx.append_action(Box::new(act)); - // sign - tx.fill_sign(&acc); - - // ok - // format!("{},{},{},{}", hex::encode(2u64.to_be_bytes()), hex::encode(Uint1::from_uint(2)), tx.hash().hex(), hex::encode(tx.serialize())) - // format!("{},{},{},{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), chain_id, acc.readable(), toaddr.readable(), amt.to_fin_string(), fee.to_fin_string(), time_set) - // format!("{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), acc.readable(), acc.readable(), time_set) - - let ok = format!(r##""tx_hash":"{}","tx_body":"{}","amount":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, - tx.hash().hex(), hex::encode(tx.serialize()), amt.to_fin_string(), fee.to_fin_string(), acc.readable(), acc.readable(), toaddr.readable(), time_set); + let mut act = HacToTransfer::new(); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.hacash = amt.clone(); + let _ = tx.push_action(Box::new(act)); + let _ = tx.fill_sign(&acc); + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","amount":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + amt.to_fin_string(), + fee.to_fin_string(), + acc.readable(), + acc.readable(), + toaddr.readable(), + time_set + ); format!("{{{}}}", ok) } - +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] -pub fn sat_transfer(chain_id: u64, from_pass: String, fee_pass: String, to_addr: String, satoshi: u64, fee: String, timestamp: i64) -> String { +pub fn sat_transfer( + chain_id: u64, + from_pass: String, + fee_pass: String, + to_addr: String, + satoshi: u64, + fee: String, + timestamp: i64, +) -> String { let time_set = get_time_set(timestamp); - // amount - let sat = Satoshi::from_uint(satoshi); - let fee = or_return!{ "Fee parse", Amount::from_string_unsafe(&fee) }; - let acc = or_return!{ "From Account", Account::create_by(&from_pass) }; - let feeacc = or_return!{ "Fee Account", Account::create_by(&fee_pass) }; - let toaddr = or_return!{ "To Address", Address::from_readable(&to_addr) }; - // tx + let sat = Satoshi::from(satoshi); + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + let feeacc = or_return! { "Fee Account", Account::create_by(&fee_pass) }; + let toaddr = or_return! { "To Address", Address::from_readable(&to_addr) }; let is_main_single = feeacc.address() == acc.address(); - let mut tx = transaction::new_type_2(feeacc.address(), &fee, time_set); - // chain id + let mut tx = TransactionType2::build(*feeacc.address(), fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); if_add_chain_id(chain_id, &mut tx); - // actions if is_main_single { - let act = action_create!{ SatTransfer, - to_address: toaddr.clone(), - satoshi: sat.clone() - }; - tx.append_action(Box::new(act)); - }else{ - let act = action_create!{ FromToSatTransfer, - from_address: acc.address().clone(), - to_address: toaddr.clone(), - satoshi: sat.clone() - }; - tx.append_action(Box::new(act)); - } - // sign - tx.fill_sign(&acc); + let mut act = SatoshiToTransfer::new(); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.satoshi = sat.clone(); + let _ = tx.push_action(Box::new(act)); + } else { + let mut act = SatoshiFromToTransfer::new(); + act.from = AddrOrPtr::from_addr(acc.address().clone()); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.satoshi = sat.clone(); + let _ = tx.push_action(Box::new(act)); + } + let _ = tx.fill_sign(&acc); if !is_main_single { - tx.fill_sign(&feeacc); + let _ = tx.fill_sign(&feeacc); } - - // ok - // format!("{},{},{},{}", hex::encode(2u64.to_be_bytes()), hex::encode(Uint1::from_uint(2)), tx.hash().hex(), hex::encode(tx.serialize())) - // format!("{},{},{},{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), chain_id, acc.readable(), toaddr.readable(), amt.to_fin_string(), fee.to_fin_string(), time_set) - // format!("{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), acc.readable(), feeacc.readable(), time_set) - - let ok = format!(r##""tx_hash":"{}","tx_body":"{}","amount":"{} SAT","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, - tx.hash().hex(), hex::encode(tx.serialize()), sat.to_u64(), fee.to_fin_string(), acc.readable(), feeacc.readable(), toaddr.readable(), time_set); + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","amount":"{} SAT","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + sat.to_u64(), + fee.to_fin_string(), + acc.readable(), + feeacc.readable(), + toaddr.readable(), + time_set + ); format!("{{{}}}", ok) - } - +#[cfg(not(target_arch = "wasm32"))] #[wasm_bindgen] -pub fn hacd_transfer(chain_id: u64, from_pass: String, fee_pass: String, to_addr: String, diamond_name_list: String, fee: String, timestamp: i64) -> String { +pub fn hacd_transfer( + chain_id: u64, + from_pass: String, + fee_pass: String, + to_addr: String, + diamond_name_list: String, + fee: String, + timestamp: i64, +) -> String { let time_set = get_time_set(timestamp); - // data - - let dlist = or_return!{ "Diamond Name parse", DiamondListMax200::parse_from_list(diamond_name_list) }; - let fee = or_return!{ "Fee parse", Amount::from_string_unsafe(&fee) }; - let acc = or_return!{ "From Account", Account::create_by(&from_pass) }; - let feeacc = or_return!{ "Fee Account", Account::create_by(&fee_pass) }; - let toaddr = or_return!{ "To Address", Address::from_readable(&to_addr) }; - // tx + let dlist = or_return! { "Diamond Name parse", parse_diamond_list(diamond_name_list) }; + let fee = or_return! { "Fee parse", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "From Account", Account::create_by(&from_pass) }; + let feeacc = or_return! { "Fee Account", Account::create_by(&fee_pass) }; + let toaddr = or_return! { "To Address", Address::from_readable(&to_addr) }; let is_main_single = feeacc.address() == acc.address(); - let mut tx = transaction::new_type_2(feeacc.address(), &fee, time_set); - // chain id + let mut tx = TransactionType2::build(*feeacc.address(), fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); if_add_chain_id(chain_id, &mut tx); - // actions - if is_main_single && dlist.len() == 1 { - let act = action_create!{ HacdTransfer, - diamond: dlist[0], - to_address: toaddr.clone() - }; - tx.append_action(Box::new(act)); - }else{ - let act = action_create!{ HacdTransferMultiple, - from_address: acc.address().clone(), - to_address: toaddr.clone(), - diamond_list: dlist.clone() - }; - tx.append_action(Box::new(act)); - } - // sign - tx.fill_sign(&acc); + if is_main_single && dlist.count().uint() == 1 { + let mut act = DiamondSingleTransfer::new(); + act.diamond = dlist.lists[0].clone(); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + let _ = tx.push_action(Box::new(act)); + } else { + let mut act = DiamondFromToTransfer::new(); + act.from = AddrOrPtr::from_addr(acc.address().clone()); + act.to = AddrOrPtr::from_addr(toaddr.clone()); + act.diamonds = dlist.clone(); + let _ = tx.push_action(Box::new(act)); + } + let _ = tx.fill_sign(&acc); if !is_main_single { - tx.fill_sign(&feeacc); + let _ = tx.fill_sign(&feeacc); } - - // ok - // format!("{},{},{},{}", hex::encode(2u64.to_be_bytes()), hex::encode(Uint1::from_uint(2)), tx.hash().hex(), hex::encode(tx.serialize())) - // format!("{},{},{},{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), chain_id, acc.readable(), toaddr.readable(), amt.to_fin_string(), fee.to_fin_string(), time_set) - // format!("{},{},{},{},{}", tx.hash().hex(), hex::encode(tx.serialize()), acc.readable(), feeacc.readable(), time_set) - - let ok = format!(r##""tx_hash":"{}","tx_body":"{}","diamond_count":{},"diamonds":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, - tx.hash().hex(), hex::encode(tx.serialize()), dlist.len(), dlist.to_string(), fee.to_fin_string(), acc.readable(), feeacc.readable(), toaddr.readable(), time_set); + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","diamond_count":{},"diamonds":"{}","fee":"{}","payment_address":"{}","fee_address":"{}","collection_address":"{}","timestamp":{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + dlist.count().uint(), + dlist.readable(), + fee.to_fin_string(), + acc.readable(), + feeacc.readable(), + toaddr.readable(), + time_set + ); format!("{{{}}}", ok) +} +/// HIP-25: stake owned HACD (action kind 34). +#[wasm_bindgen] +pub fn hacd_stake( + chain_id: u64, + from_pass: String, + diamond_name_list: String, + fee: String, + timestamp: i64, +) -> String { + build_signed_stake_tx(chain_id, from_pass, diamond_name_list, fee, timestamp, true) } +/// HIP-25: unstake HACD after min stake period (action kind 35). +#[wasm_bindgen] +pub fn hacd_unstake( + chain_id: u64, + from_pass: String, + diamond_name_list: String, + fee: String, + timestamp: i64, +) -> String { + build_signed_stake_tx(chain_id, from_pass, diamond_name_list, fee, timestamp, false) +} +fn parse_lending_id_hex(hex_id: &str) -> Result { + let clean: String = hex_id.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = hex::decode(&clean).map_err(|e| e.to_string())?; + if bytes.len() != DIAMOND_SYSLEND_ID_SIZE { + return Err(format!( + "lending id must be {} bytes hex", + DIAMOND_SYSLEND_ID_SIZE + )); + } + let arr: [u8; DIAMOND_SYSLEND_ID_SIZE] = bytes.try_into().map_err(|_| "id length")?; + Ok(DiamondSyslendId::cons(arr)) +} + +fn mortgage_tx_json( + tx: &TransactionType2, + fee: &Amount, + acc: &Account, + time_set: i64, + action_label: &str, + extra: &str, +) -> String { + let ok = format!( + r##""tx_hash":"{}","tx_body":"{}","action":"{}","fee":"{}","main_address":"{}","timestamp":{}{}"##, + tx.hash().hex(), + hex::encode(tx.serialize()), + action_label, + fee.to_fin_string(), + acc.readable(), + time_set, + extra + ); + format!("{{{}}}", ok) +} + +/// HIP-2 v2.1: open system mortgage (action kind 15). +#[wasm_bindgen] +pub fn hacd_mortgage_open( + chain_id: u64, + mut from_pass: String, + lending_id_hex: String, + diamond_name_list: String, + loan_amount: String, + borrow_period: u8, + fee: String, + timestamp: i64, +) -> String { + ensure_sdk_init(); + let time_set = get_time_set(timestamp); + let dlist = or_return! { "Diamond list", parse_diamond_list(diamond_name_list) }; + let lend_id = or_return! { "Lending id", parse_lending_id_hex(&lending_id_hex) }; + let principal = or_return! { "Loan amount", Amount::from_string_unsafe(&loan_amount) }; + let fee = or_return! { "Fee", Amount::from_string_unsafe(&fee) }; + if borrow_period < 1 || borrow_period > 20 { + return "[ERROR] borrow_period must be 1..20".to_string(); + } + let acc = or_return! { "Account", Account::create_by(&from_pass) }; + from_pass.clear(); + let addr = or_return! { "Address", Address::from_readable(acc.readable()) }; + let mut tx = TransactionType2::build(addr, fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); + if_add_chain_id(chain_id, &mut tx); + let mut act = MortgageOpen::new(); + act.lending_id = lend_id; + act.mortgage_diamonds = dlist.clone(); + act.loan_total_amount = principal.clone(); + act.borrow_period = Uint1::from(borrow_period); + if let Err(e) = tx.push_action(Box::new(act)) { + return format!("[ERROR] push mortgage open: {}", e); + } + if let Err(e) = tx.fill_sign(&acc) { + return format!("[ERROR] fill_sign: {}", e); + } + let extra = format!( + r##","diamonds":"{}","loan":"{}","borrow_period":{}"##, + dlist.readable(), + principal.to_fin_string(), + borrow_period + ); + mortgage_tx_json(&tx, &fee, &acc, time_set, "mortgage_open", &extra) +} +/// HIP-2 v2.1: redeem mortgaged HACD (action kind 16). +#[wasm_bindgen] +pub fn hacd_mortgage_redeem( + chain_id: u64, + mut from_pass: String, + lending_id_hex: String, + ransom_amount: String, + fee: String, + timestamp: i64, +) -> String { + ensure_sdk_init(); + let time_set = get_time_set(timestamp); + let lend_id = or_return! { "Lending id", parse_lending_id_hex(&lending_id_hex) }; + let ransom = or_return! { "Ransom", Amount::from_string_unsafe(&ransom_amount) }; + let fee = or_return! { "Fee", Amount::from_string_unsafe(&fee) }; + let acc = or_return! { "Account", Account::create_by(&from_pass) }; + from_pass.clear(); + let addr = or_return! { "Address", Address::from_readable(acc.readable()) }; + let mut tx = TransactionType2::build(addr, fee.clone()); + tx.timestamp = Timestamp::from(time_set as u64); + if_add_chain_id(chain_id, &mut tx); + let mut act = MortgageRedeem::new(); + act.lending_id = lend_id; + act.ransom_amount = ransom.clone(); + if let Err(e) = tx.push_action(Box::new(act)) { + return format!("[ERROR] push mortgage redeem: {}", e); + } + if let Err(e) = tx.fill_sign(&acc) { + return format!("[ERROR] fill_sign: {}", e); + } + let extra = format!(r##","ransom":"{}""##, ransom.to_fin_string()); + mortgage_tx_json(&tx, &fee, &acc, time_set, "mortgage_redeem", &extra) +} \ No newline at end of file diff --git a/src/sdk_lib.rs b/src/sdk_lib.rs new file mode 100644 index 0000000..cd7f990 --- /dev/null +++ b/src/sdk_lib.rs @@ -0,0 +1,32 @@ +//! Minimal library entry for the browser WASM SDK (hacash_sdk). + +#![cfg(target_arch = "wasm32")] + +#[macro_use] +extern crate ini; +#[macro_use] +extern crate lazy_static; + +pub mod x16rs; + +#[macro_use] +pub mod sys; +#[macro_use] +pub mod base; +pub mod interface; +pub mod config; +#[macro_use] +pub mod core; +#[macro_use] +pub mod protocol; +pub mod mint; +#[macro_use] +pub mod vm; + +pub mod sdk; + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn wasm_panic_hook() { + console_error_panic_hook::set_once(); +} \ No newline at end of file diff --git a/src/server/ctx/action.rs b/src/server/ctx/action.rs index d57a731..58dcd55 100644 --- a/src/server/ctx/action.rs +++ b/src/server/ctx/action.rs @@ -77,6 +77,29 @@ pub fn action_from_json(main_addr: &Address, jsonv: &serde_json::Value) -> Ret ({ + let Some(btstr) = jsonv[$k].as_str() else { + return errf!("lending_id format error") + }; + let bts = match hex::decode(btstr.replace(" ", "")) { + Ok(b) => b, + _ => return errf!("lending_id hex error"), + }; + if bts.len() != DIAMOND_SYSLEND_ID_SIZE { + return errf!( + "lending_id must be {} bytes hex", + DIAMOND_SYSLEND_ID_SIZE + ); + } + let arr: [u8; DIAMOND_SYSLEND_ID_SIZE] = match bts.try_into() { + Ok(a) => a, + _ => return errf!("lending_id length error"), + }; + DiamondSyslendId::cons(arr) + }) + } + macro_rules! j_uint5 { ($k: expr) => ( j_uint!($k, u64, Uint5) @@ -216,6 +239,26 @@ pub fn action_from_json(main_addr: &Address, jsonv: &serde_json::Value) -> Ret>, + pub listen_host: String, blocks_max: usize, // 4 } impl ApiCtx { - pub fn new(eng: ChainEngine, nd: ChainNode) -> ApiCtx { + pub fn new(eng: ChainEngine, nd: ChainNode, listen_host: String) -> ApiCtx { ApiCtx{ engine: eng, hcshnd: nd, blocks: Arc::default(), miner_worker_notice_count: Arc::default(), + listen_host, blocks_max: 4, } } diff --git a/src/server/ctx/param.rs b/src/server/ctx/param.rs index e85bbf8..94f1130 100644 --- a/src/server/ctx/param.rs +++ b/src/server/ctx/param.rs @@ -3,7 +3,9 @@ #[macro_export] macro_rules! ctx_state{ ($ctx:expr, $state:ident) => ( - let _s1_db = $ctx.engine.state(); + let Some(_s1_db) = $ctx.engine.try_state() else { + return api_state_unavailable(); + }; let $state = CoreStateDisk::wrap(_s1_db.as_ref()); ) } @@ -19,7 +21,9 @@ macro_rules! ctx_store{ #[macro_export] macro_rules! ctx_mintstate{ ($ctx:expr, $mintstate:ident) => ( - let _s3_db = $ctx.engine.state(); + let Some(_s3_db) = $ctx.engine.try_state() else { + return api_state_unavailable(); + }; let $mintstate = MintStateDisk::wrap(_s3_db.as_ref()); ) } @@ -178,6 +182,7 @@ macro_rules! defineQueryObject{ ( $name: ident, $( $item: ident, $ty: ty, $dv: expr,)+ ) => ( #[derive(serde::Deserialize)] + #[serde(default)] struct $name { $( $item: $ty, diff --git a/src/server/ctx/util.rs b/src/server/ctx/util.rs index 7ada1b6..761e480 100644 --- a/src/server/ctx/util.rs +++ b/src/server/ctx/util.rs @@ -42,24 +42,32 @@ pub fn json_headers() -> HeaderMap { headers } -pub fn api_error(errmsg: &str) -> (HeaderMap, String) { - (json_headers(), json!({"ret":1,"err":errmsg}).to_string()) +pub fn api_error(errmsg: &str) -> Response { + (json_headers(), json!({"ret":1,"err":errmsg}).to_string()).into_response() } -pub fn api_ok() -> (HeaderMap, String){ - (json_headers(), json!({"ret":0,"ok":true}).to_string()) +pub fn api_state_unavailable() -> Response { + ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + (json_headers(), json!({"ret":1,"err":"state temporarily unavailable, retry shortly"}).to_string()), + ) + .into_response() +} + +pub fn api_ok() -> Response { + (json_headers(), json!({"ret":0,"ok":true}).to_string()).into_response() } -pub fn api_data_list(jsdts: Vec) -> (HeaderMap, String){ +pub fn api_data_list(jsdts: Vec) -> Response { let list = jsdts.iter().map(|a|a.to_string()).collect::>().join(","); - (json_headers(), format!(r#"{{"ret":0,"list":[{}]}}"#, list)) + (json_headers(), format!(r#"{{"ret":0,"list":[{}]}}"#, list)).into_response() } -pub fn api_data(jsdts: HashMap<&'static str, Value>) -> (HeaderMap, String){ +pub fn api_data(jsdts: HashMap<&'static str, Value>) -> Response { let resjson = jsdts.iter().map(|(k,v)| format!(r#""{}":{}"#, k, v.to_string()) ).collect::>().join(","); - (json_headers(), format!(r#"{{"ret":0,{}}}"#, resjson)) + (json_headers(), format!(r#"{{"ret":0,{}}}"#, resjson)).into_response() } @@ -107,7 +115,7 @@ pub fn get_id_range(max: i64, page: i64, limit: i64, instart: i64, decs: bool) - rng = (end+1..start+1).rev().collect(); } // ok - rng.retain(|&x| x>=1 || x<=max); + rng.retain(|&x| x >= 1 && x <= max); rng } diff --git a/src/server/http/start.rs b/src/server/http/start.rs index a6720f5..79273ad 100644 --- a/src/server/http/start.rs +++ b/src/server/http/start.rs @@ -17,21 +17,52 @@ impl RPCServer { async fn server_listen(mut ser: RPCServer) { + use axum::extract::DefaultBodyLimit; + use axum::Extension; + use std::net::IpAddr; + use crate::server::security::{ + MiddlewareCtx, RPC_BODY_LIMIT_BYTES, resolve_listen_endpoint, security_middleware, + }; + let port = ser.cnf.listen; - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let host = match resolve_listen_endpoint(&ser.cnf.listen_host, port, ser.cnf.allow_public_rpc) { + Ok(h) => h, + Err(e) => { + println!("\n[Error] RPC Server config: {}\n", e); + return; + } + }; + let ip: IpAddr = host.parse().unwrap_or_else(|_| "127.0.0.1".parse().unwrap()); + let addr = SocketAddr::from((ip, port)); let listener = TcpListener::bind(addr).await; if let Err(ref e) = listener { - println!("\n[Error] RPC Server bind port {} error: {}\n", port, e); + println!("\n[Error] RPC Server bind {}:{} error: {}\n", host, port, e); return } let listener = listener.unwrap(); println!("[RPC Server] Listening on http://{addr}"); - // - let app = rpc::routes(ApiCtx::new( + if crate::server::security::is_public_bind_host(&host) { + println!("[RPC Server] WARNING: public bind enabled (allow_public_rpc=true)"); + } + // + let mw = MiddlewareCtx { + listen_host: host.clone(), + listen_port: port, + rate_limiter: Arc::new(crate::server::security::RateLimiter::new(60, 60)), + }; + let ctx = ApiCtx::new( ser.engine.clone(), ser.hcshnd.clone(), - )); - if let Err(e) = axum::serve(listener, app).await { + host, + ); + // Extension must be outermost so security_middleware can extract MiddlewareCtx. + let app = rpc::routes(ctx) + .layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)) + .layer(axum::middleware::from_fn(security_middleware)) + .layer(Extension(mw)); + println!("[RPC Server] HIP-25 wallet UI: http://{addr}/hip25/wallet"); + let make_svc = app.into_make_service_with_connect_info::(); + if let Err(e) = axum::serve(listener, make_svc).await { println!("{e}"); } -} +} \ No newline at end of file diff --git a/src/server/mod.rs b/src/server/mod.rs index f12e0e3..595a286 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -12,6 +12,7 @@ include!("util.rs"); pub mod ctx; mod extend; mod unstable; +pub mod security; mod rpc; pub mod http; diff --git a/src/server/rpc/block.rs b/src/server/rpc/block.rs index 457df70..f222c7e 100644 --- a/src/server/rpc/block.rs +++ b/src/server/rpc/block.rs @@ -182,6 +182,9 @@ async fn block_datas(State(ctx): State, q: Query) -> impl IntoRes q_must!(q, base64body, false); q_must!(q, start_height, 0); q_must!(q, limit, u64::MAX); + if limit > crate::server::security::BLOCK_DATAS_MAX_LIMIT { + limit = crate::server::security::BLOCK_DATAS_MAX_LIMIT; + } q_must!(q, max_size, MB); // 1mb q_must!(q, confirm, false); if max_size > 10*MB { diff --git a/src/server/rpc/create_account.rs b/src/server/rpc/create_account.rs index 007d573..9d1d2e2 100644 --- a/src/server/rpc/create_account.rs +++ b/src/server/rpc/create_account.rs @@ -5,6 +5,10 @@ defineQueryObject!{ Q8936, } async fn account(State(ctx): State, q: Query) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_create_account_on_mainnet(chain_id) { + return api_error(msg); + } q_must!(q, quantity, 1); if quantity == 0 { return api_error("quantity error") diff --git a/src/server/rpc/create_transfer.rs b/src/server/rpc/create_transfer.rs index 89bbff0..06fe846 100644 --- a/src/server/rpc/create_transfer.rs +++ b/src/server/rpc/create_transfer.rs @@ -11,7 +11,35 @@ defineQueryObject!{ Q9374, diamonds, Option, None, } -async fn create_coin_transfer(State(ctx): State, q: Query) -> impl IntoResponse { +fn merge_coin_transfer_json(q: &mut Q9374, body: &[u8]) { + if body.is_empty() { + return; + } + let Ok(v) = serde_json::from_slice::(body) else { + return; + }; + if let Some(s) = v.get("main_prikey").and_then(|x| x.as_str()) { + if !s.is_empty() { + q.main_prikey = s.to_string(); + } + } + if let Some(s) = v.get("from_prikey").and_then(|x| x.as_str()) { + if !s.is_empty() { + q.from_prikey = Some(s.to_string()); + } + } +} + +async fn create_coin_transfer( + State(ctx): State, + Query(mut q): Query, + body: Bytes, +) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { + return api_error(msg); + } + merge_coin_transfer_json(&mut q, &body); ctx_state!(ctx, state); q_must!(q, from_prikey, s!("")); q_must!(q, timestamp, 0); diff --git a/src/server/rpc/fee.rs b/src/server/rpc/fee.rs index 9be650e..36a93bc 100644 --- a/src/server/rpc/fee.rs +++ b/src/server/rpc/fee.rs @@ -36,7 +36,27 @@ defineQueryObject!{ Q5396, hash, Option, None, // find by tx hash } -async fn raise_fee(State(ctx): State, q: Query, body: Bytes) -> impl IntoResponse { +fn merge_raise_fee_json(q: &mut Q5396, body: &[u8]) { + if body.is_empty() { + return; + } + let Ok(v) = serde_json::from_slice::(body) else { + return; + }; + if let Some(s) = v.get("fee_prikey").and_then(|x| x.as_str()) { + if !s.is_empty() { + q.fee_prikey = s.to_string(); + } + } +} + +async fn raise_fee(State(ctx): State, Query(mut q): Query, body: Bytes) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { + return api_error(msg); + } + let body_copy = body.to_vec(); + merge_raise_fee_json(&mut q, &body_copy); // ctx_store!(ctx, store); q_must!(q, hash, s!("")); let fee = q_data_amt!(q, fee); diff --git a/src/server/rpc/hashrate.rs b/src/server/rpc/hashrate.rs index 8004d41..a29bbe6 100644 --- a/src/server/rpc/hashrate.rs +++ b/src/server/rpc/hashrate.rs @@ -5,7 +5,6 @@ use crate::mint::difficulty::*; fn query_hashrate(ctx: &ApiCtx) -> JsonObject { ctx_store!(ctx, store); - ctx_state!(ctx, state); let mtckr = ctx.engine.mint_checker(); let mtcnf = mtckr.config(); @@ -61,6 +60,9 @@ defineQueryObject!{ Q5295, } async fn hashrate(State(ctx): State, q: Query) -> impl IntoResponse { + let Some(_s1_db) = ctx.engine.try_state() else { + return api_state_unavailable(); + }; let data = query_hashrate(&ctx); diff --git a/src/server/rpc/latest.rs b/src/server/rpc/latest.rs index 0adfe11..a617564 100644 --- a/src/server/rpc/latest.rs +++ b/src/server/rpc/latest.rs @@ -10,9 +10,12 @@ async fn latest(State(ctx): State, q: Query) -> impl IntoResponse let lasthei = ctx.engine.latest_block().objc().height().uint(); let lastdia = mintstate.latest_diamond(); // return data + let chain_id = ctx.engine.config().chain_id; let mut data = jsondata!{ "height", lasthei, "diamond", lastdia.number.uint(), + "chain_id", chain_id, + "hip25_dev", chain_id == crate::config::HIP25_DEV_CHAIN_ID, }; api_data(data) } diff --git a/src/server/rpc/miner.rs b/src/server/rpc/miner.rs index ab436c9..64a8058 100644 --- a/src/server/rpc/miner.rs +++ b/src/server/rpc/miner.rs @@ -35,10 +35,10 @@ fn update_miner_pending_block(block: BlockV1, cbtx: TransactionCoinbase) { } -fn get_miner_pending_block_stuff(is_detail: bool, is_transaction: bool, is_stuff: bool, is_base64: bool) -> (HeaderMap, String) { +fn get_miner_pending_block_stuff(is_detail: bool, is_transaction: bool, is_stuff: bool, is_base64: bool) -> Response { let mut stuff = MINER_PENDING_BLOCK.lock().unwrap(); if stuff.len() == 0 { - panic!("get miner pending block stuff error: block not init!"); + return api_error("miner pending block not initialized; enable miner and wait for first template"); }; let stuff = &mut stuff[0]; @@ -198,14 +198,20 @@ fn append_valid_tx_pick_from_txpool(nexthei: u64, trslen: &mut usize, trshxs: &m macro_rules! check_pick_one_tx { ($a: expr) => { let txr = $a.objc().as_ref().as_read(); - if let Err(..) = txr.verify_signature() { - return true // sign fail, ignore, next + if txr.verify_signature().is_err() { + return true; } - if let Err(..) = engine.try_execute_tx(txr) { - return true // execute fail, ignore, next + let mut sim: Vec<&dyn TransactionRead> = trs + .list() + .iter() + .skip(1) + .map(|t| t.as_ref().as_read()) + .collect(); + sim.push(txr); + if engine.try_execute_txs_cumulative(&sim).is_err() { + return true; } - } - + }; } // pick one diamond mint tx diff --git a/src/server/rpc/mod.rs b/src/server/rpc/mod.rs index 224a2fd..263b618 100644 --- a/src/server/rpc/mod.rs +++ b/src/server/rpc/mod.rs @@ -67,5 +67,9 @@ include!("fee.rs"); include!("miner.rs"); include!("diamond_miner.rs"); +include!("staking.rs"); +include!("mortgage.rs"); +include!("wallet_ui.rs"); + diff --git a/src/server/rpc/mortgage.rs b/src/server/rpc/mortgage.rs new file mode 100644 index 0000000..96fe286 --- /dev/null +++ b/src/server/rpc/mortgage.rs @@ -0,0 +1,239 @@ + +use crate::mint::component::*; +use crate::mint::operate::{mortgage_calc_ransom, mortgage_compute_principal, mortgage_origination_burn}; + +defineQueryObject!{ QMortgageGlobal, + __nnn_, Option, None, +} + +async fn mortgage_global(State(ctx): State, _q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + let global = mintstate.mortgage_global(); + let data = jsondata!{ + "outstanding_ioo_zhu", global.outstanding_ioo_zhu.uint(), + "cumulative_loan_zhu", global.cumulative_loan_zhu.uint(), + "cumulative_ransom_burn_zhu", global.cumulative_ransom_burn_zhu.uint(), + "cumulative_origination_burn_zhu", global.cumulative_origination_burn_zhu.uint(), + "active_contracts", global.active_contracts.uint(), + "activation_height", global.activation_height.uint(), + "max_outstanding_ioo_zhu", global.max_outstanding_ioo_zhu.uint(), + "period_blocks", global.effective_period_blocks(), + "origination_fee_bps", MORTGAGE_ORIGINATION_FEE_BPS, + "early_grace_periods", MORTGAGE_EARLY_GRACE_PERIODS, + "early_interest_bps_per_period", MORTGAGE_EARLY_INTEREST_BPS_PER_PERIOD, + "apr_bps", MORTGAGE_APR_BPS, + "blocks_per_year", MORTGAGE_BLOCKS_PER_YEAR, + "auction_floor_bps", MORTGAGE_AUCTION_FLOOR_BPS, + "economics_version", "v2.1", + "owner_index_max", MORTGAGE_OWNER_INDEX_MAX as u64, + }; + api_data(data) +} + +defineQueryObject!{ QMortgageContract, + id, String, s!(""), + redeemer, String, s!(""), + height, Option, None, +} + +defineQueryObject!{ QMortgagePortfolio, + address, String, s!(""), +} + +defineQueryObject!{ QMortgagePrincipal, + diamonds, String, s!(""), +} + +async fn mortgage_contract(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let id_hex = q.id.replace(" ", "").replace("\n", ""); + let id_bytes = match hex::decode(&id_hex) { + Ok(b) if b.len() == DIAMOND_SYSLEND_ID_SIZE => b, + _ => return api_error("mortgage lending id hex error"), + }; + let lend_id = DiamondSyslendId::cons(id_bytes.try_into().unwrap()); + let contract = mintstate.diamond_syslend(&lend_id); + if contract.is_none() { + return api_error("mortgage contract not found"); + } + let contract = contract.unwrap(); + let global = mintstate.mortgage_global(); + let period_blocks = global.effective_period_blocks(); + + let mut phase = "".to_string(); + let mut min_ransom = "0".to_string(); + if !contract.redeemed() { + let redeemer_ads = q.redeemer.replace(" ", "").replace("\n", ""); + let height = q + .height + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| ctx.engine.latest_block().objc().height().uint()); + let redeemer = if redeemer_ads.is_empty() { + contract.main_address.clone() + } else { + match Address::from_readable(&redeemer_ads) { + Ok(a) => a, + Err(_) => return api_error("redeemer address format error"), + } + }; + if let Ok((ph, amt)) = mortgage_calc_ransom(&contract, &redeemer, height, period_blocks) { + phase = ph.label().to_string(); + min_ransom = amt.to_unit_string(&unit); + } + } + + let diamonds: Vec = contract + .mortgage_diamonds + .list() + .iter() + .map(|d| d.readable()) + .collect(); + + let data = jsondata!{ + "lending_id", id_hex, + "redeemed", contract.redeemed(), + "create_height", contract.create_block_height.uint(), + "main_address", contract.main_address.readable(), + "loan_principal", contract.loan_principal.to_unit_string(&unit), + "borrow_period", contract.borrow_period.uint(), + "diamonds", diamonds, + "ransom_height", contract.ransom_block_height.uint(), + "ransom_address", contract.ransom_address.readable(), + "redeem_phase", phase, + "min_ransom", min_ransom, + "period_blocks", period_blocks, + }; + api_data(data) +} + +async fn mortgage_portfolio(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let ads = q.address.replace(" ", "").replace("\n", ""); + let adr = match Address::from_readable(&ads) { + Ok(a) => a, + Err(_) => return api_error("address format error"), + }; + let global = mintstate.mortgage_global(); + let period_blocks = global.effective_period_blocks(); + let height = ctx.engine.latest_block().objc().height().uint(); + let index = mintstate.mortgage_owner_index(&adr).unwrap_or_default(); + let mut contracts: Vec = Vec::new(); + for lend_id in index.iter_ids() { + let Some(contract) = mintstate.diamond_syslend(&lend_id) else { + continue; + }; + if contract.redeemed() { + continue; + } + let id_hex = hex::encode(lend_id.as_ref()); + let diamonds: Vec = contract + .mortgage_diamonds + .list() + .iter() + .map(|d| d.readable()) + .collect(); + let (phase, min_ransom) = mortgage_calc_ransom(&contract, &adr, height, period_blocks) + .map(|(ph, amt)| (ph.label().to_string(), amt.to_unit_string(&unit))) + .unwrap_or_else(|_| ("".to_string(), "0".to_string())); + contracts.push(json!({ + "lending_id": id_hex, + "loan_principal": contract.loan_principal.to_unit_string(&unit), + "borrow_period": contract.borrow_period.uint(), + "diamonds": diamonds, + "create_height": contract.create_block_height.uint(), + "redeem_phase": phase, + "min_ransom": min_ransom, + })); + } + let indexed = index.iter_ids().len() as u64; + let data = jsondata!{ + "address", adr.readable(), + "active_count", contracts.len() as u64, + "indexed_count", indexed, + "owner_index_max", MORTGAGE_OWNER_INDEX_MAX as u64, + "owner_index_full", indexed >= MORTGAGE_OWNER_INDEX_MAX as u64, + "contracts", contracts, + "economics_version", "v2.1", + }; + api_data(data) +} + +async fn mortgage_principal(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstore!(ctx, mintstore); + q_unit!(q, unit); + let dialist = DiamondNameListMax200::from_readable(&q.diamonds.replace(" ", "")); + let list = match dialist { + Ok(l) => l, + Err(e) => return api_error(&format!("diamonds {}", e)), + }; + if list.count().uint() == 0 { + return api_error("diamonds required"); + } + let principal = match mortgage_compute_principal(&mintstore, &list) { + Ok(p) => p, + Err(e) => return api_error(&e), + }; + let origination_burn = mortgage_origination_burn(&principal) + .map(|a| a.to_unit_string(&unit)) + .unwrap_or_else(|_| "0".to_string()); + let data = jsondata!{ + "loan", principal.to_unit_string(&unit), + "diamonds", list.readable(), + "origination_fee_bps", MORTGAGE_ORIGINATION_FEE_BPS, + "origination_burn", origination_burn, + "hacd_count", list.count().uint(), + }; + api_data(data) +} + +#[cfg(test)] +mod mortgage_query_tests { + use super::*; + use crate::core::state::{BlockStore, ChainState}; + use crate::mint::state::MintStoreDisk; + use tempfile::TempDir; + + fn seed_smelt(block_store: &BlockStore, name: &str, burn_mei: u16) { + let dian = DiamondName::cons(name.as_bytes().try_into().unwrap()); + let smelt = DiamondSmelt { + diamond: dian.clone(), + number: DiamondNumber::from(1), + born_height: BlockHeight::from(1), + born_hash: Hash::default(), + prev_hash: Hash::default(), + miner_address: Address::default(), + bid_fee: Amount::default(), + nonce: Fixed8::default(), + average_bid_burn: Uint2::from(burn_mei), + life_gene: Hash::default(), + }; + MintStoreDisk::wrap(block_store).put_diamond_smelt(&dian, &smelt); + } + + #[test] + fn mortgage_principal_quote_matches_two_hacd_bid_burn() { + let dir = TempDir::new().unwrap(); + let state = ChainState::open(dir.path()); + let block_store = BlockStore::from_shared(state.copy_ldb()); + seed_smelt(&block_store, "WTYUIA", 100); + seed_smelt(&block_store, "HXVMEK", 100); + let mint_store = MintStoreDisk::wrap(&block_store); + let list = + DiamondNameListMax200::from_readable("WTYUIA,HXVMEK").expect("diamond list"); + let principal = mortgage_compute_principal(&mint_store, &list).expect("principal"); + let burn = mortgage_origination_burn(&principal).expect("origination"); + assert_eq!(principal.to_fin_string(), "2:250"); + assert_eq!(burn.to_fin_string(), "2:248"); + assert_eq!(list.count().uint(), 2); + } + + #[test] + fn mortgage_principal_query_deserializes_diamonds_only() { + let q: QMortgagePrincipal = + serde_urlencoded::from_str("diamonds=WTYUIA").expect("diamonds query"); + assert_eq!(q.diamonds, "WTYUIA"); + } +} \ No newline at end of file diff --git a/src/server/rpc/routes.rs b/src/server/rpc/routes.rs index e36a8ce..ef227e8 100644 --- a/src/server/rpc/routes.rs +++ b/src/server/rpc/routes.rs @@ -4,7 +4,17 @@ pub fn routes(mut ctx: ApiCtx) -> Router { use ctx::*; - let lrt = Router::new().route("/", get(console)) + let lrt = Router::new() + .route("/hip25/wallet", get(hip25_wallet_page)) + .route("/hip25/wallet/js/core.js", get(hip25_wallet_core_js)) + .route("/hip25/wallet/js/api.js", get(hip25_wallet_api_js)) + .route("/hip25/wallet/js/signing.js", get(hip25_wallet_signing_js)) + .route("/hip25/wallet/js/portfolio.js", get(hip25_wallet_portfolio_js)) + .route("/hip25/wallet/js/mortgage.js", get(hip25_wallet_mortgage_js)) + .route("/hip25/wallet/js/app.js", get(hip25_wallet_app_js)) + .route("/pkg/hacash_sdk.js", get(hip25_sdk_js)) + .route("/pkg/hacash_sdk_bg.wasm", get(hip25_sdk_wasm)) + .route("/", get(console)) // query .route(&query("latest"), get(latest)) @@ -28,6 +38,16 @@ pub fn routes(mut ctx: ApiCtx) -> Router { .route(&query("diamond/engrave"), get(diamond_engrave)) .route(&query("diamond/inscription_protocol_cost"), get(diamond_inscription_protocol_cost)) + .route(&query("staking/status"), get(staking_status)) + .route(&query("staking/summary"), get(staking_summary)) + .route(&query("staking/global"), get(staking_global)) + .route(&query("staking/events"), get(staking_events)) + + .route(&query("mortgage/global"), get(mortgage_global)) + .route(&query("mortgage/contract"), get(mortgage_contract)) + .route(&query("mortgage/portfolio"), get(mortgage_portfolio)) + .route(&query("mortgage/principal"), get(mortgage_principal)) + .route(&query("fee/average"), get(fee_average)) .route(&query("miner/notice"), get(miner_notice)) @@ -37,7 +57,7 @@ pub fn routes(mut ctx: ApiCtx) -> Router { // create .route(&create("account"), get(account)) .route(&create("transaction"), post(transaction_build)) - .route(&create("coin/transfer"), get(create_coin_transfer)) + .route(&create("coin/transfer"), post(create_coin_transfer)) // submit .route(&submit("transaction"), post(submit_transaction)) @@ -55,11 +75,13 @@ pub fn routes(mut ctx: ApiCtx) -> Router { ; - // merge unstable & extend - Router::new().merge(lrt) - .merge(unstable::routes()) - .merge(extend::routes()) - .with_state(ctx) + // merge extend (unstable test routes disabled in release builds) + let mut router = Router::new().merge(lrt).merge(extend::routes()); + #[cfg(debug_assertions)] + { + router = router.merge(unstable::routes()); + } + router.with_state(ctx) } diff --git a/src/server/rpc/staking.rs b/src/server/rpc/staking.rs new file mode 100644 index 0000000..7fb27f2 --- /dev/null +++ b/src/server/rpc/staking.rs @@ -0,0 +1,228 @@ + +use crate::mint::component::*; +use crate::mint::operate::staking_display_accrued_reward; + +defineQueryObject!{ QStakingStatus, + diamond, String, s!(""), +} + +async fn staking_status(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let name = q.diamond.replace(" ", "").replace("\n", ""); + if !DiamondName::is_valid(name.as_bytes()) { + return api_error("diamond name error"); + } + let dian = DiamondName::cons(name.as_bytes().try_into().unwrap()); + let diaobj = mintstate.diamond(&dian); + if diaobj.is_none() { + return api_error("cannot find diamond"); + } + let diaobj = diaobj.unwrap(); + let global = mintstate.staking_global(); + let status = diamond_wallet_status_label(&diaobj.status); + let mut stake_height = 0u64; + let mut unlock_height = 0u64; + let mut min_unstake_height = 0u64; + let mut accrued_reward = "0".to_string(); + if let Some(rec) = mintstate.staking_record(&dian) { + stake_height = rec.stake_height.uint(); + unlock_height = rec.unlock_height.uint(); + if stake_height > 0 { + let chain_id = ctx.engine.config().chain_id; + min_unstake_height = stake_height + global.effective_min_stake_blocks(chain_id); + } + if let Ok(amt) = staking_display_accrued_reward(&global.global_reward_index, &rec) { + accrued_reward = amt.to_unit_string(&unit); + } + } + let data = jsondata!{ + "literal", dian.readable(), + "status", status, + "staker", diaobj.address.readable(), + "accrued_reward", accrued_reward, + "min_unstake_height", min_unstake_height, + "unlock_height", unlock_height, + "stake_height", stake_height, + }; + api_data(data) +} + +defineQueryObject!{ QStakingSummary, + address, String, s!(""), + offset, String, s!("0"), + limit, String, s!("200"), +} + +async fn staking_summary(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let ads = q.address.replace(" ", "").replace("\n", ""); + let adr = match Address::from_readable(&ads) { + Ok(a) => a, + Err(_) => return api_error("address format error"), + }; + let owned = mintstate.diamond_owned(&adr).unwrap_or_default(); + let names = owned.readable(); + let global = mintstate.staking_global(); + let mut offset = q.offset.parse::().unwrap_or(0); + let mut limit = q.limit.parse::().unwrap_or(200); + if limit == 0 { + limit = 200; + } + if limit > crate::server::security::STAKING_SUMMARY_MAX_DIAMONDS { + limit = crate::server::security::STAKING_SUMMARY_MAX_DIAMONDS; + } + let l = DiamondName::width(); + let bytes = names.as_bytes(); + let total_owned = bytes.len() / l; + let mut staked_count = 0u64; + let mut cooldown_count = 0u64; + let mut total_accrued = Amount::default(); + let mut processed = 0usize; + let mut skipped = 0usize; + for i in (0..bytes.len()).step_by(l) { + if i + l > bytes.len() { + break; + } + if skipped < offset { + skipped += 1; + continue; + } + if processed >= limit { + break; + } + processed += 1; + let dian = DiamondName::cons(bytes[i..i + l].try_into().unwrap()); + let Some(diaobj) = mintstate.diamond(&dian) else { + continue; + }; + if diaobj.status == DIAMOND_STATUS_STAKED { + staked_count += 1; + } else if diaobj.status == DIAMOND_STATUS_STAKING_COOLDOWN { + cooldown_count += 1; + } + if let Some(rec) = mintstate.staking_record(&dian) { + if let Ok(amt) = staking_display_accrued_reward(&global.global_reward_index, &rec) { + total_accrued = total_accrued.add(&amt).unwrap_or(total_accrued); + } + } + } + let truncated = offset + processed < total_owned; + let data = jsondata!{ + "staked_count", staked_count, + "cooldown_count", cooldown_count, + "total_accrued_reward", total_accrued.to_unit_string(&unit), + "total_owned", total_owned as u64, + "offset", offset as u64, + "limit", limit as u64, + "truncated", truncated, + }; + api_data(data) +} + +defineQueryObject!{ QStakingGlobal, + __nnn_, Option, None, +} + +async fn staking_global(State(ctx): State, _q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + let global = mintstate.staking_global(); + let data = jsondata!{ + "total_staked_shares", global.total_staked_shares.uint(), + "reward_pool_pending_zhu", global.reward_pool_zhu.uint(), + "global_reward_index", global.global_reward_index.uint(), + "activation_height", global.activation_height.uint(), + "event_count", global.event_log_tail.uint(), + "paused", global.is_paused(), + "economics_version", STAKING_ECONOMICS_VERSION, + "fee_share_percent", STAKING_FEE_SHARE_PERCENT, + "fee_sources", STAKING_FEE_SOURCES, + "cumulative_deposit_zhu", global.cumulative_deposit_zhu.uint(), + "cumulative_paid_zhu", global.cumulative_paid_zhu.uint(), + "cumulative_pool_burned_zhu", global.cumulative_pool_burned_zhu.uint(), + "zero_staker_blocks", global.zero_staker_blocks.uint(), + "pool_sweep_blocks", STAKING_POOL_SWEEP_BLOCKS, + }; + api_data(data) +} + +defineQueryObject!{ QStakingEvents, + from, String, s!("0"), + limit, String, s!("50"), +} + +async fn staking_events(State(ctx): State, q: Query) -> impl IntoResponse { + ctx_mintstate!(ctx, mintstate); + q_unit!(q, unit); + let from = q.from.parse::().unwrap_or(0); + let mut limit = q.limit.parse::().unwrap_or(50); + if limit == 0 { + limit = 50; + } + if limit > 200 { + limit = 200; + } + let global = mintstate.staking_global(); + let tail = global.event_log_tail.uint(); + let end = (from + limit).min(tail); + let mut items: Vec = Vec::new(); + for id in from..end { + let Some(ev) = mintstate.staking_event(&Uint5::from(id)) else { + continue; + }; + let literal = if ev.diamond.readable().trim().is_empty() { + "".to_string() + } else { + ev.diamond.readable() + }; + let staker = if ev.staker.readable().is_empty() { + "".to_string() + } else { + ev.staker.readable() + }; + items.push(json!({ + "id": id, + "kind": staking_event_kind_label(&ev.kind), + "height": ev.height.uint(), + "literal": literal, + "staker": staker, + "unlock_height": ev.unlock_height.uint(), + "reward": ev.reward.to_unit_string(&unit), + "shares": ev.shares.uint(), + })); + } + let data = jsondata!{ + "from", from, + "limit", limit, + "total", tail, + "events", items, + }; + api_data(data) +} + +#[cfg(test)] +mod staking_query_tests { + use super::*; + + #[test] + fn staking_summary_deserializes_without_pagination() { + let q: QStakingSummary = serde_urlencoded::from_str( + "address=1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + ) + .expect("address-only query must deserialize"); + assert_eq!(q.address, "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2"); + assert_eq!(q.offset, "0"); + assert_eq!(q.limit, "200"); + } + + #[test] + fn staking_summary_deserializes_explicit_pagination() { + let q: QStakingSummary = serde_urlencoded::from_str( + "address=1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2&offset=10&limit=50", + ) + .expect("paginated query must deserialize"); + assert_eq!(q.offset, "10"); + assert_eq!(q.limit, "50"); + } +} \ No newline at end of file diff --git a/src/server/rpc/submit_transaction.rs b/src/server/rpc/submit_transaction.rs index 02e54cd..904a3b4 100644 --- a/src/server/rpc/submit_transaction.rs +++ b/src/server/rpc/submit_transaction.rs @@ -14,6 +14,7 @@ async fn submit_transaction(State(ctx): State, q: Query, body: By } let txpkg = txpkg.unwrap(); // try submit + // Avoid nested block_on inside the axum runtime (would drop the HTTP connection). let is_async = true; if let Err(e) = ctx.hcshnd.submit_transaction(&txpkg, is_async) { return api_error(&e) diff --git a/src/server/rpc/supply.rs b/src/server/rpc/supply.rs index 736009a..aedac28 100644 --- a/src/server/rpc/supply.rs +++ b/src/server/rpc/supply.rs @@ -14,8 +14,11 @@ async fn supply(State(ctx): State, q: Query) -> impl IntoResponse // total supply const ZHU: u64 = 1_0000_0000; let supply = mintstate.total_count(); + let mortgage = mintstate.mortgage_global(); let blk_rwd = cumulative_block_reward(lasthei) * ZHU; - let burn_fee = *supply.hacd_bid_burn_zhu + *supply.diamond_insc_burn_zhu; + let mortgage_burn = mortgage.cumulative_origination_burn_zhu.uint() + + mortgage.cumulative_ransom_burn_zhu.uint(); + let burn_fee = *supply.hacd_bid_burn_zhu + *supply.diamond_insc_burn_zhu + mortgage_burn; let curr_ccl = blk_rwd + *supply.channel_interest_zhu - burn_fee; // let z2m = |zhu|zhu as f64 / ZHU as f64; @@ -38,6 +41,14 @@ async fn supply(State(ctx): State, q: Query) -> impl IntoResponse "trsbtc_subsidy", 0, "block_reward", z2m(blk_rwd), + + "mortgage_outstanding_ioo_zhu", mortgage.outstanding_ioo_zhu.uint(), + "mortgage_active_contracts", mortgage.active_contracts.uint(), + "mortgage_cumulative_loan_zhu", mortgage.cumulative_loan_zhu.uint(), + "mortgage_origination_burn_zhu", mortgage.cumulative_origination_burn_zhu.uint(), + "mortgage_ransom_burn_zhu", mortgage.cumulative_ransom_burn_zhu.uint(), + "mortgage_economics_version", "v2.1", + "minted_diamond", lastdia.number.uint(), }; api_data(data) diff --git a/src/server/rpc/transaction.rs b/src/server/rpc/transaction.rs index 176ebf5..9bf6417 100644 --- a/src/server/rpc/transaction.rs +++ b/src/server/rpc/transaction.rs @@ -16,7 +16,44 @@ defineQueryObject!{ Q8375, } -async fn transaction_sign(State(ctx): State, q: Query, body: Bytes) -> impl IntoResponse { +fn parse_sign_request(q: &mut Q8375, body: &Bytes) -> Result, String> { + if !body.is_empty() { + if let Ok(v) = serde_json::from_slice::(body) { + if let Some(pk) = v.get("prikey").and_then(|x| x.as_str()) { + if !pk.is_empty() { + q.prikey = Some(pk.to_string()); + } + } + if let Some(pk) = v.get("pubkey").and_then(|x| x.as_str()) { + if !pk.is_empty() { + q.pubkey = Some(pk.to_string()); + } + } + if let Some(sig) = v.get("sigdts").and_then(|x| x.as_str()) { + if !sig.is_empty() { + q.sigdts = Some(sig.to_string()); + } + } + if let Some(tx) = v.get("tx_body").and_then(|x| x.as_str()) { + let raw = hex::decode(tx).map_err(|_| "tx_body hex error".to_string())?; + return Ok(raw); + } + } + } + let hexbody = q.hexbody.unwrap_or(false); + let bddt = body.to_vec(); + let raw = match hexbody { + false => bddt, + true => hex::decode(&bddt).map_err(|_| "hex format error".to_string())?, + }; + Ok(raw) +} + +async fn transaction_sign(State(ctx): State, Query(mut q): Query, body: Bytes) -> impl IntoResponse { + let chain_id = ctx.engine.config().chain_id; + if let Some(msg) = crate::server::security::reject_server_secret_signing(chain_id) { + return api_error(msg); + } ctx_store!(ctx, store); ctx_state!(ctx, state); q_unit!(q, unit); @@ -28,7 +65,12 @@ async fn transaction_sign(State(ctx): State, q: Query, body: Byte let lasthei = ctx.engine.latest_block().objc().height().uint(); - let txdts = q_body_data_may_hex!(q, body); + let txdts = match parse_sign_request(&mut q, &body) { + Ok(v) => v, + Err(e) => return api_error(&e), + }; + q_must!(q, prikey, s!("")); + q_must!(q, pubkey, s!("")); let Ok((mut tx, _)) = transaction::create(&txdts) else { return api_error("transaction body error") }; diff --git a/src/server/rpc/wallet_ui.rs b/src/server/rpc/wallet_ui.rs new file mode 100644 index 0000000..3329a42 --- /dev/null +++ b/src/server/rpc/wallet_ui.rs @@ -0,0 +1,120 @@ + +const HIP25_WALLET_HTML: &str = include_str!("../../../wallet/hip25/index.html"); +const HIP25_WALLET_CORE_JS: &str = include_str!("../../../wallet/hip25/js/core.js"); +const HIP25_WALLET_API_JS: &str = include_str!("../../../wallet/hip25/js/api.js"); +const HIP25_WALLET_SIGNING_JS: &str = include_str!("../../../wallet/hip25/js/signing.js"); +const HIP25_WALLET_PORTFOLIO_JS: &str = include_str!("../../../wallet/hip25/js/portfolio.js"); +const HIP25_WALLET_MORTGAGE_JS: &str = include_str!("../../../wallet/hip25/js/mortgage.js"); +const HIP25_WALLET_APP_JS: &str = include_str!("../../../wallet/hip25/js/app.js"); + +fn hip25_pkg_dir() -> Option { + let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); + let candidates = [ + exe_dir.join("pkg"), + exe_dir.join("wallet").join("hip25").join("pkg"), + ]; + for p in candidates { + if p.is_dir() { + return Some(p); + } + } + None +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes); + hex::encode(digest) +} + +fn integrity_expected(dir: &std::path::Path, name: &str) -> Option { + let manifest = dir.join("integrity.json"); + let raw = std::fs::read_to_string(&manifest).ok()?; + let v: serde_json::Value = serde_json::from_str(&raw).ok()?; + v.get(name)?.as_str().map(|s| s.to_ascii_lowercase()) +} + +fn serve_pkg_file(name: &str, content_type: &'static str) -> Response { + use axum::http::StatusCode; + let Some(dir) = hip25_pkg_dir() else { + return ( + StatusCode::NOT_FOUND, + "HIP-25 WASM SDK not built; run scripts/build_wallet_sdk.ps1", + ) + .into_response(); + }; + let path = dir.join(name); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(_) => return (StatusCode::NOT_FOUND, format!("missing {}", name)).into_response(), + }; + if let Some(expected) = integrity_expected(&dir, name) { + let actual = sha256_hex(&bytes); + if actual != expected { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!( + "HIP-25 SDK integrity check failed for {name}; rebuild with scripts/build_wallet_sdk.ps1" + ), + ) + .into_response(); + } + } + ( + [ + (header::CONTENT_TYPE, content_type), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + bytes, + ) + .into_response() +} + +fn hip25_wallet_js(content: &'static str) -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript; charset=utf-8"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + content, + ) +} + +async fn hip25_wallet_page() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + HIP25_WALLET_HTML, + ) +} + +async fn hip25_wallet_core_js() -> impl IntoResponse { + hip25_wallet_js(HIP25_WALLET_CORE_JS) +} + +async fn hip25_wallet_api_js() -> impl IntoResponse { + hip25_wallet_js(HIP25_WALLET_API_JS) +} + +async fn hip25_wallet_signing_js() -> impl IntoResponse { + hip25_wallet_js(HIP25_WALLET_SIGNING_JS) +} + +async fn hip25_wallet_portfolio_js() -> impl IntoResponse { + hip25_wallet_js(HIP25_WALLET_PORTFOLIO_JS) +} + +async fn hip25_wallet_mortgage_js() -> impl IntoResponse { + hip25_wallet_js(HIP25_WALLET_MORTGAGE_JS) +} + +async fn hip25_wallet_app_js() -> impl IntoResponse { + hip25_wallet_js(HIP25_WALLET_APP_JS) +} + +async fn hip25_sdk_js() -> impl IntoResponse { + serve_pkg_file("hacash_sdk.js", "application/javascript") +} + +async fn hip25_sdk_wasm() -> impl IntoResponse { + serve_pkg_file("hacash_sdk_bg.wasm", "application/wasm") +} \ No newline at end of file diff --git a/src/server/security.rs b/src/server/security.rs new file mode 100644 index 0000000..fcbd1ef --- /dev/null +++ b/src/server/security.rs @@ -0,0 +1,265 @@ +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::{ + extract::{ConnectInfo, Request}, + http::{header, HeaderValue, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use crate::config::HIP25_DEV_CHAIN_ID; + +use super::ctx::api_error; + +/// Max RPC POST body (signed tx, hex payloads). +pub const RPC_BODY_LIMIT_BYTES: usize = 256 * 1024; + +/// Max diamonds processed per staking/summary request. +pub const STAKING_SUMMARY_MAX_DIAMONDS: usize = 200; + +/// Max blocks per block/datas request. +pub const BLOCK_DATAS_MAX_LIMIT: u64 = 500; + +const SECRET_QUERY_MARKERS: &[&str] = &[ + "prikey=", + "main_prikey=", + "from_prikey=", + "fee_prikey=", + "password=", +]; + +const RATE_LIMIT_MAX_KEYS: usize = 4096; + +pub fn is_mainnet(chain_id: u64) -> bool { + chain_id != HIP25_DEV_CHAIN_ID +} + +pub fn is_loopback_host(host: &str) -> bool { + matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]") +} + +pub fn is_public_bind_host(host: &str) -> bool { + host == "0.0.0.0" || host == "::" || host == "[::]" +} + +pub fn server_signing_disabled_msg() -> &'static str { + "server-side secret signing is disabled on mainnet; use client-side WASM signing" +} + +pub fn reject_server_secret_signing(chain_id: u64) -> Option<&'static str> { + if is_mainnet(chain_id) { + Some(server_signing_disabled_msg()) + } else { + None + } +} + +pub fn reject_create_account_on_mainnet(chain_id: u64) -> Option<&'static str> { + if is_mainnet(chain_id) { + Some("create/account is disabled on mainnet RPC") + } else { + None + } +} + +pub fn query_string_has_secret_keys(query: &str) -> bool { + if query.is_empty() { + return false; + } + let q = query.to_ascii_lowercase(); + SECRET_QUERY_MARKERS.iter().any(|m| q.contains(m)) +} + +pub fn path_accepts_signing_secrets(path: &str) -> bool { + path == "/util/transaction/sign" + || path == "/create/coin/transfer" + || path == "/operate/fee/raise" +} + +/// Sliding-window per-IP rate limiter for expensive RPC routes. +pub struct RateLimiter { + inner: Mutex>, + max_per_window: u32, + window: Duration, +} + +impl RateLimiter { + pub fn new(max_per_window: u32, window_secs: u64) -> Self { + Self { + inner: Mutex::new(HashMap::new()), + max_per_window, + window: Duration::from_secs(window_secs), + } + } + + fn prune_stale(map: &mut HashMap, now: Instant, window: Duration) { + if map.len() <= RATE_LIMIT_MAX_KEYS { + return; + } + map.retain(|_, (_, start)| now.duration_since(*start) <= window); + if map.len() > RATE_LIMIT_MAX_KEYS { + map.clear(); + } + } + + pub fn allow(&self, key: &str) -> bool { + let mut map = self.inner.lock().unwrap(); + let now = Instant::now(); + Self::prune_stale(&mut map, now, self.window); + let entry = map.entry(key.to_string()).or_insert((0, now)); + if now.duration_since(entry.1) > self.window { + *entry = (0, now); + } + if entry.0 >= self.max_per_window { + return false; + } + entry.0 += 1; + true + } +} + +fn client_ip(request: &Request) -> String { + request + .extensions() + .get::>() + .map(|c| c.0.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn is_mutating_path(path: &str) -> bool { + path.starts_with("/submit/") + || path.starts_with("/operate/") + || path.starts_with("/create/") + || path.starts_with("/util/") +} + +fn is_rate_limited_post(path: &str, method: &Method) -> bool { + *method == Method::POST + && (path.starts_with("/submit/") + || path.starts_with("/operate/") + || path.starts_with("/create/") + || path.starts_with("/util/")) +} + +fn is_rate_limited_get(path: &str, method: &Method) -> bool { + *method == Method::GET && path.starts_with("/submit/miner/") +} + +pub fn origin_allowed(origin: &str, listen_host: &str, listen_port: u16) -> bool { + let o = origin.trim(); + if is_loopback_host(listen_host) { + if o.is_empty() { + return true; + } + return o.starts_with(&format!("http://127.0.0.1:{listen_port}")) + || o.starts_with(&format!("http://localhost:{listen_port}")) + || o.starts_with(&format!("https://127.0.0.1:{listen_port}")) + || o.starts_with(&format!("https://localhost:{listen_port}")); + } + if o.is_empty() { + return false; + } + let host = listen_host.trim(); + o.starts_with(&format!("http://{host}:{listen_port}")) + || o.starts_with(&format!("https://{host}:{listen_port}")) + || o == format!("http://{host}") + || o == format!("https://{host}") +} + +#[derive(Clone)] +pub struct MiddlewareCtx { + pub listen_host: String, + pub listen_port: u16, + pub rate_limiter: Arc, +} + +pub async fn security_middleware( + axum::Extension(mw): axum::Extension, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path().to_string(); + let method = request.method().clone(); + let query = request.uri().query().unwrap_or("").to_string(); + + if query_string_has_secret_keys(&query) + && (path_accepts_signing_secrets(&path) || path.starts_with("/util/")) + { + return ( + StatusCode::BAD_REQUEST, + api_error( + "secrets must be sent in POST JSON body, never in URL query strings", + ), + ) + .into_response(); + } + + if is_mutating_path(&path) { + let origin_hdr = request + .headers() + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !origin_allowed(origin_hdr, &mw.listen_host, mw.listen_port) { + return ( + StatusCode::FORBIDDEN, + api_error("cross-origin mutation blocked; use the local wallet UI"), + ) + .into_response(); + } + } + + if is_rate_limited_post(&path, &method) || is_rate_limited_get(&path, &method) { + let ip = client_ip(&request); + let verb = if method == Method::POST { "post" } else { "get" }; + let rate_key = format!("{verb}:{ip}:{}", path); + if !mw.rate_limiter.allow(&rate_key) { + return ( + StatusCode::TOO_MANY_REQUESTS, + api_error("rate limit exceeded, retry later"), + ) + .into_response(); + } + } + + if method == Method::GET && path == "/create/coin/transfer" { + return ( + StatusCode::METHOD_NOT_ALLOWED, + api_error( + "create/coin/transfer requires POST; never put prikey/password in URL query strings", + ), + ) + .into_response(); + } + + let mut response = next.run(request).await; + let headers = response.headers_mut(); + if path.starts_with("/hip25/wallet") { + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'none'; object-src 'none'; base-uri 'self'", + ), + ); + } + if path.starts_with("/pkg/") { + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + } + response +} + +pub fn resolve_listen_endpoint(host: &str, port: u16, allow_public_rpc: bool) -> Result { + let h = host.trim(); + if is_public_bind_host(h) && !allow_public_rpc { + return Err(format!( + "listen_host={h} requires allow_public_rpc=true in [server] (default is loopback-only)" + )); + } + Ok(h.to_string()) +} \ No newline at end of file diff --git a/src/sys/time.rs b/src/sys/time.rs index 1a3c285..7ab00e6 100644 --- a/src/sys/time.rs +++ b/src/sys/time.rs @@ -1,9 +1,20 @@ -use chrono::{DateTime, Local, TimeZone}; +use chrono::{DateTime, Local, TimeZone, Utc}; pub const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; pub fn curtimes() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as u64 + // std::time::SystemTime is unavailable on wasm32-unknown-unknown (browser WASM SDK). + #[cfg(target_arch = "wasm32")] + { + return Utc::now().timestamp() as u64; + } + #[cfg(not(target_arch = "wasm32"))] + { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as u64 + } } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index bdf4efa..85d19b6 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -36,13 +36,7 @@ - - - - - - - +include!("staking_hvm.rs"); /* diff --git a/src/vm/staking_hvm.rs b/src/vm/staking_hvm.rs new file mode 100644 index 0000000..7ffbb73 --- /dev/null +++ b/src/vm/staking_hvm.rs @@ -0,0 +1,38 @@ + +use crate::interface::chain::State; +use crate::core::field::Address; +use crate::mint::component::{STAKE_HACD_VMKIND, UNSTAKE_HACD_VMKIND}; +use crate::mint::operate::staking_exec_hvm_external; +use crate::sys::RetErr; + +/// Execute HIP-25 staking bytecode from ScriptExecute `codes`: +/// wire `[opcode][Uint1 count][count×6 literals]` (HVM StakeHacd / UnstakeHacd format). +pub fn exec_staking_script( + codes: &[u8], + staker: &Address, + height: u64, + chain_id: u64, + state: &mut dyn State, +) -> RetErr { + if codes.is_empty() { + return errf!("staking script empty"); + } + let opcode = codes[0]; + exec_staking_hvm_opcode(opcode, &codes[1..], staker, height, chain_id, state) +} + +/// Rust-side entry for HIP-25 HVM opcodes. Full HVM runtime (Go) calls the same Mint hooks. +pub fn exec_staking_hvm_opcode( + opcode: u8, + payload: &[u8], + staker: &Address, + height: u64, + chain_id: u64, + state: &mut dyn State, +) -> RetErr { + if opcode != STAKE_HACD_VMKIND && opcode != UNSTAKE_HACD_VMKIND { + return errf!("unsupported staking HVM opcode {}", opcode); + } + staking_exec_hvm_external(opcode, payload, staker, height, chain_id, state)?; + Ok(()) +} \ No newline at end of file diff --git a/src/x16rs/x16rs.rs b/src/x16rs/x16rs.rs index df53ef9..20c0e6c 100644 --- a/src/x16rs/x16rs.rs +++ b/src/x16rs/x16rs.rs @@ -1,23 +1,22 @@ - +#[cfg(not(target_arch = "wasm32"))] #[link(name = "x16rs", kind = "static")] extern "C" { fn c_x16rs_hash(a: i32, b: *const u8, c: *const u8) -> (); } - +#[cfg(not(target_arch = "wasm32"))] pub fn x16rs_hash(loopnum: i32, indata: &[u8; 32]) -> [u8; 32] { - - let outdata = [0u8; 32]; + let mut outdata = [0u8; 32]; unsafe { - // input hash let input: *const u8 = indata.as_ptr(); - - // output hash - let output: *const u8 = outdata.as_ptr(); - - // do call + let output: *mut u8 = outdata.as_mut_ptr(); c_x16rs_hash(loopnum, input, output); - // println!("{:?}", outdata); } - return outdata; + outdata +} + +/// WASM SDK stub — stake/unstake signing uses sha3/sha2 only, not x16rs PoW hash. +#[cfg(target_arch = "wasm32")] +pub fn x16rs_hash(_loopnum: i32, indata: &[u8; 32]) -> [u8; 32] { + *indata } diff --git a/wallet/hip25/index.html b/wallet/hip25/index.html new file mode 100644 index 0000000..c759145 --- /dev/null +++ b/wallet/hip25/index.html @@ -0,0 +1,303 @@ + + + + + + HIP-25 Staking · HIP-2 Mortgage Wallet + + + +
+

HIP-25 Staking · HIP-2 Mortgage

+ Wallet MVP +
+
+
+

Wallet connection

+
+
+ + Where your local fullnode listens. Usually the same as this wallet page URL. + +
+
+ + Fee paid on every tx, in HAC amount format. e.g. 0:247 + +
+
+ + Address used to load HAC/HACD. For testnet seed use 1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2. + +
+
+ + Testnet seed password: hip25test. Never sent to RPC — only derives the key that signs txs. + +
+
+ + Must match portfolio address and hold enough HAC for origination burn + tx fee. + +
+
+ + Testnet = 1 · Mainnet = 0. Synced automatically from the node. + +
+
+
+ + + +
+
+
WASM SDK: checking…
+
+ +
+

Account summary

+
+
—
HAC balance
+
—
Staked HACD
+
—
HACD in cooldown
+
—
Accrued rewards
+
—
Chain height
+
—
Staking pool (zhu)
+
—
Outstanding mortgage IOU (zhu)
+
—
Mortgage APR
+
+
+ +
+

HIP-2 HACD mortgage

+
+ Steps: + 1) Fill testnet seed + load portfolio · + 2) Select HACD with status Available · + 3) Loan auto-fills when you select HACD (1 HACD = 1:250, 2 HACD = 2:250) · + 4) Open mortgage (action 15) — needs HAC for 1% origination burn + tx fee +
+
+
+ + Unique 14-byte contract id = 28 hex characters. Click New lending ID for a random one. + +
+
+ + How many loan periods (1–20). On testnet each period = 10 blocks. + +
+
+ + Must equal bid-burn sum for selected HACD (updates automatically on selection). + + +
+
+ + HAC paid to reclaim mortgaged HACD. Minimum from Quote min ransom or an active contract. + +
+
+
+ + + + + +
+ +
Redemption phase and minimum ransom appear here after a quote.
+
+
+ +
+

Your HACD

+

+ Available = free to stake or mortgage · Staked = earning rewards · Cooldown = unstake waiting period · Mortgaged = locked in a loan +

+
+ + + + +
+
+
Load your portfolio to see HACD and status badges.
+
+
+ +
+

Activity log

+
+
+
+ + + + + + + + diff --git a/wallet/hip25/js/api.js b/wallet/hip25/js/api.js new file mode 100644 index 0000000..e24d5fe --- /dev/null +++ b/wallet/hip25/js/api.js @@ -0,0 +1,68 @@ +/* RPC client */ +(function (W) { + W.apiBase = () => { + let b = W.$("rpc").value.trim().replace(/\/$/, ""); + if (!b) b = W.sdkOrigin(); + return b; + }; + + W.explainSubmitError = (msg) => { + if (msg.includes("loan amount must be")) { + return `${msg} — re-select HACD or use Calculate loan to sync loan amount with bid-burn sum`; + } + if (msg.includes("do hac_sub error") && msg.includes("not enough")) { + const portfolio = W.$("address").value.trim() || "portfolio address"; + const signer = W.state.signAddress || "signing address"; + return `${msg} — ensure password matches ${portfolio} (signs as ${signer}) and HAC balance covers origination burn + tx fee`; + } + if (msg.includes("mortgage owner contract index full")) { + const max = W.state.mortgageGlobal?.owner_index_max ?? 64; + return `${msg} — maximum ${max} active mortgage contracts per address`; + } + return msg; + }; + + W.apiGet = async (path, params = {}) => { + const qs = new URLSearchParams(params).toString(); + const url = `${W.apiBase()}/${path}${qs ? "?" + qs : ""}`; + let r; + try { + r = await fetch(url); + } catch (e) { + throw new Error(`RPC unreachable at ${W.apiBase()} — run START_WALLET.bat and keep HIP25-FULLNODE open`); + } + const text = await r.text(); + let j; + try { + j = JSON.parse(text); + } catch { + throw new Error(text.trim() || `RPC error ${r.status}`); + } + if (j.ret !== 0) throw new Error(W.explainSubmitError(j.err || JSON.stringify(j))); + return j; + }; + + W.apiPost = async (path, body, params = {}) => { + const qs = new URLSearchParams(params).toString(); + const url = `${W.apiBase()}/${path}${qs ? "?" + qs : ""}`; + const bytes = body instanceof Uint8Array ? body : new TextEncoder().encode(body); + const r = await fetch(url, { method: "POST", body: bytes }); + const text = await r.text(); + let j; + try { + j = JSON.parse(text); + } catch { + throw new Error(text.trim() || `RPC error ${r.status}`); + } + if (j.ret !== 0) throw new Error(W.explainSubmitError(j.err || JSON.stringify(j))); + return j; + }; + + W.warnRpcOriginMismatch = () => { + const api = W.apiBase(); + const sdk = W.sdkOrigin(); + if (api !== sdk) { + W.log(`RPC URL (${api}) differs from wallet origin (${sdk}); WASM SDK always loads from wallet origin`, "err"); + } + }; +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/app.js b/wallet/hip25/js/app.js new file mode 100644 index 0000000..9a9403b --- /dev/null +++ b/wallet/hip25/js/app.js @@ -0,0 +1,97 @@ +/* Event bindings and bootstrap */ +(function (W) { + W.bootstrap = async () => { + await W.initWasmSdk(); + try { + const latest = await W.apiGet("query/latest"); + if (latest.chain_id !== undefined) W.$("chainId").value = String(latest.chain_id); + W.state.hip25Dev = !!latest.hip25_dev; + if (latest.hip25_dev) { + W.$("networkTag").textContent = "HIP-25 testnet"; + if (!W.$("address").value.trim()) { + W.$("address").value = W.TESTNET.address; + W.$("fee").value = W.TESTNET.fee; + W.$("chainId").value = "1"; + } + if (!W.$("lendId").value.trim()) W.$("lendId").value = W.randomLendIdHex(); + setTimeout(() => W.loadPortfolio().catch((e) => W.log(e.message, "err")), 800); + } else { + W.$("btnTestnet").style.display = "none"; + W.$("chainId").value = W.MAINNET_CHAIN_ID; + W.$("chainId").readOnly = true; + W.$("networkTag").textContent = `Mainnet chain ${W.MAINNET_CHAIN_ID}`; + } + } catch (e) { + W.log(e.message, "err"); + } + }; + + W.$("rpc").value = W.sdkOrigin() || "http://127.0.0.1:8083"; + W.$("rpc").addEventListener("change", W.warnRpcOriginMismatch); + W.$("rpc").addEventListener("blur", W.warnRpcOriginMismatch); + + W.$("btnTestnet").onclick = () => { + if (!W.state.hip25Dev) { + W.log("Testnet seed fill is disabled on mainnet nodes", "err"); + return; + } + W.$("address").value = W.TESTNET.address; + W.$("fee").value = W.TESTNET.fee; + W.$("chainId").value = "1"; + W.$("rpc").value = W.sdkOrigin(); + if (!W.$("prikey").value.trim()) W.$("prikey").value = W.TESTNET.password; + W.resolveSigningAddress().then(() => W.loadPortfolio()).catch((e) => W.log(e.message, "err")); + W.log("Filled testnet seed — loading portfolio…"); + }; + + W.$("prikey").addEventListener("input", () => W.resolveSigningAddress()); + W.$("address").addEventListener("input", W.updateAddressMismatchWarning); + + W.$("btnLoad").onclick = () => W.loadPortfolio().catch((e) => W.log(e.message, "err")); + W.$("btnRefresh").onclick = () => W.loadPortfolio().catch((e) => W.log(e.message, "err")); + W.$("btnStake").onclick = () => W.submitStakeAction(34).catch((e) => W.log(e.message, "err")); + W.$("btnUnstake").onclick = () => W.submitStakeAction(35).catch((e) => W.log(e.message, "err")); + + W.$("btnSelAll").onclick = () => { + W.state.diamonds.forEach((d) => { + if (W.diamondSelectable(d)) W.state.selected.add(d.literal); + }); + W.renderDiamonds(); + W.scheduleLoanRefresh(); + }; + W.$("btnSelNone").onclick = () => { + W.state.selected.clear(); + W.renderDiamonds(); + W.$("mortgageCostHint").textContent = ""; + }; + + W.$("btnNewLendId").onclick = () => { + W.$("lendId").value = W.randomLendIdHex(); + W.updateActionButtons(); + }; + + W.$("btnCalcLoan").onclick = () => + W.prepareMortgageOpen({ quiet: false }).catch((e) => W.log(e.message, "err")); + + W.$("btnMortgageQuote").onclick = async () => { + const id = W.$("lendId").value.trim(); + if (!id) return W.log("Lending ID required", "err"); + try { + const q = await W.apiGet("query/mortgage/contract", { + id, + redeemer: W.$("address").value.trim(), + height: W.state.height ? String(W.state.height) : "0", + }); + W.$("ransomAmount").value = q.min_ransom || ""; + W.$("mortgageMeta").textContent = `Redemption phase: ${q.redeem_phase || "—"} · min ransom: ${q.min_ransom || "—"}`; + W.log(`Quote: ${q.min_ransom} (${q.redeem_phase})`, "ok"); + } catch (e) { + W.log(e.message, "err"); + } + }; + + W.$("btnMortgageOpen").onclick = () => W.submitMortgage(15).catch((e) => W.log(e.message, "err")); + W.$("btnMortgageRedeem").onclick = () => W.submitMortgage(16).catch((e) => W.log(e.message, "err")); + + W.bootstrap(); +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/core.js b/wallet/hip25/js/core.js new file mode 100644 index 0000000..3ff22f6 --- /dev/null +++ b/wallet/hip25/js/core.js @@ -0,0 +1,71 @@ +/* HIP-25 wallet shared state and utilities */ +(function (W) { + W.MAINNET_CHAIN_ID = "0"; + W.TESTNET = { + address: "1Do17BuqMj5N4EZRuquXtoCCHFZpQoHyc2", + password: "hip25test", + fee: "0:247", + }; + + W.state = { + diamonds: [], + mortgageContracts: [], + mortgageGlobal: null, + height: 0, + selected: new Set(), + wasm: null, + hip25Dev: true, + signAddress: "", + }; + + W.$ = (id) => document.getElementById(id); + + W.txTimestamp = () => Math.floor(Date.now() / 1000); + + W.sdkOrigin = () => window.location.origin; + + W.log = (msg, cls = "") => { + const el = W.$("log"); + const line = document.createElement("div"); + if (cls) line.className = cls; + line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; + el.prepend(line); + }; + + W.parseAccountJson = (raw) => { + if (!raw || raw.startsWith("[ERROR]")) throw new Error(raw || "account error"); + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) throw new Error("invalid account JSON"); + return JSON.parse(trimmed); + }; + + W.parseSdkJson = (raw) => { + if (!raw || raw.startsWith("[ERROR]")) throw new Error(raw || "SDK error"); + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) throw new Error("SDK returned invalid JSON"); + return JSON.parse(trimmed); + }; + + W.hexToBytes = (hex) => { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; + }; + + W.splitDiamonds = (str) => { + if (!str) return []; + const w = 6; + const s = str.replace(/\s/g, ""); + const out = []; + for (let i = 0; i < s.length; i += w) out.push(s.slice(i, i + w)); + return out.filter((x) => x.length === w); + }; + + W.randomLendIdHex = () => { + const bytes = new Uint8Array(14); + bytes[0] = 0x4d; + crypto.getRandomValues(bytes.subarray(1, 13)); + bytes[13] = 0x7a; + return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); + }; +})(window.Hip25Wallet = window.Hip25Wallet || {}); \ No newline at end of file diff --git a/wallet/hip25/js/mortgage.js b/wallet/hip25/js/mortgage.js new file mode 100644 index 0000000..5442dc0 --- /dev/null +++ b/wallet/hip25/js/mortgage.js @@ -0,0 +1,142 @@ +/* HIP-2 mortgage: loan prep, quotes, submit */ +(function (W) { + let loanRefreshTimer = null; + + W.updateMortgageCostHint = (principal, selCount) => { + const hint = W.$("mortgageCostHint"); + if (!principal?.loan) { + hint.textContent = ""; + return; + } + const fee = W.$("fee").value.trim() || "0"; + const bps = principal.origination_fee_bps ?? W.state.mortgageGlobal?.origination_fee_bps ?? 100; + hint.textContent = `${principal.hacd_count || selCount} HACD → loan ${principal.loan} · origination burn (${bps / 100}%) ${principal.origination_burn || "—"} · tx fee ${fee}`; + }; + + W.prepareMortgageOpen = async (opts = {}) => { + const sel = W.selectedAvailableDiamonds(); + if (!sel.length) { + W.$("mortgageCostHint").textContent = ""; + if (!opts.quiet) throw new Error("Select Available HACD first"); + return null; + } + + if (opts.preflight) { + const signer = await W.resolveSigningAddress(); + if (!signer) throw new Error("Enter signing password"); + const portfolio = W.$("address").value.trim(); + if (portfolio && signer !== portfolio) { + throw new Error(`Password signs as ${signer} but portfolio is ${portfolio}. Addresses must match.`); + } + const max = W.state.mortgageGlobal?.owner_index_max ?? 64; + const active = W.state.mortgageContracts?.length ?? 0; + if (active >= max) { + throw new Error(`Mortgage index full (${active}/${max} active contracts for this address)`); + } + } + + const principal = await W.apiGet("query/mortgage/principal", { diamonds: sel.join(",") }); + W.$("loanAmount").value = principal.loan || ""; + W.updateMortgageCostHint(principal, sel.length); + + if (opts.preflight) { + const signer = W.state.signAddress; + const bal = await W.apiGet("query/balance", { address: signer }); + const hac = bal.list?.[0]?.hacash || "0"; + W.log(`Signer ${signer} balance ${hac} · need origination ${principal.origination_burn} + fee ${W.$("fee").value.trim()}`, "ok"); + } else if (!opts.quiet) { + W.log(`Loan updated: ${principal.loan} for ${sel.join(",")}`, "ok"); + } + + return { sel, principal }; + }; + + W.scheduleLoanRefresh = () => { + if (loanRefreshTimer) clearTimeout(loanRefreshTimer); + loanRefreshTimer = setTimeout(() => { + loanRefreshTimer = null; + W.prepareMortgageOpen({ quiet: true }).catch((e) => W.log(e.message, "err")); + }, 200); + }; + + W.renderMortgageContracts = () => { + const el = W.$("mortgageContracts"); + const list = W.state.mortgageContracts || []; + const max = W.state.mortgageGlobal?.owner_index_max ?? 64; + if (!list.length) { + el.textContent = `No active mortgage contracts for this address (limit ${max}).`; + return; + } + const header = list.length >= max ? `
Index full: ${list.length}/${max} active contracts
` : ""; + el.innerHTML = + header + + list + .map((c) => { + const id = c.lending_id || ""; + const dias = (c.diamonds || []).join(", "); + return `
+ ${id.slice(0, 12)}… · loan ${c.loan_principal || "—"} · phase ${c.redeem_phase || "—"} · ransom ${c.min_ransom || "—"}
+ HACD: ${dias || "—"} + +
`; + }) + .join(""); + el.querySelectorAll("button[data-lend]").forEach((btn) => { + btn.onclick = () => { + W.$("lendId").value = btn.getAttribute("data-lend") || ""; + W.$("ransomAmount").value = btn.getAttribute("data-ransom") || ""; + W.updateActionButtons(); + W.log(`Selected contract ${W.$("lendId").value.slice(0, 12)}… for redeem`); + }; + }); + }; + + W.loadMortgagePortfolio = async (addr) => { + try { + const p = await W.apiGet("query/mortgage/portfolio", { address: addr }); + W.state.mortgageContracts = p.contracts || []; + if (p.owner_index_max) { + W.state.mortgageGlobal = { ...(W.state.mortgageGlobal || {}), owner_index_max: p.owner_index_max }; + } + W.renderMortgageContracts(); + return W.state.mortgageContracts; + } catch (e) { + W.state.mortgageContracts = []; + W.$("mortgageContracts").textContent = `Mortgage portfolio: ${e.message}`; + return []; + } + }; + + W.submitMortgage = async (kind) => { + if (!W.state.wasm) throw new Error("WASM SDK required"); + const pass = W.$("prikey").value.trim(); + if (!pass) throw new Error("Password required"); + const fee = W.$("fee").value.trim(); + const chainId = BigInt(W.$("chainId").value.trim() || W.MAINNET_CHAIN_ID); + const lendId = W.$("lendId").value.trim(); + const ts = BigInt(W.txTimestamp()); + let raw; + if (kind === 15) { + const prepared = await W.prepareMortgageOpen({ preflight: true }); + if (!prepared) throw new Error("Mortgage preparation failed"); + const sel = prepared.sel; + const loan = W.$("loanAmount").value.trim(); + const bp = parseInt(W.$("borrowPeriod").value, 10); + if (!lendId || !loan) throw new Error("Lending ID and loan amount required"); + if (loan !== prepared.principal.loan) { + throw new Error(`Loan must be ${prepared.principal.loan} for selected HACD`); + } + raw = W.state.wasm.hacd_mortgage_open(chainId, pass, lendId, sel.join(","), loan, bp, fee, ts); + } else { + const ransom = W.$("ransomAmount").value.trim(); + if (!lendId || !ransom) throw new Error("Lending ID and ransom required"); + raw = W.state.wasm.hacd_mortgage_redeem(chainId, pass, lendId, ransom, fee, ts); + } + const tx = W.parseSdkJson(raw); + W.log(`Signed ${tx.action} ${tx.tx_hash}`); + const submitted = await W.apiPost("submit/transaction", W.hexToBytes(tx.tx_body)); + W.$("prikey").value = ""; + W.log(`Submitted ${submitted.hash}`, "ok"); + setTimeout(W.loadPortfolio, 3000); + }; +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/portfolio.js b/wallet/hip25/js/portfolio.js new file mode 100644 index 0000000..ff7e11a --- /dev/null +++ b/wallet/hip25/js/portfolio.js @@ -0,0 +1,233 @@ +/* Portfolio load and HACD list */ +(function (W) { + let loadRetryTimer = null; + + W.schedulePortfolioRetry = () => { + if (loadRetryTimer) return; + loadRetryTimer = setTimeout(() => { + loadRetryTimer = null; + W.loadPortfolio({ retry: true }).catch((e) => W.log(e.message, "err")); + }, 2000); + }; + + W.clearPortfolioRetry = () => { + if (!loadRetryTimer) return; + clearTimeout(loadRetryTimer); + loadRetryTimer = null; + }; + + W.selectedAvailableDiamonds = () => { + const byName = Object.fromEntries(W.state.diamonds.map((d) => [d.literal, d])); + return [...W.state.selected].filter((n) => byName[n]?.status === "Available"); + }; + + W.diamondSelectable = (d) => { + if (d.status === "Available") return true; + if (d.status === "Staked" && W.state.height >= (d.min_unstake_height || 0)) return true; + if (d.status === "Mortgaged") return true; + return false; + }; + + W.restoreSelection = (prevSelected) => { + W.state.selected.clear(); + if (!prevSelected.size) return; + const byName = Object.fromEntries(W.state.diamonds.map((d) => [d.literal, d])); + for (const lit of prevSelected) { + const d = byName[lit]; + if (d && W.diamondSelectable(d)) W.state.selected.add(lit); + } + }; + + W.updateActionButtons = () => { + const sel = [...W.state.selected]; + const byName = Object.fromEntries(W.state.diamonds.map((d) => [d.literal, d])); + const canStake = sel.some((n) => byName[n]?.status === "Available"); + const canUnstake = sel.some( + (n) => byName[n]?.status === "Staked" && W.state.height >= (byName[n].min_unstake_height || 0) + ); + const canMortgageOpen = sel.some((n) => byName[n]?.status === "Available"); + const wasmOk = !!W.state.wasm; + W.$("btnStake").disabled = !wasmOk || !canStake; + W.$("btnUnstake").disabled = !wasmOk || !canUnstake; + W.$("btnMortgageOpen").disabled = !wasmOk || !canMortgageOpen; + W.$("btnMortgageRedeem").disabled = !wasmOk || !W.$("lendId").value.trim(); + }; + + W.renderDiamonds = () => { + const box = W.$("diamonds"); + box.innerHTML = ""; + if (!W.state.diamonds.length) { + box.innerHTML = '
No HACD found for this address.
'; + return; + } + for (const d of W.state.diamonds) { + const row = document.createElement("div"); + row.className = "diamond-row" + (W.state.selected.has(d.literal) ? " selected" : ""); + const canStake = d.status === "Available"; + const canUnstake = d.status === "Staked" && W.state.height >= d.min_unstake_height; + const isMortgaged = d.status === "Mortgaged"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = W.state.selected.has(d.literal); + cb.disabled = !W.diamondSelectable(d); + cb.onchange = () => { + if (cb.checked) { + W.state.selected.add(d.literal); + if (isMortgaged && d.lending_id) { + W.$("lendId").value = d.lending_id; + W.$("btnMortgageQuote").click(); + } + } else W.state.selected.delete(d.literal); + W.updateActionButtons(); + row.classList.toggle("selected", cb.checked); + W.scheduleLoanRefresh(); + }; + const info = document.createElement("div"); + const lit = document.createElement("div"); + lit.className = "literal"; + lit.textContent = d.literal; + const meta = document.createElement("div"); + meta.className = "meta"; + let metaTxt = `reward ${d.accrued_reward || "0"} · min unstake block ${d.min_unstake_height ?? "—"}`; + if (d.lending_id) metaTxt += ` · mortgage ${d.lending_id.slice(0, 8)}…`; + meta.textContent = metaTxt; + info.append(lit, meta); + const badge = document.createElement("span"); + badge.className = `badge badge-${d.status}`; + badge.textContent = d.status; + row.append(cb, info, badge, document.createElement("span")); + box.appendChild(row); + } + W.updateActionButtons(); + }; + + W.loadPortfolio = async (opts = {}) => { + const addr = W.$("address").value.trim(); + if (!addr) throw new Error("Address required"); + const prevSelected = new Set(W.state.selected); + if (!opts.retry) W.log("Loading portfolio…"); + + const latest = await W.apiGet("query/latest"); + W.state.height = latest.height; + W.$("sHeight").textContent = latest.height; + W.state.hip25Dev = !!latest.hip25_dev; + if (latest.chain_id !== undefined) { + const nodeChain = String(latest.chain_id); + const cur = W.$("chainId").value.trim(); + if (!cur || cur !== nodeChain) { + W.$("chainId").value = nodeChain; + if (cur && cur !== nodeChain) W.log(`Chain ID synced to node (${nodeChain})`, "err"); + } + W.$("chainId").readOnly = !latest.hip25_dev; + W.$("networkTag").textContent = latest.hip25_dev ? "HIP-25 testnet" : `Mainnet chain ${W.MAINNET_CHAIN_ID}`; + } + + const global = await W.apiGet("query/staking/global"); + W.$("sPool").textContent = global.reward_pool_pending_zhu ?? "—"; + try { + const mg = await W.apiGet("query/mortgage/global"); + W.state.mortgageGlobal = mg; + W.$("sMortgageIoU").textContent = mg.outstanding_ioo_zhu ?? "—"; + W.$("sMortgageApr").textContent = mg.apr_bps ? `${(mg.apr_bps / 100).toFixed(1)}%` : "—"; + const max = mg.owner_index_max ?? 64; + W.$("mortgageIndexHint").textContent = `Up to ${max} active mortgage contracts per address (indexed in portfolio RPC).`; + } catch (_) { + W.state.mortgageGlobal = null; + W.$("sMortgageIoU").textContent = "—"; + W.$("sMortgageApr").textContent = "—"; + } + + const bal = await W.apiGet("query/balance", { address: addr, diamonds: "true" }); + const entry = bal.list?.[0] || {}; + W.$("sHac").textContent = entry.hacash || "0"; + + const summary = await W.apiGet("query/staking/summary", { address: addr }); + W.$("sStaked").textContent = summary.staked_count ?? 0; + W.$("sCooldown").textContent = summary.cooldown_count ?? 0; + W.$("sAccrued").textContent = summary.total_accrued_reward || "0"; + + const contracts = await W.loadMortgagePortfolio(addr); + const mortgagedMap = {}; + for (const c of contracts) { + for (const d of c.diamonds || []) mortgagedMap[d] = c.lending_id; + } + const names = [...new Set([...W.splitDiamonds(entry.diamonds || ""), ...Object.keys(mortgagedMap)])]; + if (!names.length && W.state.height < 1) { + W.log("Waiting for block 1 (testnet seed loads with first block)…"); + W.schedulePortfolioRetry(); + return; + } + W.clearPortfolioRetry(); + if (!names.length) { + W.state.diamonds = []; + W.state.selected.clear(); + W.renderDiamonds(); + W.log(`No HACD on ${addr} — click "Fill HIP-25 testnet seed" then Load portfolio`, "err"); + return; + } + + W.state.diamonds = names.map((literal) => ({ + literal, + status: "Loading", + accrued_reward: "0", + min_unstake_height: 0, + lending_id: mortgagedMap[literal] || "", + })); + W.renderDiamonds(); + await Promise.all( + names.map(async (literal, idx) => { + try { + const st = await W.apiGet("query/staking/status", { diamond: literal }); + W.state.diamonds[idx] = { + literal: st.literal || literal, + status: st.status || "unknown", + accrued_reward: st.accrued_reward || "0", + min_unstake_height: st.min_unstake_height || 0, + lending_id: mortgagedMap[literal] || "", + }; + } catch (e) { + W.state.diamonds[idx] = { + literal, + status: mortgagedMap[literal] ? "Mortgaged" : "unknown", + accrued_reward: "0", + min_unstake_height: 0, + lending_id: mortgagedMap[literal] || "", + }; + } + }) + ); + W.state.diamonds.sort((a, b) => a.literal.localeCompare(b.literal)); + W.restoreSelection(prevSelected); + W.renderDiamonds(); + W.log(`Loaded ${names.length} HACD`, "ok"); + if (W.selectedAvailableDiamonds().length) { + W.prepareMortgageOpen({ quiet: true }).catch((e) => W.log(e.message, "err")); + } + }; + + W.submitStakeAction = async (kind) => { + if (!W.state.wasm) throw new Error("WASM SDK required — build with scripts/build_wallet_sdk.ps1"); + const pass = W.$("prikey").value.trim(); + if (!pass) throw new Error("Password required for local signing"); + const fee = W.$("fee").value.trim(); + const chainIdStr = W.$("chainId").value.trim(); + if (!W.state.hip25Dev && chainIdStr !== W.MAINNET_CHAIN_ID) { + throw new Error(`Mainnet wallet requires chain ID ${W.MAINNET_CHAIN_ID}`); + } + const chainId = BigInt(chainIdStr || W.MAINNET_CHAIN_ID); + const sel = [...W.state.selected]; + if (!sel.length) throw new Error("Select at least one HACD"); + const diamonds = sel.join(","); + const stake = kind === 34; + const fn = stake ? W.state.wasm.hacd_stake : W.state.wasm.hacd_unstake; + const ts = BigInt(W.txTimestamp()); + W.log(`[WASM] ${stake ? "hacd_stake" : "hacd_unstake"} for ${diamonds} (ts=${ts})…`); + const raw = fn(chainId, pass, diamonds, fee, ts); + const tx = W.parseSdkJson(raw); + W.log(`Signed locally ${tx.tx_hash}`); + const submitted = await W.apiPost("submit/transaction", W.hexToBytes(tx.tx_body)); + W.$("prikey").value = ""; + W.log(`Submitted ${submitted.hash}`, "ok"); + setTimeout(W.loadPortfolio, 3000); + }; +})(window.Hip25Wallet); \ No newline at end of file diff --git a/wallet/hip25/js/signing.js b/wallet/hip25/js/signing.js new file mode 100644 index 0000000..b8f8def --- /dev/null +++ b/wallet/hip25/js/signing.js @@ -0,0 +1,75 @@ +/* Local WASM signing address resolution */ +(function (W) { + W.resolveSigningAddress = async () => { + const pass = W.$("prikey").value.trim(); + const el = W.$("signAddress"); + const warn = W.$("addressWarn"); + if (!pass) { + W.state.signAddress = ""; + el.value = ""; + warn.textContent = ""; + return ""; + } + if (!W.state.wasm?.create_account_by) { + el.value = "(rebuild WASM: scripts/build_wallet_sdk.ps1)"; + return ""; + } + try { + const acc = W.parseAccountJson(W.state.wasm.create_account_by(pass)); + W.state.signAddress = acc.address || ""; + el.value = W.state.signAddress; + W.updateAddressMismatchWarning(); + return W.state.signAddress; + } catch (e) { + W.state.signAddress = ""; + el.value = ""; + warn.textContent = e.message; + warn.className = "meta err"; + return ""; + } + }; + + W.updateAddressMismatchWarning = () => { + const warn = W.$("addressWarn"); + const portfolio = W.$("address").value.trim(); + const signer = W.state.signAddress; + if (!portfolio || !signer) { + warn.textContent = ""; + return; + } + if (portfolio !== signer) { + warn.textContent = `Mismatch: portfolio is ${portfolio} but password signs as ${signer}. Transactions will fail until they match.`; + warn.className = "meta err"; + return; + } + warn.textContent = "Portfolio address matches signing address."; + warn.className = "meta ok"; + }; + + W.loadSdkScript = () => + new Promise((resolve, reject) => { + if (typeof wasm_bindgen === "function") return resolve(); + const s = document.createElement("script"); + s.src = `${W.sdkOrigin()}/pkg/hacash_sdk.js`; + s.onload = () => resolve(); + s.onerror = () => reject(new Error("failed to load /pkg/hacash_sdk.js")); + document.head.appendChild(s); + }); + + W.initWasmSdk = async () => { + const el = W.$("sdkStatus"); + try { + await W.loadSdkScript(); + const wasmUrl = `${W.sdkOrigin()}/pkg/hacash_sdk_bg.wasm`; + await wasm_bindgen(wasmUrl); + W.state.wasm = wasm_bindgen; + el.textContent = "WASM SDK: ready — stake / unstake / mortgage (password stays in browser)"; + el.className = "meta ok"; + W.updateActionButtons(); + await W.resolveSigningAddress(); + } catch (e) { + el.textContent = `WASM SDK: ${e.message}`; + el.className = "meta err"; + } + }; +})(window.Hip25Wallet); \ No newline at end of file