diff --git a/Basalt.sln b/Basalt.sln index 0c88fb1..9f54df7 100644 --- a/Basalt.sln +++ b/Basalt.sln @@ -121,6 +121,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B3 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Basalt.Example.Wallet", "examples\Basalt.Example.Wallet\Basalt.Example.Wallet.csproj", "{D02C2C1A-95BD-4F1C-B018-C7E98C7BD834}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Basalt.Example.Contracts", "examples\Basalt.Example.Contracts\Basalt.Example.Contracts.csproj", "{3BE52F08-293B-47A2-A309-8ABC1DD098E1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -623,6 +625,18 @@ Global {D02C2C1A-95BD-4F1C-B018-C7E98C7BD834}.Release|x64.Build.0 = Release|Any CPU {D02C2C1A-95BD-4F1C-B018-C7E98C7BD834}.Release|x86.ActiveCfg = Release|Any CPU {D02C2C1A-95BD-4F1C-B018-C7E98C7BD834}.Release|x86.Build.0 = Release|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Debug|x64.Build.0 = Debug|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Debug|x86.Build.0 = Debug|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Release|Any CPU.Build.0 = Release|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Release|x64.ActiveCfg = Release|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Release|x64.Build.0 = Release|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Release|x86.ActiveCfg = Release|Any CPU + {3BE52F08-293B-47A2-A309-8ABC1DD098E1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -682,5 +696,6 @@ Global {495AC7A3-0727-484A-9E79-C41689D66C8F} = {51B637F8-9C04-5803-F48E-8CB9352021B0} {6B4DC31B-3BE6-4C85-A32E-6F1DBC0E4843} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {D02C2C1A-95BD-4F1C-B018-C7E98C7BD834} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {3BE52F08-293B-47A2-A309-8ABC1DD098E1} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index a8995a0..772cc7c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A high-performance Layer 1 blockchain built on .NET 9 with Native AOT compilatio - **Merkle Patricia Trie** -- Cryptographically verifiable state with RocksDB persistence - **Smart Contracts** -- C# contracts with gas metering, sandboxed execution, and Roslyn analyzers - **Token Standards** -- BST-20 (ERC-20), BST-721 (ERC-721), BST-1155 (ERC-1155), BST-3525 (ERC-3525 SFT), BST-4626 (ERC-4626 Vault), BST-VC (W3C Verifiable Credentials), BST-DID +- **Policy Hooks** -- Modular transfer policy enforcement on all token standards: sanctions, holding limits, lockup periods, jurisdiction whitelist/blacklist. Deploy policies as independent contracts, register on any token. - **EIP-1559 Gas Pricing** -- Dynamic base fee with elastic block gas, tip/burn fee split, MaxFeePerGas/MaxPriorityFeePerGas - **On-Chain Governance** -- Stake-weighted quadratic voting, single-hop delegation, timelock, executable proposals - **Block Explorer** -- Blazor WASM explorer with responsive design, dark/light theme, live WebSocket updates, Faucet, and Network Stats @@ -60,7 +61,7 @@ dotnet build dotnet test ``` -2,789 tests across 16 test projects covering core types, cryptography, codec serialization, storage, networking, consensus, execution (including DEX), API, compliance, bridge, confidentiality, node configuration, SDK contracts, analyzers, wallet, and end-to-end integration. +2,891 tests across 16 test projects covering core types, cryptography, codec serialization, storage, networking, consensus, execution (including DEX), API, compliance, bridge, confidentiality, node configuration, SDK contracts, analyzers, wallet, and end-to-end integration. ### Run a Local Node @@ -115,7 +116,7 @@ dotnet run --project tools/Basalt.Cli -- init MyToken ## Project Structure ``` -Basalt.sln (42 C# projects) +Basalt.sln (43 C# projects) +-- src/ | +-- core/ | | +-- Basalt.Core/ # Hash256, Address, UInt256, chain parameters @@ -152,7 +153,7 @@ Basalt.sln (42 C# projects) | | +-- Basalt.Explorer/ # Blazor WASM block explorer (responsive, dark/light theme, WebSocket live updates) | +-- node/ | +-- Basalt.Node/ # Composition root, single binary -+-- tests/ # 16 test projects, 2,781 tests ++-- tests/ # 16 test projects, 2,891 tests | +-- Basalt.Core.Tests/ | +-- Basalt.Crypto.Tests/ | +-- Basalt.Codec.Tests/ @@ -175,7 +176,10 @@ Basalt.sln (42 C# projects) | +-- Basalt.Cli/ # CLI tool (account, tx, block, faucet, contract init/compile/test) | +-- Basalt.DevNet/ # Docker devnet genesis config + validator setup script | +-- TestVectorGen/ # Codec test vector generator ++-- examples/ +| +-- Basalt.Example.Contracts/ # Example contracts (ComplianceToken, PolicyVault) +-- contracts/ # Solidity bridge contracts (BasaltBridge, WBST) ++-- smart-contracts/ # 78 contract specifications with implementation status +-- docs/ # Design plan, technical specification ``` diff --git a/examples/Basalt.Example.Contracts/Basalt.Example.Contracts.csproj b/examples/Basalt.Example.Contracts/Basalt.Example.Contracts.csproj new file mode 100644 index 0000000..5dae957 --- /dev/null +++ b/examples/Basalt.Example.Contracts/Basalt.Example.Contracts.csproj @@ -0,0 +1,15 @@ + + + Library + Basalt.Example.Contracts + false + + false + false + false + + + + + + diff --git a/examples/Basalt.Example.Contracts/ComplianceTokenExample.cs b/examples/Basalt.Example.Contracts/ComplianceTokenExample.cs new file mode 100644 index 0000000..196e98b --- /dev/null +++ b/examples/Basalt.Example.Contracts/ComplianceTokenExample.cs @@ -0,0 +1,127 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Contracts.Standards; +using Basalt.Sdk.Testing; + +namespace Basalt.Example.Contracts; + +/// +/// Example: A regulated security token using BST-20 with policy hooks. +/// +/// This shows how to: +/// 1. Deploy a BST-20 token +/// 2. Deploy compliance policies (sanctions, lockup, jurisdiction) +/// 3. Register policies on the token +/// 4. All transfers are automatically enforced +/// +/// Run this as a standalone program to see the compliance workflow. +/// +public static class ComplianceTokenExample +{ + public static void Run() + { + using var host = new BasaltTestHost(); + + // --- Addresses --- + var issuer = BasaltTestHost.CreateAddress(1); + var alice = BasaltTestHost.CreateAddress(2); + var bob = BasaltTestHost.CreateAddress(3); + var charlie = BasaltTestHost.CreateAddress(4); // Will be sanctioned + var tokenAddr = BasaltTestHost.CreateAddress(0xA0); + var sanctionsAddr = BasaltTestHost.CreateAddress(0xA1); + var lockupAddr = BasaltTestHost.CreateAddress(0xA2); + var jurisdictionAddr = BasaltTestHost.CreateAddress(0xA3); + + // === Step 1: Deploy the security token === + host.SetCaller(issuer); + Context.Self = tokenAddr; + var token = new BST20Token("RegulatedToken", "RSEC", 18, new UInt256(1_000_000)); + host.Deploy(tokenAddr, token); + + // === Step 2: Deploy compliance policies === + + // Sanctions: blocks transfers involving sanctioned addresses + Context.Self = sanctionsAddr; + var sanctions = new SanctionsPolicy(); + host.Deploy(sanctionsAddr, sanctions); + + // Lockup: time-based transfer restrictions for insiders + Context.Self = lockupAddr; + var lockup = new LockupPolicy(); + host.Deploy(lockupAddr, lockup); + + // Jurisdiction: country-based whitelist/blacklist + Context.Self = jurisdictionAddr; + var jurisdiction = new JurisdictionPolicy(); + host.Deploy(jurisdictionAddr, jurisdiction); + + Context.IsDeploying = false; + + // === Step 3: Configure policies === + + // Sanction Charlie + host.SetCaller(issuer); + Context.Self = sanctionsAddr; + sanctions.AddSanction(charlie); + + // Set 6-month lockup on Alice (insider) + host.SetBlockTimestamp(1_000_000); + Context.Self = lockupAddr; + lockup.SetLockup(tokenAddr, alice, 16_000_000); // ~6 months + + // Whitelist US + EU jurisdictions + Context.Self = jurisdictionAddr; + jurisdiction.SetMode(tokenAddr, true); // whitelist mode + jurisdiction.SetJurisdiction(tokenAddr, 840, true); // US + jurisdiction.SetJurisdiction(tokenAddr, 276, true); // Germany + jurisdiction.SetAddressJurisdiction(alice, 840); + jurisdiction.SetAddressJurisdiction(bob, 276); + + // === Step 4: Register all policies on the token === + host.SetCaller(issuer); + Context.Self = tokenAddr; + token.AddPolicy(sanctionsAddr); + token.AddPolicy(lockupAddr); + token.AddPolicy(jurisdictionAddr); + + // Token now has 3 active policies + Console.WriteLine($"Policies registered: {token.PolicyCount()}"); + + // === Step 5: Distribute tokens === + token.Transfer(alice, new UInt256(50_000)); + token.Transfer(bob, new UInt256(50_000)); + Console.WriteLine($"Alice balance: {token.BalanceOf(alice)}"); + Console.WriteLine($"Bob balance: {token.BalanceOf(bob)}"); + + // === Step 6: Demonstrate enforcement === + + // Bob -> Alice: works (both in whitelisted jurisdictions, no lockup on Bob) + host.SetCaller(bob); + Context.Self = tokenAddr; + token.Transfer(alice, new UInt256(1_000)); + Console.WriteLine("Bob -> Alice: OK"); + + // Alice -> Bob: BLOCKED (Alice is locked up) + host.SetCaller(alice); + Context.Self = tokenAddr; + try { token.Transfer(bob, new UInt256(100)); } + catch (ContractRevertException e) { Console.WriteLine($"Alice -> Bob: DENIED ({e.Message})"); } + + // Bob -> Charlie: BLOCKED (Charlie is sanctioned) + host.SetCaller(bob); + Context.Self = tokenAddr; + try { token.Transfer(charlie, new UInt256(100)); } + catch (ContractRevertException e) { Console.WriteLine($"Bob -> Charlie: DENIED ({e.Message})"); } + + // === Step 7: Time passes, lockup expires === + host.SetBlockTimestamp(16_000_001); + host.SetCaller(alice); + Context.Self = tokenAddr; + token.Transfer(bob, new UInt256(5_000)); + Console.WriteLine("Alice -> Bob after lockup expiry: OK"); + + Console.WriteLine($"Final Alice balance: {token.BalanceOf(alice)}"); + Console.WriteLine($"Final Bob balance: {token.BalanceOf(bob)}"); + } +} diff --git a/examples/Basalt.Example.Contracts/PolicyVaultExample.cs b/examples/Basalt.Example.Contracts/PolicyVaultExample.cs new file mode 100644 index 0000000..69b6fc5 --- /dev/null +++ b/examples/Basalt.Example.Contracts/PolicyVaultExample.cs @@ -0,0 +1,130 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Contracts.Standards; +using Basalt.Sdk.Testing; + +namespace Basalt.Example.Contracts; + +/// +/// Example: A BST-4626 tokenized vault where both the underlying asset +/// and the vault shares have independent policy enforcement. +/// +/// Scenario: +/// - An underlying BST-20 "stablecoin" with a sanctions policy +/// - A BST-4626 vault that wraps the stablecoin +/// - The vault shares have a holding limit policy (max 100K shares per address) +/// - Sanctioned users can't deposit (asset transfer blocked) +/// - Large holders can't accumulate unlimited shares +/// +public static class PolicyVaultExample +{ + public static void Run() + { + using var host = new BasaltTestHost(); + + var admin = BasaltTestHost.CreateAddress(1); + var alice = BasaltTestHost.CreateAddress(2); + var bob = BasaltTestHost.CreateAddress(3); + var assetAddr = BasaltTestHost.CreateAddress(0xA0); + var vaultAddr = BasaltTestHost.CreateAddress(0xA1); + var sanctionsAddr = BasaltTestHost.CreateAddress(0xA2); + var holdingLimitAddr = BasaltTestHost.CreateAddress(0xA3); + + host.SetCaller(admin); + + // === Deploy underlying asset (stablecoin) === + Context.Self = assetAddr; + var asset = new BST20Token("USD Coin", "USDC", 6, new UInt256(10_000_000)); + host.Deploy(assetAddr, asset); + + // === Deploy vault === + Context.Self = vaultAddr; + var vault = new BST4626Vault("Vault USDC", "vUSDC", 6, assetAddr); + host.Deploy(vaultAddr, vault); + + // === Deploy policies === + Context.Self = sanctionsAddr; + var sanctions = new SanctionsPolicy(); + host.Deploy(sanctionsAddr, sanctions); + + Context.Self = holdingLimitAddr; + var holdingLimit = new HoldingLimitPolicy(); + host.Deploy(holdingLimitAddr, holdingLimit); + + Context.IsDeploying = false; + + // === Configure === + + // Sanctions on the underlying asset + host.SetCaller(admin); + Context.Self = assetAddr; + asset.AddPolicy(sanctionsAddr); + + // Holding limit on vault shares (max 100K shares per address) + Context.Self = holdingLimitAddr; + holdingLimit.SetDefaultLimit(vaultAddr, new UInt256(100_000)); + + Context.Self = vaultAddr; + vault.AddPolicy(holdingLimitAddr); + + // === Distribute stablecoins === + host.SetCaller(admin); + Context.Self = assetAddr; + asset.Transfer(alice, new UInt256(500_000)); + asset.Transfer(bob, new UInt256(500_000)); + Console.WriteLine($"Alice USDC: {asset.BalanceOf(alice)}"); + + // === Alice deposits into vault === + // First approve vault to pull tokens + host.SetCaller(alice); + Context.Self = assetAddr; + asset.Approve(vaultAddr, new UInt256(200_000)); + + // Deposit + Context.Self = vaultAddr; + var shares = vault.Deposit(new UInt256(50_000)); + Console.WriteLine($"Alice deposited 50K USDC, got {shares} shares"); + Console.WriteLine($"Alice vault shares: {vault.BalanceOf(alice)}"); + Console.WriteLine($"Vault total assets: {vault.TotalAssets()}"); + + // === Bob deposits too === + host.SetCaller(bob); + Context.Self = assetAddr; + asset.Approve(vaultAddr, new UInt256(200_000)); + + Context.Self = vaultAddr; + shares = vault.Deposit(new UInt256(80_000)); + Console.WriteLine($"Bob deposited 80K USDC, got {shares} shares"); + + // === Alice tries to transfer shares to Bob (holding limit check) === + // Bob has ~80K shares, limit is 100K — small transfer works + host.SetCaller(alice); + Context.Self = vaultAddr; + vault.Transfer(bob, new UInt256(10_000)); + Console.WriteLine("Alice -> Bob 10K shares: OK"); + + // Large transfer would exceed Bob's 100K limit + try { vault.Transfer(bob, new UInt256(50_000)); } + catch (ContractRevertException e) + { + Console.WriteLine($"Alice -> Bob 50K shares: DENIED ({e.Message})"); + } + + // === Sanction Bob — he can't withdraw (asset transfer blocked) === + host.SetCaller(admin); + Context.Self = sanctionsAddr; + sanctions.AddSanction(bob); + + // Bob tries to redeem shares for USDC — asset transfer to sanctioned Bob blocked + host.SetCaller(bob); + Context.Self = vaultAddr; + try { vault.Redeem(new UInt256(1_000)); } + catch (ContractRevertException e) + { + Console.WriteLine($"Bob redeem: DENIED ({e.Message})"); + } + + Console.WriteLine($"Final vault total assets: {vault.TotalAssets()}"); + } +} diff --git a/smart-contracts/01-amm-dex.md b/smart-contracts/01-amm-dex.md index b133257..51109a0 100644 --- a/smart-contracts/01-amm-dex.md +++ b/smart-contracts/01-amm-dex.md @@ -1,3 +1,5 @@ +> **STATUS: DONE** — Superseded by **Caldera Fusion DEX** (Phases A-E): batch auction, order book, TWAP oracle, dynamic fees, concentrated liquidity, encrypted intents, solver network. + # Automated Market Maker (AMM DEX) ## Category diff --git a/smart-contracts/02-order-book-dex.md b/smart-contracts/02-order-book-dex.md index 1fa2b42..8e555ac 100644 --- a/smart-contracts/02-order-book-dex.md +++ b/smart-contracts/02-order-book-dex.md @@ -1,3 +1,5 @@ +> **STATUS: DONE** — Included in **Caldera Fusion DEX** order book engine. + # Order Book DEX ## Category diff --git a/smart-contracts/03-lending-protocol.md b/smart-contracts/03-lending-protocol.md index 48425f1..cac096d 100644 --- a/smart-contracts/03-lending-protocol.md +++ b/smart-contracts/03-lending-protocol.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (BST-4626 Vault). Needs: collateral baskets, liquidation engine, variable rates, oracle price feeds. + # Lending/Borrowing Protocol ## Category diff --git a/smart-contracts/04-stablecoin-cdp.md b/smart-contracts/04-stablecoin-cdp.md index 5792dc2..1e3b98d 100644 --- a/smart-contracts/04-stablecoin-cdp.md +++ b/smart-contracts/04-stablecoin-cdp.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires decentralized oracle (41) for collateral price feeds and peg stability. + # Collateralized Stablecoin (CDP) ## Category diff --git a/smart-contracts/05-yield-aggregator.md b/smart-contracts/05-yield-aggregator.md index 8edbfac..9f37f33 100644 --- a/smart-contracts/05-yield-aggregator.md +++ b/smart-contracts/05-yield-aggregator.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (BST-4626 Vault). Needs: multi-strategy orchestration, auto-harvest, reward swapping. + # Yield Aggregator ## Category diff --git a/smart-contracts/06-liquid-staking.md b/smart-contracts/06-liquid-staking.md index c9d4b40..ea23d26 100644 --- a/smart-contracts/06-liquid-staking.md +++ b/smart-contracts/06-liquid-staking.md @@ -1,3 +1,5 @@ +> **STATUS: DONE** — Implemented as **StakingPool** system contract (0x0105) at `0x...1005`. + # Liquid Staking Token (stBSLT) ## Category diff --git a/smart-contracts/07-flash-loans.md b/smart-contracts/07-flash-loans.md index 8831ae9..1edcf74 100644 --- a/smart-contracts/07-flash-loans.md +++ b/smart-contracts/07-flash-loans.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (Escrow 0x0103). Needs: atomic single-tx borrow/repay mechanics, fee model. + # Flash Loan Pool ## Category diff --git a/smart-contracts/08-streaming-payments.md b/smart-contracts/08-streaming-payments.md index cf73c7a..f07c47e 100644 --- a/smart-contracts/08-streaming-payments.md +++ b/smart-contracts/08-streaming-payments.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (Escrow 0x0103). Needs: linear streaming curves, cancellation, top-up. + # Streaming Payments (Sablier-style) ## Category diff --git a/smart-contracts/09-perpetual-futures.md b/smart-contracts/09-perpetual-futures.md index 18c3c90..d384522 100644 --- a/smart-contracts/09-perpetual-futures.md +++ b/smart-contracts/09-perpetual-futures.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires decentralized oracle (41) for mark price and funding rate calculation. + # Perpetual Futures Exchange ## Category diff --git a/smart-contracts/10-options-protocol.md b/smart-contracts/10-options-protocol.md index b4e71db..443237e 100644 --- a/smart-contracts/10-options-protocol.md +++ b/smart-contracts/10-options-protocol.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires decentralized oracle (41) for underlying asset price at expiry. + # Options Protocol ## Category diff --git a/smart-contracts/11-insurance-pool.md b/smart-contracts/11-insurance-pool.md index 4272ac8..d5dbc9e 100644 --- a/smart-contracts/11-insurance-pool.md +++ b/smart-contracts/11-insurance-pool.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires decentralized oracle (41) for parametric trigger conditions. + # Insurance Protocol ## Category diff --git a/smart-contracts/12-prediction-market.md b/smart-contracts/12-prediction-market.md index 6dd6e8b..815f6a2 100644 --- a/smart-contracts/12-prediction-market.md +++ b/smart-contracts/12-prediction-market.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires decentralized oracle (41) for outcome resolution. + # Prediction Market ## Category diff --git a/smart-contracts/14-revenue-sharing.md b/smart-contracts/14-revenue-sharing.md index 5782383..6e5b2d1 100644 --- a/smart-contracts/14-revenue-sharing.md +++ b/smart-contracts/14-revenue-sharing.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (Governance 0x0102). Needs: multi-source fee aggregation, epoch-based snapshots. + # Revenue Sharing / Dividend Distributor ## Category diff --git a/smart-contracts/17-synthetic-assets.md b/smart-contracts/17-synthetic-assets.md index bde8e73..0cfc1f6 100644 --- a/smart-contracts/17-synthetic-assets.md +++ b/smart-contracts/17-synthetic-assets.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires decentralized oracle (41) for underlying price feed. + # Synthetic Asset Protocol ## Category diff --git a/smart-contracts/18-token-vesting.md b/smart-contracts/18-token-vesting.md index 48cc6d7..7003956 100644 --- a/smart-contracts/18-token-vesting.md +++ b/smart-contracts/18-token-vesting.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (Escrow 0x0103). Needs: linear/cliff vesting curves, revocation logic. + # Token Vesting Contract ## Category diff --git a/smart-contracts/21-tokenized-bonds.md b/smart-contracts/21-tokenized-bonds.md index 4abc50c..d15755b 100644 --- a/smart-contracts/21-tokenized-bonds.md +++ b/smart-contracts/21-tokenized-bonds.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (BST-3525 SFT). Needs: coupon distribution, maturity tracking, credit rating integration. + # Tokenized Bonds ## Category diff --git a/smart-contracts/27-supply-chain.md b/smart-contracts/27-supply-chain.md index dfe4a58..b1fcda5 100644 --- a/smart-contracts/27-supply-chain.md +++ b/smart-contracts/27-supply-chain.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires oracle integration for IoT delivery confirmation. + # Supply Chain Finance ## Category diff --git a/smart-contracts/29-kyc-marketplace.md b/smart-contracts/29-kyc-marketplace.md index 1e5b15a..93f0512 100644 --- a/smart-contracts/29-kyc-marketplace.md +++ b/smart-contracts/29-kyc-marketplace.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (IssuerRegistry + ComplianceEngine + ZK compliance). Needs: provider bidding, credential pricing. + # Decentralized KYC Marketplace ## Category diff --git a/smart-contracts/30-reputation-soulbound.md b/smart-contracts/30-reputation-soulbound.md index e9bdc7a..442bc79 100644 --- a/smart-contracts/30-reputation-soulbound.md +++ b/smart-contracts/30-reputation-soulbound.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (BST-721). Needs: activity tracking, weight calculations, cross-protocol queries. + # Reputation System (Soulbound Tokens) ## Category diff --git a/smart-contracts/39-multisig-wallet.md b/smart-contracts/39-multisig-wallet.md index 9b422ef..db80008 100644 --- a/smart-contracts/39-multisig-wallet.md +++ b/smart-contracts/39-multisig-wallet.md @@ -1,3 +1,5 @@ +> **STATUS: DONE** — M-of-N Ed25519 multisig pattern implemented in **BridgeETH** (0x0107). Extractable as reusable wallet. + # Multi-Signature Wallet ## Category diff --git a/smart-contracts/40-dao-treasury.md b/smart-contracts/40-dao-treasury.md index f00b658..8618ccc 100644 --- a/smart-contracts/40-dao-treasury.md +++ b/smart-contracts/40-dao-treasury.md @@ -1,3 +1,5 @@ +> **STATUS: DONE** — Implemented as **Governance** system contract (0x0102): stake-weighted quadratic voting, delegation, timelock, executable proposals, StakingPool integration. + # DAO Treasury ## Category diff --git a/smart-contracts/41-oracle-contract.md b/smart-contracts/41-oracle-contract.md index ae84789..c7027df 100644 --- a/smart-contracts/41-oracle-contract.md +++ b/smart-contracts/41-oracle-contract.md @@ -1,3 +1,5 @@ +> **STATUS: PRIORITY** — Critical infrastructure gap. Unblocks 13+ other contracts. Build first. + # Decentralized Oracle Network ## Category diff --git a/smart-contracts/49-quadratic-funding.md b/smart-contracts/49-quadratic-funding.md index a26ae72..a2bdda2 100644 --- a/smart-contracts/49-quadratic-funding.md +++ b/smart-contracts/49-quadratic-funding.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (Governance 0x0102). Needs: matching pool logic, QF formula, ZK Sybil resistance. + # Quadratic Funding (Gitcoin-style) ## Category diff --git a/smart-contracts/50-futarchy.md b/smart-contracts/50-futarchy.md index b05c5c6..d5ef769 100644 --- a/smart-contracts/50-futarchy.md +++ b/smart-contracts/50-futarchy.md @@ -1,3 +1,5 @@ +> **STATUS: NEEDS ORACLE** — Requires decentralized oracle (41) for market outcome metrics and resolution. + # Futarchy Governance ## Category diff --git a/smart-contracts/68-cross-chain-bridge-generic.md b/smart-contracts/68-cross-chain-bridge-generic.md index f0b834a..2e46980 100644 --- a/smart-contracts/68-cross-chain-bridge-generic.md +++ b/smart-contracts/68-cross-chain-bridge-generic.md @@ -1,3 +1,5 @@ +> **STATUS: DONE** — Implemented as **BridgeETH** (0x0107): lock/unlock native BST, M-of-N Ed25519 multisig relayers, deposit lifecycle, pause/unpause. Currently EVM-focused. + # Generic Cross-Chain Message Bridge ## Category diff --git a/smart-contracts/75-nft-royalty-enforcer.md b/smart-contracts/75-nft-royalty-enforcer.md index c745d19..5471a2e 100644 --- a/smart-contracts/75-nft-royalty-enforcer.md +++ b/smart-contracts/75-nft-royalty-enforcer.md @@ -1,3 +1,5 @@ +> **STATUS: DONE** — Implemented via **Policy Hooks**: ITransferPolicy/INftTransferPolicy on BST-721/1155/3525 with PolicyEnforcer. Royalty deduction achievable as a custom policy contract. + # NFT Royalty Enforcement ## Category diff --git a/smart-contracts/77-carbon-offset-marketplace.md b/smart-contracts/77-carbon-offset-marketplace.md index 3594671..f6d5a8a 100644 --- a/smart-contracts/77-carbon-offset-marketplace.md +++ b/smart-contracts/77-carbon-offset-marketplace.md @@ -1,3 +1,5 @@ +> **STATUS: PARTIAL** — Foundation exists (BST-3525 + BST-VC). Needs: retirement mechanism, corporate accounting dashboard. + # Carbon Offset Marketplace ## Category diff --git a/smart-contracts/README.md b/smart-contracts/README.md new file mode 100644 index 0000000..0cf0860 --- /dev/null +++ b/smart-contracts/README.md @@ -0,0 +1,153 @@ +# Basalt Smart Contract Catalog + +78 contract specifications for the Basalt ecosystem. This index tracks implementation +status against the current codebase. + +> Last updated: 2026-03-12 + +## Status Legend + +| Tag | Meaning | +|-----|---------| +| **DONE** | Already implemented in the Basalt SDK or system contracts | +| **PARTIAL** | Foundation exists, spec extends beyond current implementation | +| **READY** | Buildable now with existing SDK primitives | +| **NEEDS ORACLE** | Buildable but requires the oracle contract (41) first | +| **BLOCKED** | Requires primitives that don't exist yet | + +--- + +## Already Implemented (7) + +These are **done** — the functionality already ships with Basalt. + +| # | Contract | Basalt Equivalent | +|---|----------|-------------------| +| 01 | AMM DEX | **Caldera Fusion DEX** — batch auction, order book, TWAP oracle, concentrated liquidity, encrypted intents, solver network | +| 02 | Order Book DEX | **Caldera Fusion DEX** — includes full order book engine | +| 06 | Liquid Staking | **StakingPool** (0x0105) system contract | +| 39 | Multisig Wallet | **BridgeETH** (0x0107) implements M-of-N Ed25519 multisig pattern | +| 40 | DAO Treasury | **Governance** (0x0102) — stake-weighted quadratic voting, timelock, executable proposals | +| 68 | Cross-Chain Bridge | **BridgeETH** (0x0107) — lock/unlock, multisig relayers, deposit lifecycle | +| 75 | NFT Royalty Enforcer | **Policy Hooks** — ITransferPolicy on BST-721/1155/3525 with PolicyEnforcer | + +## Partially Implemented (11) + +Foundation exists; the spec goes further than what's currently built. + +| # | Contract | What Exists | What's Missing | +|---|----------|-------------|----------------| +| 03 | Lending Protocol | BST-4626 Vault | Collateral baskets, liquidation engine, variable rates | +| 05 | Yield Aggregator | BST-4626 Vault | Multi-strategy orchestration, auto-harvest | +| 07 | Flash Loans | Escrow (0x0103) | Atomic single-tx borrow/repay, fee mechanics | +| 08 | Streaming Payments | Escrow (0x0103) | Linear streaming curves, cancellation/top-up | +| 14 | Revenue Sharing | Governance (0x0102) | Multi-source fee aggregation, epoch snapshots | +| 18 | Token Vesting | Escrow (0x0103) | Linear/cliff curves, revocation | +| 21 | Tokenized Bonds | BST-3525 SFT | Coupon distribution, maturity, credit ratings | +| 29 | KYC Marketplace | IssuerRegistry + ComplianceEngine | Provider bidding, credential pricing | +| 30 | Reputation (Soulbound) | BST-721 | Activity tracking, weight formulas, cross-protocol queries | +| 49 | Quadratic Funding | Governance (0x0102) | Matching pool, QF formula, Sybil resistance | +| 77 | Carbon Offset Marketplace | BST-3525 + BST-VC | Retirement mechanism, corporate accounting | + +## Ready to Build (34) + +Implementable **now** with existing SDK primitives (BST-20/721/1155/3525/4626, BST-VC, BST-DID, StorageMap, cross-contract calls, Escrow, ZK compliance). + +### DeFi +| # | Contract | Key Primitives | +|---|----------|----------------| +| 13 | Bonding Curve | BST-20, StorageMap (reserve tracking) | +| 15 | Lottery | BST-20, StakingPool yield, BLAKE3 randomness | +| 16 | DCA Bot | StorageMap, Caldera DEX swaps, keeper execution | +| 19 | Atomic Swap | BLAKE3 hashlock, block-number timelock | +| 20 | Fee Distributor | StorageMap, BST-4626, epoch snapshots | +| 67 | Token Launchpad | BST-20, Escrow, ZK compliance gating, vesting | +| 69 | NFT Lending | BST-721 collateral, Escrow, liquidation auctions | +| 70 | Index Fund | BST-4626 vault, weighted basket, rebalancing | +| 71 | Conditional Escrow | Escrow (0x0103), oracle triggers, multi-party | + +### Real-World Assets +| # | Contract | Key Primitives | +|---|----------|----------------| +| 22 | Real Estate Fractionalization | BST-3525 (slot=property), income distribution | +| 23 | Invoice Factoring | BST-3525 (slot=debtor), bidding, payment routing | +| 24 | Carbon Credits | BST-3525 (slot=vintage), retirement (burn), BST-VC | +| 25 | Commodity Tokens | BST-20, BST-VC proof-of-reserves | +| 26 | Music Royalties | BST-3525 (slot=song), streaming earnings | +| 28 | Art Fractionalization | BST-3525 (slot=artwork), buyout, BST-VC provenance | + +### Identity & Compliance +| # | Contract | Key Primitives | +|---|----------|----------------| +| 31 | Professional Licenses | BST-VC, IssuerRegistry, ZK proofs | +| 32 | Academic Credentials | BST-VC, SchemaRegistry | +| 33 | Age Verification | BST-VC + ZK range proofs | +| 34 | Compliant Privacy Pool | ComplianceEngine, Pedersen commitments, nullifiers | +| 35 | Confidential OTC | Escrow, Pedersen commitments, range proofs | +| 36 | Private Payroll | Pedersen commitments, ZK sum proofs, BST-VC | +| 37 | Anonymous Voting | Commit-reveal or ZK voting, BST-VC eligibility | +| 38 | Sealed-Bid Auction | Pedersen commitments, Escrow deposits | +| 76 | Identity Aggregator | BridgeETH, BST-DID, W3C compatibility | + +### Infrastructure & Governance +| # | Contract | Key Primitives | +|---|----------|----------------| +| 42 | Timelock Vault | Escrow extension, linear/cliff vesting | +| 43 | Payment Channels | Ed25519 signed state, on-chain open/close | +| 44 | Meta-Transactions | Ed25519 sig verification, relayer fees | +| 45 | Contract Factory | StorageMap registry, parameterized deploy | +| 46 | Dead Man's Switch | StorageMap, heartbeat tracking, time-delayed release | +| 47 | Subscription Manager | Pull payments, BST-721 subscription NFT | +| 48 | Conviction Voting | StorageMap, time-weighted conviction | +| 72 | Token Curated Registry | StorageMap, staking/challenging, governance | +| 73 | Perpetual Organization | Continuous membership, rage-quit, streaming salary | + +### Social & Gaming +| # | Contract | Key Primitives | +|---|----------|----------------| +| 51 | NFT Marketplace | BST-721/1155, Escrow, royalty enforcement | +| 52 | Loot & Crafting | BST-1155, deterministic recipes, BLAKE3 random | +| 53 | Play-to-Earn | Off-chain proofs, on-chain rewards, rate limiting | +| 54 | Virtual Land | BST-721 grid parcels, merge/split, rental | +| 55 | Achievement System | BST-721 soulbound badges | +| 56 | Trading Cards | BST-1155, pack opening, deck validation | +| 57 | Social Profile | StorageMap + BNS, BST-VC verification | +| 58 | Tipping | BST-20, BNS lookup, leaderboard | +| 59 | Content Monetization | BST-721 access tokens, streaming payments | +| 60 | Crowdfunding | Escrow, goal/deadline, milestone release | +| 61 | Bounty Board | Escrow, submission workflow, soulbound reputation | +| 62 | Messaging Registry | StorageMap, public key registry, BNS | +| 63 | Data Marketplace | IPFS hashes, encrypted keys, ZK quality proofs | +| 64 | Freelance Platform | Escrow milestones, BST-VC skills, arbitration | +| 65 | Social Recovery Wallet | M-of-N guardians, time-delayed rotation | +| 66 | Whistleblower Vault | Encrypted submission, ZK employment proofs | +| 74 | Credit Scoring | Soulbound BST-721, ZK range proofs | +| 78 | Decentralized Insurance Mutual | StorageMap pool, assessor voting, claims | + +## Needs Oracle (14) + +Require the **Decentralized Oracle Network (41)** to be built first. The oracle is the single highest-priority infrastructure gap. + +| # | Contract | Oracle Dependency | +|---|----------|-------------------| +| 41 | **Oracle Contract** | **This IS the oracle — build first** | +| 04 | Stablecoin CDP | Collateral price feed for peg stability | +| 09 | Perpetual Futures | Mark price, funding rate calculation | +| 10 | Options Protocol | Underlying asset price at expiry | +| 11 | Insurance Pool | Parametric trigger conditions (weather, etc.) | +| 12 | Prediction Market | Outcome resolution | +| 17 | Synthetic Assets | Underlying price feed | +| 27 | Supply Chain | IoT delivery confirmation | +| 50 | Futarchy | Market outcome metrics + resolution | + +> Note: 22 (Real Estate), 23 (Invoice), 24 (Carbon), 25 (Commodity), 26 (Music) also benefit from oracles for RWA pricing but can function with admin-set prices initially. + +--- + +## Implementation Priority + +1. **Oracle Contract (41)** — unblocks 13+ specs, critical DeFi infrastructure +2. **NFT Marketplace (51)** — high ecosystem value, fully buildable now +3. **Token Launchpad (67)** — drives token creation and ecosystem growth +4. **Lending Protocol (03)** — core DeFi primitive (BST-4626 foundation exists) +5. **Stablecoin CDP (04)** — foundational for DeFi (needs oracle) \ No newline at end of file diff --git a/src/api/Basalt.Api.Rest/RestApiEndpoints.cs b/src/api/Basalt.Api.Rest/RestApiEndpoints.cs index 099e46b..e0eae62 100644 --- a/src/api/Basalt.Api.Rest/RestApiEndpoints.cs +++ b/src/api/Basalt.Api.Rest/RestApiEndpoints.cs @@ -807,6 +807,172 @@ public static void MapBasaltEndpoints( // ═══ DEX Endpoints ═══ + app.MapGet("/v1/dex/quote", (string? tokenIn, string? tokenOut, string? amountIn, uint? feeBps) => + { + if (string.IsNullOrEmpty(tokenIn) || string.IsNullOrEmpty(tokenOut) || string.IsNullOrEmpty(amountIn)) + return Microsoft.AspNetCore.Http.Results.BadRequest(new ErrorResponse { Code = 400, Message = "tokenIn, tokenOut, and amountIn are required" }); + + if (!Address.TryFromHexString(tokenIn, out var tokenInAddr)) + return Microsoft.AspNetCore.Http.Results.BadRequest(new ErrorResponse { Code = 400, Message = "Invalid tokenIn address format" }); + if (!Address.TryFromHexString(tokenOut, out var tokenOutAddr)) + return Microsoft.AspNetCore.Http.Results.BadRequest(new ErrorResponse { Code = 400, Message = "Invalid tokenOut address format" }); + if (!UInt256.TryParse(amountIn, out var amountInVal) || amountInVal.IsZero) + return Microsoft.AspNetCore.Http.Results.BadRequest(new ErrorResponse { Code = 400, Message = "amountIn must be a non-zero integer" }); + if (tokenInAddr == tokenOutAddr) + return Microsoft.AspNetCore.Http.Results.BadRequest(new ErrorResponse { Code = 400, Message = "tokenIn and tokenOut must be different" }); + + var dexState = new DexState(stateDb); + var (token0, token1) = DexEngine.SortTokens(tokenInAddr, tokenOutAddr); + var zeroForOne = tokenInAddr == token0; + + var currentBlock = chainManager.LatestBlock?.Number ?? 0; + + // If feeBps specified, look up that specific pool. Otherwise try all fee tiers. + uint[] feeTiers = feeBps.HasValue ? [feeBps.Value] : DexLibrary.AllowedFeeTiers; + + ulong bestPoolId = 0; + UInt256 bestAmountOut = UInt256.Zero; + uint bestEffectiveFee = 0; + bool bestIsConcentrated = false; + bool found = false; + + foreach (var tier in feeTiers) + { + var poolId = dexState.LookupPool(token0, token1, tier); + if (poolId == null) continue; + + var isConcentrated = dexState.GetConcentratedPoolState(poolId.Value) != null; + UInt256 amountOut; + + if (isConcentrated) + { + var clPool = new ConcentratedPool(dexState); + var sqrtPriceLimit = zeroForOne ? TickMath.MinSqrtRatio + UInt256.One : TickMath.MaxSqrtRatio - UInt256.One; + var effectiveFee = DynamicFeeCalculator.ComputeDynamicFeeFromState(dexState, poolId.Value, tier, currentBlock); + var result = clPool.SimulateSwap(poolId.Value, zeroForOne, amountInVal, sqrtPriceLimit, effectiveFee); + if (result == null) continue; + amountOut = result.Value.AmountOut; + + if (amountOut > bestAmountOut) + { + bestPoolId = poolId.Value; + bestAmountOut = amountOut; + bestEffectiveFee = effectiveFee; + bestIsConcentrated = true; + found = true; + } + } + else + { + var reserves = dexState.GetPoolReserves(poolId.Value); + if (reserves == null || reserves.Value.Reserve0.IsZero || reserves.Value.Reserve1.IsZero) + continue; + + var reserveIn = zeroForOne ? reserves.Value.Reserve0 : reserves.Value.Reserve1; + var reserveOut = zeroForOne ? reserves.Value.Reserve1 : reserves.Value.Reserve0; + var effectiveFee = DynamicFeeCalculator.ComputeDynamicFeeFromState(dexState, poolId.Value, tier, currentBlock); + + try + { + amountOut = DexLibrary.GetAmountOut(amountInVal, reserveIn, reserveOut, effectiveFee); + } + catch (ArgumentException) + { + continue; + } + + if (amountOut > bestAmountOut) + { + bestPoolId = poolId.Value; + bestAmountOut = amountOut; + bestEffectiveFee = effectiveFee; + bestIsConcentrated = false; + found = true; + } + } + } + + if (!found) + return Microsoft.AspNetCore.Http.Results.NotFound(new ErrorResponse { Code = 404, Message = "No pool found for this token pair" }); + + // Compute spot price, TWAP, volatility + UInt256 spotPrice; + if (bestIsConcentrated) + { + // For concentrated pools, derive spot price from sqrtPriceX96: + // price = (sqrtPriceX96)^2 / 2^192, scaled by PriceScale (2^64) = sqrtPriceX96^2 / 2^128 + var clState = dexState.GetConcentratedPoolState(bestPoolId); + if (clState != null && !clState.Value.SqrtPriceX96.IsZero) + { + var sqrtP = clState.Value.SqrtPriceX96; + // PriceScale = 2^64; price_scaled = sqrtP * sqrtP * 2^64 / 2^192 = sqrtP * sqrtP / 2^128 + // Use FullMath.MulDiv(sqrtP, sqrtP, 2^128) but 2^128 is UInt128.MaxValue+1 + // Instead: MulDiv(sqrtP, sqrtP, 1) >> 128 doesn't work easily. + // Simpler: MulDiv(sqrtP, sqrtP, 2^96) / 2^96 * PriceScale + // = MulDiv(sqrtP, sqrtP, 2^96) * 2^64 / 2^96 + // = MulDiv(sqrtP, sqrtP, 2^96) / 2^32 + // But integer division loses precision. Better approach: + // spotPrice = MulDiv(sqrtP, MulDiv(sqrtP, PriceScale, Q96), Q96) + // where Q96 = 2^96 + var q96 = UInt256.One << 96; + spotPrice = FullMath.MulDiv(sqrtP, FullMath.MulDiv(sqrtP, BatchAuctionSolver.PriceScale, q96), q96); + } + else + { + spotPrice = UInt256.Zero; + } + } + else + { + var bestReserves = dexState.GetPoolReserves(bestPoolId); + spotPrice = (bestReserves != null && !bestReserves.Value.Reserve0.IsZero) + ? BatchAuctionSolver.ComputeSpotPrice(bestReserves.Value.Reserve0, bestReserves.Value.Reserve1) + : UInt256.Zero; + } + + var twap = TwapOracle.ComputeTwap(dexState, bestPoolId, currentBlock, 100); + var volatilityBps = TwapOracle.ComputeVolatilityBps(dexState, bestPoolId, currentBlock, 7200); + + // Price impact: compare spot-based output (if no slippage) vs actual output + uint priceImpactBps = 0; + if (!spotPrice.IsZero) + { + // Spot amount out = amountIn * spotPrice / PriceScale (for zeroForOne) + // or amountIn * PriceScale / spotPrice (for oneForZero) + // Then deduct fee to get spotAmountOutAfterFee + UInt256 spotAmountOutAfterFee; + var feeComplement = new UInt256(10_000 - bestEffectiveFee); + var feeDenom = new UInt256(10_000); + var amountInAfterFee = FullMath.MulDiv(amountInVal, feeComplement, feeDenom); + + if (zeroForOne) + spotAmountOutAfterFee = FullMath.MulDiv(amountInAfterFee, spotPrice, BatchAuctionSolver.PriceScale); + else + spotAmountOutAfterFee = FullMath.MulDiv(amountInAfterFee, BatchAuctionSolver.PriceScale, spotPrice); + + if (!spotAmountOutAfterFee.IsZero && spotAmountOutAfterFee > bestAmountOut) + { + var impact = FullMath.MulDiv(spotAmountOutAfterFee - bestAmountOut, new UInt256(10_000), spotAmountOutAfterFee); + priceImpactBps = (uint)(ulong)impact.Lo; + } + } + + return Microsoft.AspNetCore.Http.Results.Ok(new DexQuoteResponse + { + PoolId = bestPoolId, + TokenIn = tokenIn, + TokenOut = tokenOut, + AmountIn = amountInVal.ToString(), + AmountOut = bestAmountOut.ToString(), + EffectiveFeeBps = bestEffectiveFee, + PriceImpactBps = priceImpactBps, + SpotPrice = spotPrice.ToString(), + Twap = twap.ToString(), + VolatilityBps = volatilityBps, + IsConcentrated = bestIsConcentrated, + }); + }); + app.MapGet("/v1/dex/pools", () => { var dexState = new DexState(stateDb); @@ -1535,6 +1701,21 @@ public static DexOrderResponse From(ulong orderId, LimitOrder order) } } +public sealed class DexQuoteResponse +{ + [JsonPropertyName("poolId")] public ulong PoolId { get; set; } + [JsonPropertyName("tokenIn")] public string TokenIn { get; set; } = ""; + [JsonPropertyName("tokenOut")] public string TokenOut { get; set; } = ""; + [JsonPropertyName("amountIn")] public string AmountIn { get; set; } = "0"; + [JsonPropertyName("amountOut")] public string AmountOut { get; set; } = "0"; + [JsonPropertyName("effectiveFeeBps")] public uint EffectiveFeeBps { get; set; } + [JsonPropertyName("priceImpactBps")] public uint PriceImpactBps { get; set; } + [JsonPropertyName("spotPrice")] public string SpotPrice { get; set; } = "0"; + [JsonPropertyName("twap")] public string Twap { get; set; } = "0"; + [JsonPropertyName("volatilityBps")] public uint VolatilityBps { get; set; } + [JsonPropertyName("isConcentrated")] public bool IsConcentrated { get; set; } +} + public sealed class DexLpBalanceResponse { [JsonPropertyName("poolId")] public ulong PoolId { get; set; } @@ -1624,6 +1805,7 @@ public sealed class SyncBlocksResponse [JsonSerializable(typeof(LogResponse[]))] [JsonSerializable(typeof(ComplianceProofDto))] [JsonSerializable(typeof(ComplianceProofDto[]))] +[JsonSerializable(typeof(DexQuoteResponse))] [JsonSerializable(typeof(DexLpBalanceResponse))] [JsonSerializable(typeof(DexPoolResponse))] [JsonSerializable(typeof(DexPoolResponse[]))] diff --git a/src/execution/Basalt.Execution/VM/ContractRegistry.cs b/src/execution/Basalt.Execution/VM/ContractRegistry.cs index da0345f..7bdea9b 100644 --- a/src/execution/Basalt.Execution/VM/ContractRegistry.cs +++ b/src/execution/Basalt.Execution/VM/ContractRegistry.cs @@ -205,6 +205,19 @@ public static ContractRegistry CreateDefault() return new Basalt.Sdk.Contracts.Standards.BridgeETH(); }); + // Policy contracts (user-deployable) + registry.Register(0x0008, "HoldingLimitPolicy", _ => + new Basalt.Sdk.Contracts.Policies.HoldingLimitPolicy()); + + registry.Register(0x0009, "LockupPolicy", _ => + new Basalt.Sdk.Contracts.Policies.LockupPolicy()); + + registry.Register(0x000A, "JurisdictionPolicy", _ => + new Basalt.Sdk.Contracts.Policies.JurisdictionPolicy()); + + registry.Register(0x000B, "SanctionsPolicy", _ => + new Basalt.Sdk.Contracts.Policies.SanctionsPolicy()); + return registry; } diff --git a/src/node/Basalt.Node/NodeCoordinator.cs b/src/node/Basalt.Node/NodeCoordinator.cs index 444e3aa..f52d4c9 100644 --- a/src/node/Basalt.Node/NodeCoordinator.cs +++ b/src/node/Basalt.Node/NodeCoordinator.cs @@ -1664,6 +1664,18 @@ private async Task RunPipelinedConsensusLoop(CancellationToken ct) if (Volatile.Read(ref _isSyncing) != 0) continue; + // Periodic behind-detection: check if any connected peer is ahead. + // This runs BEFORE the circuit breaker check so that a node stuck 1 block + // behind can still trigger sync during cooldown — otherwise small gaps + // cause a permanent stall when all validators trip their circuit breakers. + // The _isSyncing guard in TrySyncFromPeers prevents concurrent attempts. + var bestPeerCheck = GetBestPeer(); + if (bestPeerCheck != null && bestPeerCheck.BestBlockNumber > _chainManager.LatestBlockNumber) + { + // Fire-and-forget sync attempt; _isSyncing guard deduplicates + _ = Task.Run(() => TrySyncFromPeers(ct), ct); + } + // Circuit breaker auto-reset: after cooldown, reset and attempt // sync to recover from transient failures (state divergence, etc.) if (_circuitBreakerTripped) @@ -1686,7 +1698,7 @@ private async Task RunPipelinedConsensusLoop(CancellationToken ct) } else { - continue; // Still in cooldown, skip this iteration + continue; // Still in cooldown, skip proposals } } @@ -1706,19 +1718,6 @@ private async Task RunPipelinedConsensusLoop(CancellationToken ct) // Cleanup finalized rounds periodically _pipelinedConsensus.CleanupFinalizedRounds(); - // Periodic behind-detection: check if any connected peer is significantly - // ahead. This catches the case where a node fell behind and no proposals - // arrive (e.g., because other validators' circuit breakers are also tripped). - // Without this, OnBehindDetected only fires from received proposals. - var bestPeerCheck = GetBestPeer(); - if (bestPeerCheck != null && bestPeerCheck.BestBlockNumber > _chainManager.LatestBlockNumber + 10) - { - _logger.LogInformation( - "Peer {Peer} is at #{PeerHeight} vs local #{LocalHeight} — triggering catch-up sync", - bestPeerCheck.Id, bestPeerCheck.BestBlockNumber, _chainManager.LatestBlockNumber); - _ = Task.Run(() => TrySyncFromPeers(ct), ct); - } - _episub!.RebalanceTiers(); } catch (OperationCanceledException) diff --git a/src/sdk/Basalt.Sdk.Analyzers/AnalyzerReleases.Unshipped.md b/src/sdk/Basalt.Sdk.Analyzers/AnalyzerReleases.Unshipped.md index 62d47f0..f50d866 100644 --- a/src/sdk/Basalt.Sdk.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/sdk/Basalt.Sdk.Analyzers/AnalyzerReleases.Unshipped.md @@ -10,3 +10,7 @@ BST005 | Basalt.Safety | Warning | OverflowAnalyzer BST006 | Basalt.Safety | Warning | StorageAccessAnalyzer BST007 | Basalt.Performance | Info | GasEstimationAnalyzer BST008 | Basalt.Compatibility | Error | AotCompatibilityAnalyzer +BST009 | Basalt.Safety | Warning | CrossContractReturnAnalyzer +BST010 | Basalt.Safety | Warning | PolicyOrderingAnalyzer +BST011 | Basalt.Determinism | Warning | CollectionOrderingAnalyzer +BST012 | Basalt.Safety | Warning | PolicyEnforcementAnalyzer diff --git a/src/sdk/Basalt.Sdk.Analyzers/CollectionOrderingAnalyzer.cs b/src/sdk/Basalt.Sdk.Analyzers/CollectionOrderingAnalyzer.cs new file mode 100644 index 0000000..ffce50b --- /dev/null +++ b/src/sdk/Basalt.Sdk.Analyzers/CollectionOrderingAnalyzer.cs @@ -0,0 +1,102 @@ +#nullable enable +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Basalt.Sdk.Analyzers; + +/// +/// BST011: Warns when Dictionary<,> or HashSet<> are used inside a +/// BasaltContract. These collections have non-deterministic iteration order +/// across runtimes, which breaks consensus. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CollectionOrderingAnalyzer : DiagnosticAnalyzer +{ + private static readonly string[] BannedTypeNames = + { + "Dictionary", "HashSet", + }; + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticIds.NonDeterministicCollection); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression); + context.RegisterSyntaxNodeAction(AnalyzeGenericName, SyntaxKind.GenericName); + } + + private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) + { + var creation = (ObjectCreationExpressionSyntax)context.Node; + + if (!AnalyzerHelper.IsInsideBasaltContract(creation)) + return; + + var typeName = GetUnqualifiedTypeName(creation.Type); + if (typeName == null) return; + + for (int i = 0; i < BannedTypeNames.Length; i++) + { + if (typeName == BannedTypeNames[i]) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.NonDeterministicCollection, + creation.GetLocation(), + $"new {typeName}() — hash-based collections have non-deterministic iteration order; use StorageMap or SortedDictionary instead")); + return; + } + } + } + + private static void AnalyzeGenericName(SyntaxNodeAnalysisContext context) + { + var genericName = (GenericNameSyntax)context.Node; + + if (!AnalyzerHelper.IsInsideBasaltContract(genericName)) + return; + + // Only flag field declarations (not local variables used transiently). + // Walk up from the GenericName to find if it's part of a field declaration. + var current = genericName.Parent; + while (current != null) + { + if (current is FieldDeclarationSyntax) + { + var typeName = genericName.Identifier.Text; + for (int i = 0; i < BannedTypeNames.Length; i++) + { + if (typeName == BannedTypeNames[i]) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.NonDeterministicCollection, + genericName.GetLocation(), + $"{typeName}<> field — hash-based collections have non-deterministic iteration order; use StorageMap or SortedDictionary instead")); + return; + } + } + break; + } + if (current is MemberDeclarationSyntax) + break; + current = current.Parent; + } + } + + private static string? GetUnqualifiedTypeName(TypeSyntax type) + { + return type switch + { + GenericNameSyntax generic => generic.Identifier.Text, + QualifiedNameSyntax qualified => GetUnqualifiedTypeName(qualified.Right), + IdentifierNameSyntax id => id.Identifier.Text, + _ => null, + }; + } +} diff --git a/src/sdk/Basalt.Sdk.Analyzers/CrossContractReturnAnalyzer.cs b/src/sdk/Basalt.Sdk.Analyzers/CrossContractReturnAnalyzer.cs new file mode 100644 index 0000000..51fd9b2 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Analyzers/CrossContractReturnAnalyzer.cs @@ -0,0 +1,60 @@ +#nullable enable +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Basalt.Sdk.Analyzers; + +/// +/// BST009: Warns when Context.CallContract<bool> return value is discarded +/// (used as a statement expression rather than checked in a condition or Require). +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CrossContractReturnAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticIds.UncheckedCrossContractReturn); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeExpressionStatement, SyntaxKind.ExpressionStatement); + } + + private static void AnalyzeExpressionStatement(SyntaxNodeAnalysisContext context) + { + var exprStmt = (ExpressionStatementSyntax)context.Node; + + if (!AnalyzerHelper.IsInsideBasaltContract(exprStmt)) + return; + + if (exprStmt.Expression is not InvocationExpressionSyntax invocation) + return; + + // Use semantic model to resolve the invocation target + var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken); + if (symbolInfo.Symbol is not IMethodSymbol method) + return; + + // Check for Context.CallContract — a generic method named CallContract + // on a type named Context, with bool as the type argument + if (method.Name != "CallContract" || !method.IsGenericMethod) + return; + + if (method.ContainingType?.Name != "Context") + return; + + if (method.TypeArguments.Length == 1 && + method.TypeArguments[0].SpecialType == SpecialType.System_Boolean) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.UncheckedCrossContractReturn, + invocation.GetLocation(), + "Context.CallContract() return value discarded — check the result with Context.Require()")); + } + } +} diff --git a/src/sdk/Basalt.Sdk.Analyzers/DiagnosticIds.cs b/src/sdk/Basalt.Sdk.Analyzers/DiagnosticIds.cs index 524766e..8430939 100644 --- a/src/sdk/Basalt.Sdk.Analyzers/DiagnosticIds.cs +++ b/src/sdk/Basalt.Sdk.Analyzers/DiagnosticIds.cs @@ -68,4 +68,36 @@ internal static class DiagnosticIds "Basalt.Compatibility", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UncheckedCrossContractReturn = new( + "BST009", + "Unchecked cross-contract call return value", + "Cross-contract call return value not checked: {0}", + "Basalt.Safety", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor StateWriteBeforePolicyCheck = new( + "BST010", + "State write before policy enforcement", + "Storage write before policy check: {0}", + "Basalt.Safety", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor NonDeterministicCollection = new( + "BST011", + "Non-deterministic collection iteration", + "Non-deterministic collection: {0}", + "Basalt.Determinism", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MissingPolicyEnforcement = new( + "BST012", + "Missing policy enforcement in transfer override", + "Transfer override without policy enforcement: {0}", + "Basalt.Safety", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/src/sdk/Basalt.Sdk.Analyzers/PolicyEnforcementAnalyzer.cs b/src/sdk/Basalt.Sdk.Analyzers/PolicyEnforcementAnalyzer.cs new file mode 100644 index 0000000..bf92d15 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Analyzers/PolicyEnforcementAnalyzer.cs @@ -0,0 +1,141 @@ +#nullable enable +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Basalt.Sdk.Analyzers; + +/// +/// BST012: Warns when a class that derives from a BST token base type +/// (BST20Token, BST721Token, BST1155Token, BST3525Token) has a method named +/// TransferInternal that does not contain a call to EnforceTransfer or +/// EnforceNftTransfer. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PolicyEnforcementAnalyzer : DiagnosticAnalyzer +{ + private static readonly string[] BstTokenBaseTypes = + { + "BST20Token", "BST721Token", "BST1155Token", "BST3525Token", + }; + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticIds.MissingPolicyEnforcement); + + /// + /// Transfer method names that require policy enforcement, mapped to the base types + /// on which they are defined. TransferInternal applies to BST20/BST721. + /// SafeTransferFrom/SafeBatchTransferFrom apply to BST1155. + /// TransferValueFrom/TransferFrom apply to BST3525. + /// + private static readonly (string MethodName, string[] BaseTypes)[] TransferMethods = + { + ("TransferInternal", new[] { "BST20Token", "BST721Token" }), + ("SafeTransferFrom", new[] { "BST1155Token" }), + ("SafeBatchTransferFrom", new[] { "BST1155Token" }), + ("TransferValueFrom", new[] { "BST3525Token" }), + ("TransferFrom", new[] { "BST3525Token" }), + }; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration); + } + + private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) + { + var methodDecl = (MethodDeclarationSyntax)context.Node; + + if (!AnalyzerHelper.IsInsideBasaltContract(methodDecl)) + return; + + var methodName = methodDecl.Identifier.Text; + + // Find which base types this method name applies to + string[]? applicableBaseTypes = null; + for (int i = 0; i < TransferMethods.Length; i++) + { + if (TransferMethods[i].MethodName == methodName) + { + applicableBaseTypes = TransferMethods[i].BaseTypes; + break; + } + } + if (applicableBaseTypes == null) + return; + + // Check if the enclosing class derives from an applicable BST token base type + // Uses the semantic model to walk the full inheritance chain (catches indirect inheritance) + var classDecl = methodDecl.Ancestors().OfType().FirstOrDefault(); + if (classDecl == null) + return; + + var classSymbol = context.SemanticModel.GetDeclaredSymbol(classDecl, context.CancellationToken); + if (classSymbol == null) + return; + + bool derivesBstToken = false; + var current = classSymbol.BaseType; + while (current != null) + { + for (int i = 0; i < applicableBaseTypes.Length; i++) + { + if (current.Name == applicableBaseTypes[i]) + { + derivesBstToken = true; + break; + } + } + if (derivesBstToken) break; + current = current.BaseType; + } + + if (!derivesBstToken) + return; + + // Check if the method body contains EnforceTransfer or EnforceNftTransfer + SyntaxNode? bodyNode = (SyntaxNode?)methodDecl.Body ?? methodDecl.ExpressionBody; + if (bodyNode == null) + return; + + bool hasEnforcement = false; + foreach (var node in bodyNode.DescendantNodes()) + { + if (node is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var name = memberAccess.Name.Identifier.Text; + if (name == "EnforceTransfer" || name == "EnforceNftTransfer") + { + hasEnforcement = true; + break; + } + } + } + + if (!hasEnforcement) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.MissingPolicyEnforcement, + methodDecl.Identifier.GetLocation(), + $"{methodName} in {classDecl.Identifier.Text} does not call EnforceTransfer()/EnforceNftTransfer() — policy hooks will be bypassed")); + } + } + + private static string? GetUnqualifiedName(TypeSyntax type) + { + return type switch + { + IdentifierNameSyntax id => id.Identifier.Text, + GenericNameSyntax generic => generic.Identifier.Text, + QualifiedNameSyntax qualified => GetUnqualifiedName(qualified.Right), + _ => null, + }; + } +} diff --git a/src/sdk/Basalt.Sdk.Analyzers/PolicyOrderingAnalyzer.cs b/src/sdk/Basalt.Sdk.Analyzers/PolicyOrderingAnalyzer.cs new file mode 100644 index 0000000..12fbb66 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Analyzers/PolicyOrderingAnalyzer.cs @@ -0,0 +1,125 @@ +#nullable enable +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Basalt.Sdk.Analyzers; + +/// +/// BST010: Warns when a storage .Set() or .Delete() call appears before an +/// EnforceTransfer() or EnforceNftTransfer() call within the same method body. +/// State should be mutated only after policy enforcement passes. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PolicyOrderingAnalyzer : DiagnosticAnalyzer +{ + private static readonly string[] StorageTypeNames = + { + "StorageValue", "StorageMap", "StorageList", + }; + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticIds.StateWriteBeforePolicyCheck); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration); + } + + private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) + { + var methodDecl = (MethodDeclarationSyntax)context.Node; + + if (!AnalyzerHelper.IsInsideBasaltContract(methodDecl)) + return; + + if (methodDecl.Body == null && methodDecl.ExpressionBody == null) + return; + + SyntaxNode bodyNode = (SyntaxNode?)methodDecl.Body ?? methodDecl.ExpressionBody!; + + var storageWrites = new List<(SyntaxNode Node, int Position, string MethodName)>(); + int firstEnforcePosition = int.MaxValue; + + // Only walk top-level statements — skip nested lambdas and local functions + // to avoid false positives from .Set()/.Delete() inside deferred callbacks. + foreach (var node in GetTopLevelDescendants(bodyNode)) + { + if (node is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name.Identifier.Text; + + if (methodName == "EnforceTransfer" || methodName == "EnforceNftTransfer") + { + if (invocation.SpanStart < firstEnforcePosition) + firstEnforcePosition = invocation.SpanStart; + } + else if (methodName == "Set" || methodName == "Delete") + { + // Verify the receiver is a Basalt storage type via semantic model + var receiverType = context.SemanticModel.GetTypeInfo( + memberAccess.Expression, context.CancellationToken).Type; + + if (receiverType != null && IsStorageType(receiverType)) + { + storageWrites.Add((invocation, invocation.SpanStart, methodName)); + } + } + } + } + + // No policy enforcement in this method — nothing to check + if (firstEnforcePosition == int.MaxValue) + return; + + foreach (var write in storageWrites) + { + if (write.Position < firstEnforcePosition) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticIds.StateWriteBeforePolicyCheck, + write.Node.GetLocation(), + $".{write.MethodName}() called before policy enforcement — move state writes after EnforceTransfer()/EnforceNftTransfer()")); + } + } + } + + private static bool IsStorageType(ITypeSymbol type) + { + var name = type.Name; + for (int i = 0; i < StorageTypeNames.Length; i++) + { + if (name == StorageTypeNames[i]) + return true; + } + return false; + } + + /// + /// Yields descendant nodes of the body but skips into nested lambdas, + /// anonymous functions, and local function bodies to avoid false positives. + /// + private static IEnumerable GetTopLevelDescendants(SyntaxNode root) + { + foreach (var node in root.ChildNodes()) + { + // Skip lambda/anonymous function/local function bodies + if (node is LambdaExpressionSyntax or + AnonymousFunctionExpressionSyntax or + LocalFunctionStatementSyntax) + continue; + + yield return node; + + foreach (var descendant in GetTopLevelDescendants(node)) + yield return descendant; + } + } +} diff --git a/src/sdk/Basalt.Sdk.Analyzers/README.md b/src/sdk/Basalt.Sdk.Analyzers/README.md index 666a00a..d92bcfa 100644 --- a/src/sdk/Basalt.Sdk.Analyzers/README.md +++ b/src/sdk/Basalt.Sdk.Analyzers/README.md @@ -14,12 +14,16 @@ Roslyn analyzers for Basalt smart contract safety. Catches common errors at buil | BST006 | Warning | Direct ContractStorage access detected | Raw `ContractStorage.Read` and `ContractStorage.Write` calls. Recommends using `StorageValue`/`StorageMap` wrappers instead. | | BST007 | Info | Estimated gas cost | Reports a gas estimate on methods marked `[BasaltEntrypoint]` or `[BasaltView]`. Sums base gas (21,000) + loops (10,000 each) + storage operations (5,000 each) + external calls (25,000 each). | | BST008 | Error | API incompatible with Basalt AOT sandbox | Banned member accesses: `Type.MakeGenericType`, `Activator.CreateInstance`, `Assembly.Load`/`LoadFrom`, `Task.Run`, `Parallel.For`/`ForEach`/`Invoke`, file I/O (`File.ReadAllText`/`WriteAllText`/`ReadAllBytes`/`WriteAllBytes`/`Exists`/`Delete`/`Open`, `Directory.Exists`/`CreateDirectory`). Banned constructor types: `Thread`, `HttpClient`, `TcpClient`, `Socket`, `WebClient`, `FileStream`, `StreamReader`, `StreamWriter`. | +| BST009 | Warning | Unchecked cross-contract return value | `Context.CallContract(...)` used as an expression statement with the return value discarded. The call may silently fail. Check or assign the result. | +| BST010 | Warning | Storage mutation before policy enforcement | `.Set()` or `.Delete()` call on a Basalt storage type appears before `EnforceTransfer()` or `EnforceNftTransfer()` in the same method. Storage mutations should follow policy checks (checks-effects-interactions). | +| BST011 | Warning | Non-deterministic collection in contract | `Dictionary<>` or `HashSet<>` created or declared as a field inside a `[BasaltContract]`. Iteration order is non-deterministic across runtimes. Use `SortedDictionary<>` or `SortedSet<>` instead. | +| BST012 | Warning | Missing policy enforcement in transfer method | A class deriving from a BST token type overrides a transfer method without calling `EnforceTransfer` or `EnforceNftTransfer`. Covers `TransferInternal` (BST-20/721), `SafeTransferFrom`/`SafeBatchTransferFrom` (BST-1155), and `TransferValueFrom`/`TransferFrom` (BST-3525). | ## Diagnostic Categories - **Basalt.Compatibility** -- BST001, BST002, BST008 (AOT and sandbox compatibility). -- **Basalt.Determinism** -- BST003 (non-deterministic APIs). -- **Basalt.Safety** -- BST004, BST005, BST006 (reentrancy, overflow, raw storage). +- **Basalt.Determinism** -- BST003, BST011 (non-deterministic APIs and collections). +- **Basalt.Safety** -- BST004, BST005, BST006, BST009, BST010, BST012 (reentrancy, overflow, raw storage, unchecked returns, policy ordering, missing enforcement). - **Basalt.Performance** -- BST007 (gas estimation). ## AnalyzerHelper diff --git a/src/sdk/Basalt.Sdk.Contracts/Context.cs b/src/sdk/Basalt.Sdk.Contracts/Context.cs index 8410a57..96321e0 100644 --- a/src/sdk/Basalt.Sdk.Contracts/Context.cs +++ b/src/sdk/Basalt.Sdk.Contracts/Context.cs @@ -122,6 +122,22 @@ public static void TransferNative(byte[] to, UInt256 amount) /// public static HashSet ReentrancyGuard { get; } = new(); + /// + /// Ref-counted set of contracts that currently have active outgoing cross-contract calls. + /// If any of these is called back (re-entry), the call is forced into static mode. + /// A dictionary is used instead of a HashSet so that nested outgoing calls from + /// the same contract (A → B → A → C) correctly maintain the "active" state until + /// all outgoing calls from that contract have returned. + /// + private static readonly Dictionary ActiveCallers = new(); + + /// + /// True when the current execution is a static (read-only) callback. + /// Storage writes are blocked during static calls to prevent reentrancy attacks + /// while still allowing view-method callbacks (e.g. policy querying BalanceOf). + /// + public static bool IsStaticCall { get; internal set; } + /// /// Delegate for cross-contract calls. Set by the runtime/test host. /// Parameters: targetAddress, methodName, args. Returns: result object or null. @@ -147,11 +163,18 @@ public static T CallContract(byte[] targetAddress, string methodName, params var previousEventEmitted = EventEmitted; var previousNativeTransferHandler = NativeTransferHandler; + var selfKey = Convert.ToHexString(Self); + var previousIsStaticCall = IsStaticCall; + try { - // H-2: Guard the calling contract's own address against re-entry - var selfKey = Convert.ToHexString(Self); - ReentrancyGuard.Add(selfKey); + // If the target has an active outgoing call (it's a caller higher in the + // chain), this is a re-entrant callback. Force static mode so the callee + // can read state (e.g. BalanceOf) but cannot mutate it. + if (ActiveCallers.ContainsKey(targetKey)) + IsStaticCall = true; + + ActiveCallers[selfKey] = ActiveCallers.GetValueOrDefault(selfKey) + 1; ReentrancyGuard.Add(targetKey); CallDepth++; Caller = Self; // The calling contract becomes the caller @@ -164,9 +187,13 @@ public static T CallContract(byte[] targetAddress, string methodName, params finally { // H-3: Restore full caller context + var count = ActiveCallers.GetValueOrDefault(selfKey); + if (count <= 1) + ActiveCallers.Remove(selfKey); + else + ActiveCallers[selfKey] = count - 1; ReentrancyGuard.Remove(targetKey); - var selfKey = Convert.ToHexString(previousSelf); - ReentrancyGuard.Remove(selfKey); + IsStaticCall = previousIsStaticCall; CallDepth = previousDepth; Caller = previousCaller; Self = previousSelf; @@ -199,6 +226,8 @@ public static void Reset() IsDeploying = false; CallDepth = 0; ReentrancyGuard.Clear(); + ActiveCallers.Clear(); + IsStaticCall = false; EventEmitted = null; NativeTransferHandler = null; CrossContractCallHandler = null; diff --git a/src/sdk/Basalt.Sdk.Contracts/Policies/HoldingLimitPolicy.cs b/src/sdk/Basalt.Sdk.Contracts/Policies/HoldingLimitPolicy.cs new file mode 100644 index 0000000..b228b61 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Contracts/Policies/HoldingLimitPolicy.cs @@ -0,0 +1,126 @@ +using Basalt.Core; + +namespace Basalt.Sdk.Contracts.Policies; + +/// +/// Policy contract that enforces maximum holding limits per address per token. +/// Deploy this contract, configure limits, then register it with BST tokens. +/// Type ID: 0x0008 +/// +[BasaltContract] +public partial class HoldingLimitPolicy : ITransferPolicy +{ + private readonly StorageMap _admin; + private readonly StorageMap _limits; // "token:address" -> max balance + private readonly StorageMap _defaultLimits; // token -> default max + + public HoldingLimitPolicy() + { + _admin = new StorageMap("hlp_admin"); + _limits = new StorageMap("hlp_limits"); + _defaultLimits = new StorageMap("hlp_deflim"); + if (Context.IsDeploying) + _admin.Set("owner", Convert.ToHexString(Context.Caller)); + } + + /// + /// Set the default holding limit for a token. Zero means no limit. + /// + [BasaltEntrypoint] + public void SetDefaultLimit(byte[] token, UInt256 maxBalance) + { + RequireAdmin(); + _defaultLimits.Set(Convert.ToHexString(token), maxBalance); + } + + /// + /// Set a per-address holding limit for a specific token. Zero means use default. + /// + [BasaltEntrypoint] + public void SetAddressLimit(byte[] token, byte[] account, UInt256 maxBalance) + { + RequireAdmin(); + _limits.Set(LimitKey(token, account), maxBalance); + } + + /// + /// Query the effective limit for an address on a token. + /// + [BasaltView] + public UInt256 GetEffectiveLimit(byte[] token, byte[] account) + { + var perAddr = _limits.Get(LimitKey(token, account)); + if (perAddr > 0) return perAddr; + return _defaultLimits.Get(Convert.ToHexString(token)); + } + + /// + /// ITransferPolicy implementation. Called by token contracts via cross-contract call. + /// Queries the recipient's balance on the token and checks against the limit. + /// + /// + /// This policy calls BalanceOf(byte[] account) → UInt256, which is the BST-20 + /// and BST-721 signature. Standards with different BalanceOf signatures (BST-1155 + /// uses BalanceOf(byte[], ulong), BST-3525 uses BalanceOf(ulong)) will + /// fail the cross-contract call and be denied conservatively. To enforce holding + /// limits on those standards, deploy a standard-specific policy variant. + /// + [BasaltView] + public bool CheckTransfer(byte[] token, byte[] from, byte[] to, UInt256 amount) + { + var limit = GetEffectiveLimit(token, to); + if (limit.IsZero) return true; // No limit configured + + // Query recipient's current balance on the token. + // If the call fails (e.g. incompatible BalanceOf signature), deny conservatively. + UInt256 currentBalance; + try + { + currentBalance = Context.CallContract(token, "BalanceOf", to); + } + catch + { + return false; + } + + // If adding would overflow UInt256, the balance obviously exceeds any limit. + if (!UInt256.TryAdd(currentBalance, amount, out var newBalance)) + return false; + + return newBalance <= limit; + } + + /// + /// Propose a new admin. The new admin must call AcceptAdmin to complete the transfer. + /// + [BasaltEntrypoint] + public void TransferAdmin(byte[] newAdmin) + { + RequireAdmin(); + Context.Require(newAdmin.Length == 20, "HoldingLimit: invalid address"); + _admin.Set("pending", Convert.ToHexString(newAdmin)); + } + + /// + /// Accept admin role. Must be called by the pending admin. + /// + [BasaltEntrypoint] + public void AcceptAdmin() + { + var pending = _admin.Get("pending"); + Context.Require(!string.IsNullOrEmpty(pending), "HoldingLimit: no pending admin"); + Context.Require(Convert.ToHexString(Context.Caller) == pending, "HoldingLimit: not pending admin"); + _admin.Set("owner", pending); + _admin.Delete("pending"); + } + + private void RequireAdmin() + { + Context.Require( + Convert.ToHexString(Context.Caller) == _admin.Get("owner"), + "HoldingLimit: not admin"); + } + + private static string LimitKey(byte[] token, byte[] account) => + Convert.ToHexString(token) + ":" + Convert.ToHexString(account); +} diff --git a/src/sdk/Basalt.Sdk.Contracts/Policies/ITransferPolicy.cs b/src/sdk/Basalt.Sdk.Contracts/Policies/ITransferPolicy.cs new file mode 100644 index 0000000..58f6dd6 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Contracts/Policies/ITransferPolicy.cs @@ -0,0 +1,73 @@ +using Basalt.Core; + +namespace Basalt.Sdk.Contracts.Policies; + +/// +/// Interface for transfer policy contracts. Deploy as a standalone BasaltContract +/// and register with any BST token to enforce compliance rules on every transfer. +/// +/// +/// Policies are invoked via cross-contract calls before each transfer. +/// Return true to allow the transfer, false to deny it. +/// The runtime reverts the entire transaction if any policy returns false. +/// +public interface ITransferPolicy +{ + /// + /// Called before a fungible token transfer (BST-20, BST-1155 single, BST-3525 value). + /// + /// Address of the token contract. + /// Sender address. + /// Recipient address. + /// Transfer amount. + /// True to allow, false to deny. + bool CheckTransfer(byte[] token, byte[] from, byte[] to, UInt256 amount); +} + +/// +/// Interface for NFT transfer policy contracts (BST-721, BST-3525 token ownership). +/// +public interface INftTransferPolicy +{ + /// + /// Called before an NFT ownership transfer. + /// + /// Address of the token contract. + /// Current owner address. + /// New owner address. + /// Token ID being transferred. + /// True to allow, false to deny. + bool CheckNftTransfer(byte[] token, byte[] from, byte[] to, ulong tokenId); +} + +/// +/// Event emitted when a policy is added to a token. +/// +[BasaltEvent] +public sealed class PolicyAddedEvent +{ + [Indexed] public byte[] Token { get; init; } = []; + [Indexed] public byte[] Policy { get; init; } = []; +} + +/// +/// Event emitted when a policy is removed from a token. +/// +[BasaltEvent] +public sealed class PolicyRemovedEvent +{ + [Indexed] public byte[] Token { get; init; } = []; + [Indexed] public byte[] Policy { get; init; } = []; +} + +/// +/// Event emitted when a transfer is denied by a policy. +/// +[BasaltEvent] +public sealed class TransferDeniedEvent +{ + [Indexed] public byte[] Token { get; init; } = []; + [Indexed] public byte[] Policy { get; init; } = []; + [Indexed] public byte[] From { get; init; } = []; + public byte[] To { get; init; } = []; +} diff --git a/src/sdk/Basalt.Sdk.Contracts/Policies/JurisdictionPolicy.cs b/src/sdk/Basalt.Sdk.Contracts/Policies/JurisdictionPolicy.cs new file mode 100644 index 0000000..e6f0f15 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Contracts/Policies/JurisdictionPolicy.cs @@ -0,0 +1,144 @@ +using Basalt.Core; + +namespace Basalt.Sdk.Contracts.Policies; + +/// +/// Policy contract that restricts transfers based on jurisdiction (country code). +/// Maintains a whitelist or blacklist of country codes per token. +/// Address-to-jurisdiction mappings are stored locally in this contract's storage +/// and managed by the admin via . +/// Type ID: 0x000A +/// +[BasaltContract] +public partial class JurisdictionPolicy : ITransferPolicy, INftTransferPolicy +{ + private readonly StorageMap _admin; + private readonly StorageMap _allowedJurisdictions; // "token:countryCode" -> allowed + private readonly StorageMap _addressJurisdictions; // address -> country code + private readonly StorageMap _useWhitelist; // token -> true=whitelist, false=blacklist + + public JurisdictionPolicy() + { + _admin = new StorageMap("jur_admin"); + _allowedJurisdictions = new StorageMap("jur_allowed"); + _addressJurisdictions = new StorageMap("jur_addr"); + _useWhitelist = new StorageMap("jur_mode"); + if (Context.IsDeploying) + _admin.Set("owner", Convert.ToHexString(Context.Caller)); + } + + /// + /// Set whether a token uses whitelist mode (true) or blacklist mode (false). + /// Whitelist: only listed jurisdictions are allowed. + /// Blacklist: listed jurisdictions are blocked, all others allowed. + /// + [BasaltEntrypoint] + public void SetMode(byte[] token, bool whitelist) + { + RequireAdmin(); + _useWhitelist.Set(Convert.ToHexString(token), whitelist); + } + + /// + /// Add or remove a jurisdiction for a token. + /// + [BasaltEntrypoint] + public void SetJurisdiction(byte[] token, ushort countryCode, bool allowed) + { + RequireAdmin(); + _allowedJurisdictions.Set(JurKey(token, countryCode), allowed); + } + + /// + /// Register an address's jurisdiction. Admin-only. + /// + [BasaltEntrypoint] + public void SetAddressJurisdiction(byte[] account, ushort countryCode) + { + RequireAdmin(); + _addressJurisdictions.Set(Convert.ToHexString(account), countryCode); + } + + /// + /// Query the jurisdiction of an address. + /// + [BasaltView] + public ushort GetAddressJurisdiction(byte[] account) + { + return _addressJurisdictions.Get(Convert.ToHexString(account)); + } + + [BasaltView] + public bool CheckTransfer(byte[] token, byte[] from, byte[] to, UInt256 amount) + { + var tokenHex = Convert.ToHexString(token); + + // Check sender jurisdiction + if (!CheckAddress(tokenHex, from)) return false; + + // Check recipient jurisdiction + if (!CheckAddress(tokenHex, to)) return false; + + return true; + } + + private bool CheckAddress(string tokenHex, byte[] account) + { + var country = _addressJurisdictions.Get(Convert.ToHexString(account)); + var isWhitelist = _useWhitelist.Get(tokenHex); + + // No jurisdiction registered: deny in whitelist mode (must be KYC'd), allow in blacklist mode + if (country == 0) return !isWhitelist; + + var isListed = _allowedJurisdictions.Get(JurKey(tokenHex, country)); + + // Whitelist: must be listed. Blacklist: must NOT be listed. + return isWhitelist ? isListed : !isListed; + } + + [BasaltView] + public bool CheckNftTransfer(byte[] token, byte[] from, byte[] to, ulong tokenId) + { + var tokenHex = Convert.ToHexString(token); + if (!CheckAddress(tokenHex, from)) return false; + if (!CheckAddress(tokenHex, to)) return false; + return true; + } + + /// + /// Propose a new admin. The new admin must call AcceptAdmin to complete the transfer. + /// + [BasaltEntrypoint] + public void TransferAdmin(byte[] newAdmin) + { + RequireAdmin(); + Context.Require(newAdmin.Length == 20, "Jurisdiction: invalid address"); + _admin.Set("pending", Convert.ToHexString(newAdmin)); + } + + /// + /// Accept admin role. Must be called by the pending admin. + /// + [BasaltEntrypoint] + public void AcceptAdmin() + { + var pending = _admin.Get("pending"); + Context.Require(!string.IsNullOrEmpty(pending), "Jurisdiction: no pending admin"); + Context.Require(Convert.ToHexString(Context.Caller) == pending, "Jurisdiction: not pending admin"); + _admin.Set("owner", pending); + _admin.Delete("pending"); + } + + private void RequireAdmin() + { + Context.Require( + Convert.ToHexString(Context.Caller) == _admin.Get("owner"), + "Jurisdiction: not admin"); + } + + private static string JurKey(byte[] token, ushort countryCode) => + Convert.ToHexString(token) + ":" + countryCode; + + private static string JurKey(string tokenHex, ushort countryCode) => + tokenHex + ":" + countryCode; +} diff --git a/src/sdk/Basalt.Sdk.Contracts/Policies/LockupPolicy.cs b/src/sdk/Basalt.Sdk.Contracts/Policies/LockupPolicy.cs new file mode 100644 index 0000000..58fddbe --- /dev/null +++ b/src/sdk/Basalt.Sdk.Contracts/Policies/LockupPolicy.cs @@ -0,0 +1,114 @@ +using Basalt.Core; + +namespace Basalt.Sdk.Contracts.Policies; + +/// +/// Policy contract that enforces time-based transfer lockups per address per token. +/// Tokens cannot be transferred until the lockup period expires (checked against block timestamp). +/// Type ID: 0x0009 +/// +[BasaltContract] +public partial class LockupPolicy : ITransferPolicy, INftTransferPolicy +{ + private readonly StorageMap _admin; + private readonly StorageMap _lockups; // "token:address" -> unlock timestamp + + public LockupPolicy() + { + _admin = new StorageMap("lkp_admin"); + _lockups = new StorageMap("lkp_locks"); + if (Context.IsDeploying) + _admin.Set("owner", Convert.ToHexString(Context.Caller)); + } + + /// + /// Set a lockup expiry for an address on a token. The address cannot send + /// tokens from this token contract until after the given timestamp (Unix seconds). + /// + [BasaltEntrypoint] + public void SetLockup(byte[] token, byte[] account, long unlockTimestamp) + { + RequireAdmin(); + Context.Require(unlockTimestamp > 0, "Lockup: invalid timestamp"); + _lockups.Set(LockKey(token, account), unlockTimestamp); + } + + /// + /// Remove a lockup for an address. + /// + [BasaltEntrypoint] + public void RemoveLockup(byte[] token, byte[] account) + { + RequireAdmin(); + _lockups.Delete(LockKey(token, account)); + } + + /// + /// Query the unlock timestamp for an address on a token. Returns 0 if no lockup. + /// + [BasaltView] + public long GetUnlockTime(byte[] token, byte[] account) + { + return _lockups.Get(LockKey(token, account)); + } + + /// + /// Check if an address is currently locked for a token. + /// + [BasaltView] + public bool IsLocked(byte[] token, byte[] account) + { + var unlock = _lockups.Get(LockKey(token, account)); + return unlock > 0 && Context.BlockTimestamp < unlock; + } + + [BasaltView] + public bool CheckTransfer(byte[] token, byte[] from, byte[] to, UInt256 amount) + { + var unlock = _lockups.Get(LockKey(token, from)); + if (unlock == 0) return true; // No lockup + return Context.BlockTimestamp >= unlock; + } + + [BasaltView] + public bool CheckNftTransfer(byte[] token, byte[] from, byte[] to, ulong tokenId) + { + var unlock = _lockups.Get(LockKey(token, from)); + if (unlock == 0) return true; + return Context.BlockTimestamp >= unlock; + } + + /// + /// Propose a new admin. The new admin must call AcceptAdmin to complete the transfer. + /// + [BasaltEntrypoint] + public void TransferAdmin(byte[] newAdmin) + { + RequireAdmin(); + Context.Require(newAdmin.Length == 20, "Lockup: invalid address"); + _admin.Set("pending", Convert.ToHexString(newAdmin)); + } + + /// + /// Accept admin role. Must be called by the pending admin. + /// + [BasaltEntrypoint] + public void AcceptAdmin() + { + var pending = _admin.Get("pending"); + Context.Require(!string.IsNullOrEmpty(pending), "Lockup: no pending admin"); + Context.Require(Convert.ToHexString(Context.Caller) == pending, "Lockup: not pending admin"); + _admin.Set("owner", pending); + _admin.Delete("pending"); + } + + private void RequireAdmin() + { + Context.Require( + Convert.ToHexString(Context.Caller) == _admin.Get("owner"), + "Lockup: not admin"); + } + + private static string LockKey(byte[] token, byte[] account) => + Convert.ToHexString(token) + ":" + Convert.ToHexString(account); +} diff --git a/src/sdk/Basalt.Sdk.Contracts/Policies/PolicyEnforcer.cs b/src/sdk/Basalt.Sdk.Contracts/Policies/PolicyEnforcer.cs new file mode 100644 index 0000000..2c61db1 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Contracts/Policies/PolicyEnforcer.cs @@ -0,0 +1,172 @@ +using Basalt.Core; + +namespace Basalt.Sdk.Contracts.Policies; + +/// +/// Helper that manages a storage-backed list of policy contract addresses +/// and enforces them via cross-contract calls before transfers. +/// Embed this in any BST token contract to add policy support. +/// +/// +/// Policies are called in registration order. A single denial reverts the transfer. +/// The admin address is stored externally by the owning token contract. +/// +public sealed class PolicyEnforcer +{ + private readonly StorageMap _policies; // index -> policy address hex + private readonly StorageMap _policyExists; // address hex -> registered + private readonly StorageValue _policyCount; + + public PolicyEnforcer(string storagePrefix = "pol") + { + _policies = new StorageMap($"{storagePrefix}_addr"); + _policyExists = new StorageMap($"{storagePrefix}_exists"); + _policyCount = new StorageValue($"{storagePrefix}_count"); + } + + /// + /// Number of registered policies. + /// + public ulong Count => _policyCount.Get(); + + /// + /// Get the policy address at a given index. + /// + public byte[] GetPolicy(ulong index) + { + Context.Require(index < Count, "Policy: index out of bounds"); + var hex = _policies.Get(index.ToString()); + Context.Require(!string.IsNullOrEmpty(hex), "Policy: corrupted policy slot"); + return Convert.FromHexString(hex); + } + + /// + /// Maximum number of policies that can be registered on a single token. + /// + public const ulong MaxPolicies = 16; + + /// + /// Add a policy contract address. Caller must verify admin access. + /// + public void AddPolicy(byte[] policyAddress) + { + Context.Require(policyAddress.Length == 20, "Policy: invalid address"); + var hex = Convert.ToHexString(policyAddress); + + Context.Require(!_policyExists.Get(hex), "Policy: already registered"); + + var count = Count; + Context.Require(count < MaxPolicies, "Policy: max policy count reached"); + + _policies.Set(count.ToString(), hex); + _policyExists.Set(hex, true); + _policyCount.Set(count + 1); + + Context.Emit(new PolicyAddedEvent + { + Token = Context.Self, + Policy = policyAddress, + }); + } + + /// + /// Remove a policy contract address by shifting remaining entries. Caller must verify admin access. + /// + public void RemovePolicy(byte[] policyAddress) + { + var hex = Convert.ToHexString(policyAddress); + var count = Count; + bool found = false; + + for (ulong i = 0; i < count; i++) + { + if (!found && _policies.Get(i.ToString()) == hex) + { + found = true; + } + + // Shift entries left after the removed one + if (found && i + 1 < count) + { + _policies.Set(i.ToString(), _policies.Get((i + 1).ToString())); + } + } + + Context.Require(found, "Policy: not registered"); + _policies.Delete((count - 1).ToString()); + _policyExists.Delete(hex); + _policyCount.Set(count - 1); + + Context.Emit(new PolicyRemovedEvent + { + Token = Context.Self, + Policy = policyAddress, + }); + } + + /// + /// Enforce all registered policies for a fungible transfer. + /// Reverts if any policy denies the transfer. + /// + public void EnforceTransfer(byte[] from, byte[] to, UInt256 amount) + { + var count = Count; + if (count == 0) return; + + var token = Context.Self; + for (ulong i = 0; i < count; i++) + { + var policyHex = _policies.Get(i.ToString()); + Context.Require(!string.IsNullOrEmpty(policyHex), "Policy: corrupted policy slot"); + + var policyAddr = Convert.FromHexString(policyHex); + var allowed = Context.CallContract( + policyAddr, "CheckTransfer", token, from, to, amount); + + if (!allowed) + { + Context.Emit(new TransferDeniedEvent + { + Token = token, + Policy = policyAddr, + From = from, + To = to, + }); + Context.Revert("Policy: transfer denied by " + policyHex); + } + } + } + + /// + /// Enforce all registered policies for an NFT transfer. + /// Reverts if any policy denies the transfer. + /// + public void EnforceNftTransfer(byte[] from, byte[] to, ulong tokenId) + { + var count = Count; + if (count == 0) return; + + var token = Context.Self; + for (ulong i = 0; i < count; i++) + { + var policyHex = _policies.Get(i.ToString()); + Context.Require(!string.IsNullOrEmpty(policyHex), "Policy: corrupted policy slot"); + + var policyAddr = Convert.FromHexString(policyHex); + var allowed = Context.CallContract( + policyAddr, "CheckNftTransfer", token, from, to, tokenId); + + if (!allowed) + { + Context.Emit(new TransferDeniedEvent + { + Token = token, + Policy = policyAddr, + From = from, + To = to, + }); + Context.Revert("Policy: NFT transfer denied by " + policyHex); + } + } + } +} diff --git a/src/sdk/Basalt.Sdk.Contracts/Policies/SanctionsPolicy.cs b/src/sdk/Basalt.Sdk.Contracts/Policies/SanctionsPolicy.cs new file mode 100644 index 0000000..3c26a64 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Contracts/Policies/SanctionsPolicy.cs @@ -0,0 +1,90 @@ +using Basalt.Core; + +namespace Basalt.Sdk.Contracts.Policies; + +/// +/// Policy contract that maintains an on-chain sanctions list. +/// Denies transfers where either sender or receiver is sanctioned. +/// Type ID: 0x000B +/// +[BasaltContract] +public partial class SanctionsPolicy : ITransferPolicy, INftTransferPolicy +{ + private readonly StorageMap _admin; + private readonly StorageMap _sanctioned; + + public SanctionsPolicy() + { + _admin = new StorageMap("san_admin"); + _sanctioned = new StorageMap("san_list"); + if (Context.IsDeploying) + _admin.Set("owner", Convert.ToHexString(Context.Caller)); + } + + [BasaltEntrypoint] + public void AddSanction(byte[] account) + { + RequireAdmin(); + _sanctioned.Set(Convert.ToHexString(account), true); + } + + [BasaltEntrypoint] + public void RemoveSanction(byte[] account) + { + RequireAdmin(); + _sanctioned.Delete(Convert.ToHexString(account)); + } + + [BasaltView] + public bool IsSanctioned(byte[] account) + { + return _sanctioned.Get(Convert.ToHexString(account)); + } + + [BasaltView] + public bool CheckTransfer(byte[] token, byte[] from, byte[] to, UInt256 amount) + { + if (_sanctioned.Get(Convert.ToHexString(from))) return false; + if (_sanctioned.Get(Convert.ToHexString(to))) return false; + return true; + } + + [BasaltView] + public bool CheckNftTransfer(byte[] token, byte[] from, byte[] to, ulong tokenId) + { + if (_sanctioned.Get(Convert.ToHexString(from))) return false; + if (_sanctioned.Get(Convert.ToHexString(to))) return false; + return true; + } + + /// + /// Propose a new admin. The new admin must call AcceptAdmin to complete the transfer. + /// + [BasaltEntrypoint] + public void TransferAdmin(byte[] newAdmin) + { + RequireAdmin(); + Context.Require(newAdmin.Length == 20, "Sanctions: invalid address"); + _admin.Set("pending", Convert.ToHexString(newAdmin)); + } + + /// + /// Accept admin role. Must be called by the pending admin. + /// + [BasaltEntrypoint] + public void AcceptAdmin() + { + var pending = _admin.Get("pending"); + Context.Require(!string.IsNullOrEmpty(pending), "Sanctions: no pending admin"); + Context.Require(Convert.ToHexString(Context.Caller) == pending, "Sanctions: not pending admin"); + _admin.Set("owner", pending); + _admin.Delete("pending"); + } + + private void RequireAdmin() + { + Context.Require( + Convert.ToHexString(Context.Caller) == _admin.Get("owner"), + "Sanctions: not admin"); + } +} diff --git a/src/sdk/Basalt.Sdk.Contracts/README.md b/src/sdk/Basalt.Sdk.Contracts/README.md index 68d2d37..9076f7e 100644 --- a/src/sdk/Basalt.Sdk.Contracts/README.md +++ b/src/sdk/Basalt.Sdk.Contracts/README.md @@ -111,6 +111,8 @@ Access blockchain state from within contracts via `Context`: | `Context.BlockHeight` | `ulong` | Current block number | | `Context.ChainId` | `uint` | Chain identifier | | `Context.GasRemaining` | `ulong` | Remaining gas | +| `Context.IsDeploying` | `bool` | True during first-time contract deployment (check before one-time side effects) | +| `Context.IsStaticCall` | `bool` | True during read-only re-entrant callbacks (storage writes blocked) | | `Context.CallDepth` | `int` | Current call depth (0 = top-level call) | | `Context.MaxCallDepth` | `int` | Maximum cross-contract call depth (const `8`) | | `Context.ReentrancyGuard` | `HashSet` | Set of contract addresses currently on the call stack | @@ -402,6 +404,82 @@ bool supports = issuerRegistry.SupportsSchema(issuerAddr, schemaIdBytes); **Events**: `IssuerRegisteredEvent`, `CollateralStakedEvent`, `RevocationRootUpdatedEvent`, `IssuerSlashedEvent`, `IssuerDeactivatedEvent`, `IssuerReactivatedEvent`, `AdminTransferredEvent`. +## Policy Hooks (`Policies/`) + +The SDK provides a standardized policy enforcement layer for all token standards. Deploy modular compliance policies as independent contracts, then register them on any BST token. All transfers are automatically checked against registered policies before execution -- zero policies means near-zero overhead (single storage read). + +### Architecture + +``` +BST-20/721/1155/3525/4626 Token + | + +-- PolicyEnforcer (storage-backed list of policy addresses) + | + +-- Policy A (ITransferPolicy) -- cross-contract call: CheckTransfer() + +-- Policy B (ITransferPolicy) -- cross-contract call: CheckTransfer() + +-- Policy C (INftTransferPolicy) -- cross-contract call: CheckNftTransfer() +``` + +### Interfaces + +- **`ITransferPolicy`** -- `CheckTransfer(byte[] token, byte[] from, byte[] to, UInt256 amount)` returns `bool` +- **`INftTransferPolicy`** -- `CheckNftTransfer(byte[] token, byte[] from, byte[] to, ulong tokenId)` returns `bool` + +### Reference Policies + +| Contract | Type ID | Description | +|----------|---------|-------------| +| `HoldingLimitPolicy` | `0x0008` | Max balance per address per token. Queries `BalanceOf()` via cross-contract call. | +| `LockupPolicy` | `0x0009` | Time-based transfer lockup. Checks `Context.BlockTimestamp` against per-address unlock time. | +| `JurisdictionPolicy` | `0x000A` | Country whitelist/blacklist. Maps addresses to ISO 3166 country codes. | +| `SanctionsPolicy` | `0x000B` | Admin-managed sanctions list. Denies transfers involving sanctioned addresses. | + +### Policy Management on Tokens + +Every BST token (BST-20, BST-721, BST-1155, BST-3525, BST-4626) exposes four policy entrypoints: + +```csharp +token.AddPolicy(byte[] policyAddress); // Admin only -- register a policy +token.RemovePolicy(byte[] policyAddress); // Admin only -- unregister a policy +token.PolicyCount(); // View -- number of registered policies +token.GetPolicyAt(ulong index); // View -- policy address at index +``` + +### Usage Example + +```csharp +// Deploy token and policy +var token = new BST20Token("RegulatedToken", "RSEC", 18, new UInt256(1_000_000)); +var sanctions = new SanctionsPolicy(); + +// Configure and register +sanctions.AddSanction(badActor); +token.AddPolicy(sanctionsAddress); + +// All transfers are now enforced -- sanctioned addresses are blocked +token.Transfer(bob, new UInt256(1000)); // OK +token.Transfer(badActor, new UInt256(1000)); // Reverts: "transfer denied by policy" +``` + +### Admin Transfer + +All policy contracts support two-step admin transfer to prevent accidental lockout: + +```csharp +policy.TransferAdmin(newAdminAddr); // Current admin proposes +policy.AcceptAdmin(); // New admin accepts (must be called by pending admin) +``` + +### Design Notes + +- **Max 16 policies** per token (`PolicyEnforcer.MaxPolicies`) -- prevents unbounded gas cost +- **Mint/burn bypass policies** -- admin-only operations, matching ERC-20 convention +- **BST-4626 inherits BST-20** -- vault share policies propagate automatically +- **BST-1155 batch atomicity** -- all policy checks run before any state mutations +- **Reentrancy safe** -- policies can query token state (e.g. `BalanceOf`) via static-mode re-entrant callbacks (`Context.IsStaticCall` blocks writes) +- **Corrupted slot detection** -- PolicyEnforcer reverts on empty policy slots instead of silently skipping +- **Jurisdiction whitelist mode** -- unregistered addresses (no KYC) are denied in whitelist mode, allowed in blacklist mode + ## ContractRegistry Type IDs All contract types are registered with `ContractRegistry.CreateDefault()` and identified by a 2-byte type ID in the deployment manifest (`[0xBA, 0x5A][typeId BE][ctor args]`). @@ -418,6 +496,15 @@ All contract types are registered with `ContractRegistry.CreateDefault()` and id | `0x0006` | `BST4626Vault` | Tokenized vault (ERC-4626) | | `0x0007` | `BSTVCRegistry` | Verifiable credentials (W3C VC) | +### Policy Contracts + +| Type ID | Contract | Description | +|---------|----------|-------------| +| `0x0008` | `HoldingLimitPolicy` | Max balance per address per token | +| `0x0009` | `LockupPolicy` | Time-based transfer lockup | +| `0x000A` | `JurisdictionPolicy` | Country whitelist/blacklist | +| `0x000B` | `SanctionsPolicy` | Admin-managed sanctions list | + ### System Contracts | Type ID | Contract | Genesis Address | diff --git a/src/sdk/Basalt.Sdk.Contracts/Standards/BST1155Token.cs b/src/sdk/Basalt.Sdk.Contracts/Standards/BST1155Token.cs index c1c5378..b921cae 100644 --- a/src/sdk/Basalt.Sdk.Contracts/Standards/BST1155Token.cs +++ b/src/sdk/Basalt.Sdk.Contracts/Standards/BST1155Token.cs @@ -1,4 +1,5 @@ using Basalt.Core; +using Basalt.Sdk.Contracts.Policies; namespace Basalt.Sdk.Contracts.Standards; @@ -13,6 +14,7 @@ public partial class BST1155Token : IBST1155 private readonly StorageMap _tokenURIs; // tokenId -> uri private readonly StorageValue _nextTokenId; private readonly StorageMap _contractAdmin; + private readonly PolicyEnforcer _policyEnforcer; private readonly string _baseUri; public BST1155Token(string baseUri) @@ -23,6 +25,7 @@ public BST1155Token(string baseUri) _tokenURIs = new StorageMap("m_uris"); _nextTokenId = new StorageValue("m_next_id"); _contractAdmin = new StorageMap("m_admin"); + _policyEnforcer = new PolicyEnforcer("m_pol"); if (Context.IsDeploying) _contractAdmin.Set("owner", Convert.ToHexString(Context.Caller)); } @@ -51,6 +54,9 @@ public void SafeTransferFrom(byte[] from, byte[] to, ulong tokenId, UInt256 amou IsCallerOrApproved(from, caller), "BST1155: caller is not owner or approved"); + // Policy enforcement before any state mutation + _policyEnforcer.EnforceTransfer(from, to, amount); + var fromBalance = _balances.Get(BalanceKey(from, tokenId)); Context.Require(fromBalance >= amount, "BST1155: insufficient balance"); @@ -80,6 +86,12 @@ public void SafeBatchTransferFrom(byte[] from, byte[] to, ulong[] tokenIds, ulon IsCallerOrApproved(from, caller), "BST1155: caller is not owner or approved"); + // Policy enforcement: check all items before any state mutations + for (int i = 0; i < tokenIds.Length; i++) + { + _policyEnforcer.EnforceTransfer(from, to, (UInt256)amounts[i]); + } + for (int i = 0; i < tokenIds.Length; i++) { UInt256 amount = amounts[i]; @@ -171,6 +183,30 @@ public ulong Create(byte[] to, UInt256 initialSupply, string uri) return tokenId; } + // --- Policy Management --- + + [BasaltEntrypoint] + public void AddPolicy(byte[] policyAddress) + { + Context.Require(Convert.ToHexString(Context.Caller) == _contractAdmin.Get("owner"), "BST1155: not owner"); + _policyEnforcer.AddPolicy(policyAddress); + } + + [BasaltEntrypoint] + public void RemovePolicy(byte[] policyAddress) + { + Context.Require(Convert.ToHexString(Context.Caller) == _contractAdmin.Get("owner"), "BST1155: not owner"); + _policyEnforcer.RemovePolicy(policyAddress); + } + + [BasaltView] + public ulong PolicyCount() => _policyEnforcer.Count; + + [BasaltView] + public byte[] GetPolicyAt(ulong index) => _policyEnforcer.GetPolicy(index); + + // --- Internal --- + private bool IsCallerOrApproved(byte[] owner, byte[] caller) { if (owner.AsSpan().SequenceEqual(caller)) diff --git a/src/sdk/Basalt.Sdk.Contracts/Standards/BST20Token.cs b/src/sdk/Basalt.Sdk.Contracts/Standards/BST20Token.cs index c7b1770..dca3ccf 100644 --- a/src/sdk/Basalt.Sdk.Contracts/Standards/BST20Token.cs +++ b/src/sdk/Basalt.Sdk.Contracts/Standards/BST20Token.cs @@ -1,4 +1,5 @@ using Basalt.Core; +using Basalt.Sdk.Contracts.Policies; namespace Basalt.Sdk.Contracts.Standards; @@ -12,6 +13,8 @@ public partial class BST20Token : IBST20 private readonly StorageValue _totalSupply; private readonly StorageMap _balances; private readonly StorageMap _allowances; + private readonly StorageMap _tokenAdmin; + private readonly PolicyEnforcer _policyEnforcer; private readonly string _name; private readonly string _symbol; private readonly byte _decimals; @@ -24,6 +27,13 @@ public BST20Token(string name, string symbol, byte decimals = 18, UInt256 initia _totalSupply = new StorageValue("total_supply"); _balances = new StorageMap("balances"); _allowances = new StorageMap("allowances"); + _tokenAdmin = new StorageMap("bst20_admin"); + _policyEnforcer = new PolicyEnforcer("bst20_pol"); + + if (Context.IsDeploying) + { + _tokenAdmin.Set("owner", Convert.ToHexString(Context.Caller)); + } if (!initialSupply.IsZero && Context.IsDeploying) { @@ -176,10 +186,48 @@ protected void Burn(byte[] from, UInt256 amount) }); } + // --- Policy Management --- + + /// + /// Register a policy contract. Admin-only. + /// + [BasaltEntrypoint] + public void AddPolicy(byte[] policyAddress) + { + Context.Require( + Convert.ToHexString(Context.Caller) == _tokenAdmin.Get("owner"), + "BST20: not admin"); + _policyEnforcer.AddPolicy(policyAddress); + } + + /// + /// Remove a policy contract. Admin-only. + /// + [BasaltEntrypoint] + public void RemovePolicy(byte[] policyAddress) + { + Context.Require( + Convert.ToHexString(Context.Caller) == _tokenAdmin.Get("owner"), + "BST20: not admin"); + _policyEnforcer.RemovePolicy(policyAddress); + } + + [BasaltView] + public ulong PolicyCount() => _policyEnforcer.Count; + + [BasaltView] + public byte[] GetPolicyAt(ulong index) => _policyEnforcer.GetPolicy(index); + + // --- Internal --- + private bool TransferInternal(byte[] from, byte[] to, UInt256 amount) { // L-1: Reject zero-amount transfers to prevent event spam Context.Require(!amount.IsZero, "BST20: zero amount"); + + // Policy enforcement before any state mutation + _policyEnforcer.EnforceTransfer(from, to, amount); + var fromBalance = _balances.Get(ToKey(from)); Context.Require(fromBalance >= amount, "BST20: insufficient balance"); diff --git a/src/sdk/Basalt.Sdk.Contracts/Standards/BST3525Token.cs b/src/sdk/Basalt.Sdk.Contracts/Standards/BST3525Token.cs index b1a6f9e..d6efeb6 100644 --- a/src/sdk/Basalt.Sdk.Contracts/Standards/BST3525Token.cs +++ b/src/sdk/Basalt.Sdk.Contracts/Standards/BST3525Token.cs @@ -1,4 +1,5 @@ using Basalt.Core; +using Basalt.Sdk.Contracts.Policies; namespace Basalt.Sdk.Contracts.Standards; @@ -24,6 +25,7 @@ public partial class BST3525Token : IBST3525 private readonly StorageMap _slotUris; private readonly StorageMap _tokenUris; private readonly StorageMap _contractAdmin; + private readonly PolicyEnforcer _policyEnforcer; public BST3525Token(string name, string symbol, byte valueDecimals = 0) { @@ -40,6 +42,7 @@ public BST3525Token(string name, string symbol, byte valueDecimals = 0) _slotUris = new StorageMap("sft_suri"); _tokenUris = new StorageMap("sft_turi"); _contractAdmin = new StorageMap("sft_admin"); + _policyEnforcer = new PolicyEnforcer("sft_pol"); if (Context.IsDeploying) _contractAdmin.Set("owner", Convert.ToHexString(Context.Caller)); } @@ -121,6 +124,11 @@ public void TransferValueToId(ulong fromId, ulong toId, UInt256 value) RequireValueAuthorized(fromId, value); + // Policy enforcement: check value transfer between token owners + var fromOwner = Convert.FromHexString(_tokenOwners.Get(TokenKey(fromId))); + var toOwner = Convert.FromHexString(_tokenOwners.Get(TokenKey(toId))); + _policyEnforcer.EnforceTransfer(fromOwner, toOwner, value); + var fromVal = _tokenValues.Get(TokenKey(fromId)); Context.Require(fromVal >= value, "SFT: insufficient value"); _tokenValues.Set(TokenKey(fromId), fromVal - value); @@ -145,6 +153,10 @@ public ulong TransferValueToAddress(ulong fromId, byte[] to, UInt256 value) RequireTokenExists(fromId); RequireValueAuthorized(fromId, value); + // Policy enforcement + var fromOwner = Convert.FromHexString(_tokenOwners.Get(TokenKey(fromId))); + _policyEnforcer.EnforceTransfer(fromOwner, to, value); + var fromVal = _tokenValues.Get(TokenKey(fromId)); Context.Require(fromVal >= value, "SFT: insufficient value"); @@ -194,6 +206,9 @@ public void TransferToken(byte[] to, ulong tokenId) var toHex = AddrKey(to); var from = Convert.FromHexString(ownerHex); + // Policy enforcement before state mutation + _policyEnforcer.EnforceNftTransfer(from, to, tokenId); + // Update ownership _tokenOwners.Set(TokenKey(tokenId), toHex); @@ -242,6 +257,28 @@ public void SetTokenUri(ulong tokenId, string uri) _tokenUris.Set(TokenKey(tokenId), uri); } + // --- Policy Management --- + + [BasaltEntrypoint] + public void AddPolicy(byte[] policyAddress) + { + Context.Require(Convert.ToHexString(Context.Caller) == _contractAdmin.Get("owner"), "SFT: not owner"); + _policyEnforcer.AddPolicy(policyAddress); + } + + [BasaltEntrypoint] + public void RemovePolicy(byte[] policyAddress) + { + Context.Require(Convert.ToHexString(Context.Caller) == _contractAdmin.Get("owner"), "SFT: not owner"); + _policyEnforcer.RemovePolicy(policyAddress); + } + + [BasaltView] + public ulong PolicyCount() => _policyEnforcer.Count; + + [BasaltView] + public byte[] GetPolicyAt(ulong index) => _policyEnforcer.GetPolicy(index); + // --- Internal helpers --- private ulong MintInternal(byte[] to, ulong slot, UInt256 value) diff --git a/src/sdk/Basalt.Sdk.Contracts/Standards/BST721Token.cs b/src/sdk/Basalt.Sdk.Contracts/Standards/BST721Token.cs index aa3d5c3..42b2d81 100644 --- a/src/sdk/Basalt.Sdk.Contracts/Standards/BST721Token.cs +++ b/src/sdk/Basalt.Sdk.Contracts/Standards/BST721Token.cs @@ -1,3 +1,5 @@ +using Basalt.Sdk.Contracts.Policies; + namespace Basalt.Sdk.Contracts.Standards; /// @@ -13,6 +15,7 @@ public partial class BST721Token : IBST721 private readonly StorageMap _operatorApprovals; // "owner:operator" -> approved private readonly StorageValue _nextTokenId; private readonly StorageMap _contractAdmin; + private readonly PolicyEnforcer _policyEnforcer; private readonly string _name; private readonly string _symbol; @@ -27,6 +30,7 @@ public BST721Token(string name, string symbol) _operatorApprovals = new StorageMap("nft_ops"); _nextTokenId = new StorageValue("nft_next_id"); _contractAdmin = new StorageMap("nft_admin"); + _policyEnforcer = new PolicyEnforcer("nft_pol"); if (Context.IsDeploying) _contractAdmin.Set("owner", AddressKey(Context.Caller)); } @@ -146,8 +150,35 @@ public ulong Mint(byte[] to, string tokenUri) return tokenId; } + // --- Policy Management --- + + [BasaltEntrypoint] + public void AddPolicy(byte[] policyAddress) + { + Context.Require(AddressKey(Context.Caller) == _contractAdmin.Get("owner"), "BST721: not owner"); + _policyEnforcer.AddPolicy(policyAddress); + } + + [BasaltEntrypoint] + public void RemovePolicy(byte[] policyAddress) + { + Context.Require(AddressKey(Context.Caller) == _contractAdmin.Get("owner"), "BST721: not owner"); + _policyEnforcer.RemovePolicy(policyAddress); + } + + [BasaltView] + public ulong PolicyCount() => _policyEnforcer.Count; + + [BasaltView] + public byte[] GetPolicyAt(ulong index) => _policyEnforcer.GetPolicy(index); + + // --- Internal --- + private void TransferInternal(byte[] from, byte[] to, ulong tokenId) { + // Policy enforcement before any state mutation + _policyEnforcer.EnforceNftTransfer(from, to, tokenId); + // Clear approval _approvals.Delete(TokenKey(tokenId)); diff --git a/src/sdk/Basalt.Sdk.Contracts/Storage.cs b/src/sdk/Basalt.Sdk.Contracts/Storage.cs index 464a29e..a584106 100644 --- a/src/sdk/Basalt.Sdk.Contracts/Storage.cs +++ b/src/sdk/Basalt.Sdk.Contracts/Storage.cs @@ -91,16 +91,28 @@ public static void ResetProvider() /// public static IStorageProvider Provider => _provider; - public static void Set(string key, object? value) => _provider.Set(key, value); + public static void Set(string key, object? value) + { + if (Context.IsStaticCall) + throw new ContractRevertException("Static call: state modification not allowed"); + _provider.Set(key, value); + } public static T Get(string key) => _provider.Get(key); public static bool ContainsKey(string key) => _provider.ContainsKey(key); - public static void Delete(string key) => _provider.Delete(key); + public static void Delete(string key) + { + if (Context.IsStaticCall) + throw new ContractRevertException("Static call: state modification not allowed"); + _provider.Delete(key); + } public static void Clear() { + if (Context.IsStaticCall) + throw new ContractRevertException("Static call: state modification not allowed"); if (_provider is InMemoryStorageProvider mem) mem.Clear(); else diff --git a/src/sdk/Basalt.Sdk.Wallet/BasaltProvider.cs b/src/sdk/Basalt.Sdk.Wallet/BasaltProvider.cs index 4f92f21..89c76ca 100644 --- a/src/sdk/Basalt.Sdk.Wallet/BasaltProvider.cs +++ b/src/sdk/Basalt.Sdk.Wallet/BasaltProvider.cs @@ -177,6 +177,27 @@ public Task CallReadOnlyAsync( return _client.CallReadOnlyAsync(toHex, dataHex, fromHex, gasLimit, ct); } + /// + /// Gets a DEX swap quote for the given token pair and amount. + /// Returns null if no pool exists for this pair. + /// + /// The input token address. + /// The output token address. + /// The input amount. + /// Optional fee tier. If null, selects the best pool. + /// Cancellation token. + public Task GetDexQuoteAsync( + Address tokenIn, + Address tokenOut, + UInt256 amountIn, + uint? feeBps = null, + CancellationToken ct = default) + { + var tokenInHex = "0x" + Convert.ToHexString(tokenIn.ToArray()).ToLowerInvariant(); + var tokenOutHex = "0x" + Convert.ToHexString(tokenOut.ToArray()).ToLowerInvariant(); + return _client.GetDexQuoteAsync(tokenInHex, tokenOutHex, amountIn.ToString(), feeBps, ct); + } + // ── Transaction Methods ──────────────────────────────────────────── /// diff --git a/src/sdk/Basalt.Sdk.Wallet/Rpc/BasaltClient.cs b/src/sdk/Basalt.Sdk.Wallet/Rpc/BasaltClient.cs index c9e3e7a..40ca140 100644 --- a/src/sdk/Basalt.Sdk.Wallet/Rpc/BasaltClient.cs +++ b/src/sdk/Basalt.Sdk.Wallet/Rpc/BasaltClient.cs @@ -178,6 +178,23 @@ public async Task GetValidatorsAsync(CancellationToken ct = def return await JsonSerializer.DeserializeAsync(stream, WalletJsonContext.Default.ValidatorInfoArray, ct).ConfigureAwait(false) ?? []; } + /// + public async Task GetDexQuoteAsync(string tokenIn, string tokenOut, string amountIn, uint? feeBps = null, CancellationToken ct = default) + { + var url = $"/v1/dex/quote?tokenIn={Uri.EscapeDataString(tokenIn)}&tokenOut={Uri.EscapeDataString(tokenOut)}&amountIn={Uri.EscapeDataString(amountIn)}"; + if (feeBps.HasValue) + url += $"&feeBps={feeBps.Value}"; + + var response = await _http.GetAsync(url, ct).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + response.EnsureSuccessStatusCode(); + var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, WalletJsonContext.Default.DexQuoteInfo, ct).ConfigureAwait(false); + } + /// public async Task CallReadOnlyAsync(string to, string data, string? from = null, ulong gasLimit = 1_000_000, CancellationToken ct = default) { diff --git a/src/sdk/Basalt.Sdk.Wallet/Rpc/IBasaltClient.cs b/src/sdk/Basalt.Sdk.Wallet/Rpc/IBasaltClient.cs index 7782227..b4f2000 100644 --- a/src/sdk/Basalt.Sdk.Wallet/Rpc/IBasaltClient.cs +++ b/src/sdk/Basalt.Sdk.Wallet/Rpc/IBasaltClient.cs @@ -81,6 +81,18 @@ public interface IBasaltClient : IDisposable /// An array of validator information. Task GetValidatorsAsync(CancellationToken ct = default); + /// + /// Retrieves a DEX swap quote showing expected output, fees, and price impact. + /// Returns null if no pool exists for the given token pair. + /// + /// The input token address in "0x..." hex format. + /// The output token address in "0x..." hex format. + /// The input amount as a decimal string (UInt256). + /// Optional fee tier in basis points. If null, selects the best pool. + /// Cancellation token. + /// Quote information, or null if no pool found. + Task GetDexQuoteAsync(string tokenIn, string tokenOut, string amountIn, uint? feeBps = null, CancellationToken ct = default); + /// /// Executes a read-only contract call without submitting a transaction. /// Equivalent to eth_call — executes against current state and returns the result. diff --git a/src/sdk/Basalt.Sdk.Wallet/Rpc/Models/DexQuoteInfo.cs b/src/sdk/Basalt.Sdk.Wallet/Rpc/Models/DexQuoteInfo.cs new file mode 100644 index 0000000..f43838c --- /dev/null +++ b/src/sdk/Basalt.Sdk.Wallet/Rpc/Models/DexQuoteInfo.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Basalt.Sdk.Wallet.Rpc.Models; + +/// +/// DEX swap quote information returned by the quote endpoint. +/// +public sealed class DexQuoteInfo +{ + [JsonPropertyName("poolId")] public ulong PoolId { get; set; } + [JsonPropertyName("tokenIn")] public string TokenIn { get; set; } = ""; + [JsonPropertyName("tokenOut")] public string TokenOut { get; set; } = ""; + [JsonPropertyName("amountIn")] public string AmountIn { get; set; } = "0"; + [JsonPropertyName("amountOut")] public string AmountOut { get; set; } = "0"; + [JsonPropertyName("effectiveFeeBps")] public uint EffectiveFeeBps { get; set; } + [JsonPropertyName("priceImpactBps")] public uint PriceImpactBps { get; set; } + [JsonPropertyName("spotPrice")] public string SpotPrice { get; set; } = "0"; + [JsonPropertyName("twap")] public string Twap { get; set; } = "0"; + [JsonPropertyName("volatilityBps")] public uint VolatilityBps { get; set; } + [JsonPropertyName("isConcentrated")] public bool IsConcentrated { get; set; } +} diff --git a/src/sdk/Basalt.Sdk.Wallet/Rpc/WalletJsonContext.cs b/src/sdk/Basalt.Sdk.Wallet/Rpc/WalletJsonContext.cs index 6b8f6c0..77d01ab 100644 --- a/src/sdk/Basalt.Sdk.Wallet/Rpc/WalletJsonContext.cs +++ b/src/sdk/Basalt.Sdk.Wallet/Rpc/WalletJsonContext.cs @@ -132,4 +132,5 @@ public sealed class CallReadOnlyRequest [JsonSerializable(typeof(FaucetRequest))] [JsonSerializable(typeof(CallReadOnlyRequest))] [JsonSerializable(typeof(CallResult))] +[JsonSerializable(typeof(DexQuoteInfo))] internal partial class WalletJsonContext : JsonSerializerContext; diff --git a/src/sdk/Basalt.Sdk.Wallet/Transactions/DexSwapIntentBuilder.cs b/src/sdk/Basalt.Sdk.Wallet/Transactions/DexSwapIntentBuilder.cs new file mode 100644 index 0000000..7374357 --- /dev/null +++ b/src/sdk/Basalt.Sdk.Wallet/Transactions/DexSwapIntentBuilder.cs @@ -0,0 +1,177 @@ +using System.Buffers.Binary; +using Basalt.Core; +using Basalt.Execution; +using Basalt.Execution.Dex; + +namespace Basalt.Sdk.Wallet.Transactions; + +/// +/// Fluent builder for DEX swap intent transactions (plaintext and encrypted). +/// Encodes the 114-byte intent payload matching . +/// +public sealed class DexSwapIntentBuilder +{ + private readonly TransactionBuilder _inner; + private readonly bool _encrypted; + private BlsPublicKey _groupPublicKey; + private ulong _epoch; + + private Address _tokenIn; + private Address _tokenOut; + private UInt256 _amountIn; + private UInt256 _minAmountOut; + private ulong _deadline; + private bool _allowPartialFill; + + private DexSwapIntentBuilder(TransactionBuilder inner, bool encrypted) + { + _inner = inner; + _encrypted = encrypted; + _inner.WithGasLimit(80_000); + _inner.WithTo(DexState.DexAddress); + } + + /// + /// Creates a builder for a plaintext swap intent (type 10). + /// + public static DexSwapIntentBuilder Create() + { + return new DexSwapIntentBuilder(TransactionBuilder.DexSwapIntent(), encrypted: false); + } + + /// + /// Creates a builder for an encrypted swap intent (type 18). + /// The intent payload is encrypted with the DKG group public key for MEV protection. + /// + /// The DKG group public key (48-byte compressed G1 point). + /// The DKG epoch number. + public static DexSwapIntentBuilder CreateEncrypted(BlsPublicKey groupPublicKey, ulong epoch) + { + var builder = new DexSwapIntentBuilder(TransactionBuilder.DexEncryptedSwapIntent(), encrypted: true); + builder._groupPublicKey = groupPublicKey; + builder._epoch = epoch; + return builder; + } + + /// Sets the input token address. + public DexSwapIntentBuilder WithTokenIn(Address tokenIn) + { + _tokenIn = tokenIn; + return this; + } + + /// Sets the output token address. + public DexSwapIntentBuilder WithTokenOut(Address tokenOut) + { + _tokenOut = tokenOut; + return this; + } + + /// Sets the input amount. + public DexSwapIntentBuilder WithAmountIn(UInt256 amountIn) + { + _amountIn = amountIn; + return this; + } + + /// Sets the minimum acceptable output amount (slippage protection). + public DexSwapIntentBuilder WithMinAmountOut(UInt256 minAmountOut) + { + _minAmountOut = minAmountOut; + return this; + } + + /// Sets the block deadline (0 = no deadline). + public DexSwapIntentBuilder WithDeadline(ulong deadline) + { + _deadline = deadline; + return this; + } + + /// Sets whether partial fills are allowed. + public DexSwapIntentBuilder WithAllowPartialFill(bool allowPartialFill) + { + _allowPartialFill = allowPartialFill; + return this; + } + + /// Sets the gas limit. Default: 80,000. + public DexSwapIntentBuilder WithGasLimit(ulong gasLimit) + { + _inner.WithGasLimit(gasLimit); + return this; + } + + /// Sets the gas price. + public DexSwapIntentBuilder WithGasPrice(UInt256 gasPrice) + { + _inner.WithGasPrice(gasPrice); + return this; + } + + /// Sets the maximum fee per gas (EIP-1559). + public DexSwapIntentBuilder WithMaxFeePerGas(UInt256 maxFeePerGas) + { + _inner.WithMaxFeePerGas(maxFeePerGas); + return this; + } + + /// Sets the maximum priority fee per gas (EIP-1559). + public DexSwapIntentBuilder WithMaxPriorityFeePerGas(UInt256 maxPriorityFeePerGas) + { + _inner.WithMaxPriorityFeePerGas(maxPriorityFeePerGas); + return this; + } + + /// Sets the chain ID. + public DexSwapIntentBuilder WithChainId(uint chainId) + { + _inner.WithChainId(chainId); + return this; + } + + /// Sets the transaction nonce. + public DexSwapIntentBuilder WithNonce(ulong nonce) + { + _inner.WithNonce(nonce); + return this; + } + + /// Sets the sender address. + public DexSwapIntentBuilder WithSender(Address sender) + { + _inner.WithSender(sender); + return this; + } + + /// + /// Builds the unsigned swap intent transaction. + /// Encodes the 114-byte payload: [1B version][20B tokenIn][20B tokenOut][32B amountIn LE][32B minAmountOut LE][8B deadline BE][1B flags] + /// For encrypted intents, the payload is wrapped with EC-ElGamal + AES-256-GCM. + /// + public Transaction Build() + { + var payload = new byte[114]; + payload[0] = 1; // version + + _tokenIn.ToArray().CopyTo(payload.AsSpan(1, 20)); + _tokenOut.ToArray().CopyTo(payload.AsSpan(21, 20)); + _amountIn.WriteTo(payload.AsSpan(41, 32)); // LE default + _minAmountOut.WriteTo(payload.AsSpan(73, 32)); // LE default + BinaryPrimitives.WriteUInt64BigEndian(payload.AsSpan(105, 8), _deadline); + payload[113] = (byte)(_allowPartialFill ? 0x01 : 0x00); + + byte[] data; + if (_encrypted) + { + data = EncryptedIntent.Encrypt(payload, _groupPublicKey, _epoch); + } + else + { + data = payload; + } + + _inner.WithData(data); + return _inner.Build(); + } +} diff --git a/src/sdk/Basalt.Sdk.Wallet/Transactions/TransactionBuilder.cs b/src/sdk/Basalt.Sdk.Wallet/Transactions/TransactionBuilder.cs index db2eb19..cebf214 100644 --- a/src/sdk/Basalt.Sdk.Wallet/Transactions/TransactionBuilder.cs +++ b/src/sdk/Basalt.Sdk.Wallet/Transactions/TransactionBuilder.cs @@ -63,6 +63,16 @@ private TransactionBuilder(TransactionType type) /// public static TransactionBuilder ValidatorRegister() => new(TransactionType.ValidatorRegister); + /// + /// Creates a builder for a DEX swap intent transaction (plaintext). + /// + public static TransactionBuilder DexSwapIntent() => new(TransactionType.DexSwapIntent); + + /// + /// Creates a builder for a DEX encrypted swap intent transaction. + /// + public static TransactionBuilder DexEncryptedSwapIntent() => new(TransactionType.DexEncryptedSwapIntent); + /// /// Sets the transaction nonce (sender's sequence number). /// diff --git a/tests/Basalt.Execution.Tests/Dex/DexQuoteTests.cs b/tests/Basalt.Execution.Tests/Dex/DexQuoteTests.cs new file mode 100644 index 0000000..093e499 --- /dev/null +++ b/tests/Basalt.Execution.Tests/Dex/DexQuoteTests.cs @@ -0,0 +1,128 @@ +using Xunit; +using FluentAssertions; +using Basalt.Core; +using Basalt.Execution.Dex; +using Basalt.Execution.Dex.Math; + +namespace Basalt.Execution.Tests.Dex; + +public sealed class DexQuoteTests +{ + private static readonly Address Token0 = new(new byte[20] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }); + private static readonly Address Token1 = new(new byte[20] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2 }); + + [Fact] + public void GetAmountOut_KnownReserves_ReturnsExpectedOutput() + { + // Pool: 1,000,000 / 2,000,000 reserves, 30 bps fee + var reserveIn = new UInt256(1_000_000); + var reserveOut = new UInt256(2_000_000); + var amountIn = new UInt256(10_000); + + var amountOut = DexLibrary.GetAmountOut(amountIn, reserveIn, reserveOut, 30); + + // Expected: amountIn * 9970 * reserveOut / (reserveIn * 10000 + amountIn * 9970) + // = 10000 * 9970 * 2000000 / (1000000 * 10000 + 10000 * 9970) + // = 199,400,000,000 / 10,099,700,000 ≈ 19,743 + amountOut.Should().BeGreaterThan(UInt256.Zero); + amountOut.Should().BeLessThan(new UInt256(20_000)); // Less than ideal due to price impact + fee + amountOut.Should().BeGreaterThan(new UInt256(19_000)); // But close to 2x (price ratio) + } + + [Fact] + public void GetAmountOut_LargeSwap_HasSignificantPriceImpact() + { + var reserveIn = new UInt256(1_000_000); + var reserveOut = new UInt256(1_000_000); + + var smallSwap = DexLibrary.GetAmountOut(new UInt256(1_000), reserveIn, reserveOut, 30); + var largeSwap = DexLibrary.GetAmountOut(new UInt256(500_000), reserveIn, reserveOut, 30); + + // Small swap should get close to 1:1 + smallSwap.Should().BeGreaterThan(new UInt256(990)); + + // Large swap (50% of reserves) should get significantly less than 1:1 + largeSwap.Should().BeLessThan(new UInt256(350_000)); // Far below 500,000 + } + + [Fact] + public void BestPool_Selection_PicksHighestOutput() + { + // Simulate trying multiple fee tiers and picking the best one + var reserveIn = new UInt256(10_000_000); + var reserveOut = new UInt256(10_000_000); + var amountIn = new UInt256(100_000); + + var outputs = new List<(uint fee, UInt256 output)>(); + + foreach (var feeBps in DexLibrary.AllowedFeeTiers) + { + var output = DexLibrary.GetAmountOut(amountIn, reserveIn, reserveOut, feeBps); + outputs.Add((feeBps, output)); + } + + // Lower fee tiers should give more output (all else equal) + var sorted = outputs.OrderByDescending(x => x.output).ToList(); + sorted[0].fee.Should().Be(1); // 1 bps gives the best output + sorted[^1].fee.Should().Be(100); // 100 bps gives the worst output + } + + [Fact] + public void PriceImpact_Calculation_IsCorrect() + { + var reserve0 = new UInt256(1_000_000); + var reserve1 = new UInt256(2_000_000); + var amountIn = new UInt256(100_000); + uint feeBps = 30; + + // Spot price = reserve1 / reserve0 * PriceScale + var spotPrice = BatchAuctionSolver.ComputeSpotPrice(reserve0, reserve1); + spotPrice.Should().BeGreaterThan(UInt256.Zero); + + // Actual output + var amountOut = DexLibrary.GetAmountOut(amountIn, reserve0, reserve1, feeBps); + + // Spot-based output (what you'd get at zero price impact, after fee) + var feeComplement = new UInt256(10_000 - feeBps); + var feeDenom = new UInt256(10_000); + var amountInAfterFee = FullMath.MulDiv(amountIn, feeComplement, feeDenom); + var spotAmountOut = FullMath.MulDiv(amountInAfterFee, spotPrice, BatchAuctionSolver.PriceScale); + + // Price impact = (spotAmountOut - amountOut) / spotAmountOut * 10000 + if (spotAmountOut > amountOut) + { + var impactBps = FullMath.MulDiv(spotAmountOut - amountOut, new UInt256(10_000), spotAmountOut); + var impact = (uint)(ulong)impactBps.Lo; + impact.Should().BeGreaterThan(0U, "100k into 1M reserves should have measurable price impact"); + impact.Should().BeLessThan(2000U, "10% of reserves shouldn't exceed 20% price impact"); + } + } + + [Fact] + public void SpotPrice_Calculation_MatchesReserveRatio() + { + var reserve0 = new UInt256(1_000_000); + var reserve1 = new UInt256(3_000_000); + + var spotPrice = BatchAuctionSolver.ComputeSpotPrice(reserve0, reserve1); + + // spot = reserve1 * PriceScale / reserve0 = 3 * PriceScale + var expected = FullMath.MulDiv(reserve1, BatchAuctionSolver.PriceScale, reserve0); + spotPrice.Should().Be(expected); + } + + [Fact] + public void DynamicFee_BelowThreshold_ReturnsBaseFee() + { + var fee = DynamicFeeCalculator.ComputeDynamicFee(baseFeeBps: 30, volatilityBps: 50); + fee.Should().Be(30U); + } + + [Fact] + public void DynamicFee_AboveThreshold_IncreasesFee() + { + var fee = DynamicFeeCalculator.ComputeDynamicFee(baseFeeBps: 30, volatilityBps: 300); + fee.Should().BeGreaterThan(30U); + fee.Should().BeLessOrEqualTo(DynamicFeeCalculator.MaxFeeBps); + } +} diff --git a/tests/Basalt.Execution.Tests/README.md b/tests/Basalt.Execution.Tests/README.md index 96fc72d..fe7319f 100644 --- a/tests/Basalt.Execution.Tests/README.md +++ b/tests/Basalt.Execution.Tests/README.md @@ -1,6 +1,6 @@ # Basalt.Execution.Tests -Unit tests for Basalt transaction processing: validation, execution, mempool, chain management, block building, VM operations, sandboxed contract runtime, staking transactions, SDK contract execution, and the Caldera Fusion DEX engine. **556 tests.** +Unit tests for Basalt transaction processing: validation, execution, mempool, chain management, block building, VM operations, sandboxed contract runtime, staking transactions, SDK contract execution, and the Caldera Fusion DEX engine. **559 tests.** ## Test Coverage @@ -38,7 +38,7 @@ Unit tests for Basalt transaction processing: validation, execution, mempool, ch | Transaction | 5 | Transaction creation, signing, hash computation, serialization roundtrip | | FaucetDiagnostic | 3 | Faucet endpoint and SDK contract diagnostics | -**Total: 556 tests** +**Total: 559 tests** ## Test Files diff --git a/tests/Basalt.Network.Tests/README.md b/tests/Basalt.Network.Tests/README.md index 63ecc63..fa9bb8c 100644 --- a/tests/Basalt.Network.Tests/README.md +++ b/tests/Basalt.Network.Tests/README.md @@ -1,6 +1,6 @@ # Basalt.Network.Tests -Unit tests for Basalt P2P networking: message serialization, Kademlia DHT, TCP transport, Episub gossip protocol, reputation scoring, transport encryption, and network audit. **113 tests.** +Unit tests for Basalt P2P networking: message serialization, Kademlia DHT, TCP transport, Episub gossip protocol, reputation scoring, transport encryption, and network audit. **116 tests.** ## Test Coverage @@ -15,7 +15,7 @@ Unit tests for Basalt P2P networking: message serialization, Kademlia DHT, TCP t | TcpTransport | 6 | TCP connection lifecycle, length-prefixed framing, peer connection management, async disposal | | Episub | 5 | Eager/lazy tier management, graft/prune, priority vs standard broadcast, rebalancing | -**Total: 113 tests** +**Total: 116 tests** ## Test Files diff --git a/tests/Basalt.Node.Tests/README.md b/tests/Basalt.Node.Tests/README.md index 2f3765a..98bb32e 100644 --- a/tests/Basalt.Node.Tests/README.md +++ b/tests/Basalt.Node.Tests/README.md @@ -1,6 +1,6 @@ # Basalt.Node.Tests -Unit tests for the Basalt node: configuration, message handling, validator setup, slashing integration, mainnet guards, solver manager, and solver scoring. **88 tests.** +Unit tests for the Basalt node: configuration, message handling, validator setup, slashing integration, mainnet guards, solver manager, and solver scoring. **96 tests.** ## Test Coverage @@ -14,7 +14,7 @@ Unit tests for the Basalt node: configuration, message handling, validator setup | NodeConfiguration | 8 | Default values, IsConsensusMode (requires both peers and validator index), property initialization, environment variable handling | | SlashingIntegration | 5 | Double-sign slashing (100% penalty), inactivity slashing (5% penalty), staking state registration, active validator queries, weighted leader selection | -**Total: 88 tests** +**Total: 96 tests** ## Test Files diff --git a/tests/Basalt.Sdk.Analyzers.Tests/AnalyzerTests.cs b/tests/Basalt.Sdk.Analyzers.Tests/AnalyzerTests.cs index 385ab10..cfcb781 100644 --- a/tests/Basalt.Sdk.Analyzers.Tests/AnalyzerTests.cs +++ b/tests/Basalt.Sdk.Analyzers.Tests/AnalyzerTests.cs @@ -844,4 +844,415 @@ public class BasaltContractAttribute : System.Attribute { } var diags = await GetDiagnosticsAsync(source); diags.Should().Contain(d => d.Id == "BST008"); } + + // ======================================================================== + // BST009: CrossContractReturnAnalyzer + // ======================================================================== + + [Fact] + public async Task BST009_DiscardedBoolReturn_InContract_ReportsDiagnostic() + { + var source = @" +[BasaltContract] +public class MyContract +{ + public void Transfer() + { + Context.CallContract(null, ""Transfer""); + } +} +public class BasaltContractAttribute : System.Attribute { } +public static class Context +{ + public static T CallContract(object addr, string method) => default; +} +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST009"); + } + + [Fact] + public async Task BST009_CheckedBoolReturn_InContract_NoDiagnostic() + { + var source = @" +[BasaltContract] +public class MyContract +{ + public void Transfer() + { + var ok = Context.CallContract(null, ""Transfer""); + } +} +public class BasaltContractAttribute : System.Attribute { } +public static class Context +{ + public static T CallContract(object addr, string method) => default; +} +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + [Fact] + public async Task BST009_OutsideContract_NoDiagnostic() + { + var source = @" +public class NotAContract +{ + public void Transfer() + { + Context.CallContract(null, ""Transfer""); + } +} +public static class Context +{ + public static T CallContract(object addr, string method) => default; +} +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + // ======================================================================== + // BST010: PolicyOrderingAnalyzer + // ======================================================================== + + [Fact] + public async Task BST010_SetBeforeEnforce_InContract_ReportsDiagnostic() + { + var source = @" +[BasaltContract] +public class MyContract +{ + private StorageMap _balances = new StorageMap(); + + public void Transfer() + { + _balances.Set(""key"", ""value""); + _enforcer.EnforceTransfer(null, null, 0); + } + + private PolicyEnforcer _enforcer = new PolicyEnforcer(); +} +public class BasaltContractAttribute : System.Attribute { } +public class StorageMap { public void Set(string k, string v) { } } +public class PolicyEnforcer { public void EnforceTransfer(object a, object b, int c) { } } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST010"); + } + + [Fact] + public async Task BST010_EnforceBeforeSet_InContract_NoDiagnostic() + { + var source = @" +[BasaltContract] +public class MyContract +{ + private StorageMap _balances = new StorageMap(); + + public void Transfer() + { + _enforcer.EnforceTransfer(null, null, 0); + _balances.Set(""key"", ""value""); + } + + private PolicyEnforcer _enforcer = new PolicyEnforcer(); +} +public class BasaltContractAttribute : System.Attribute { } +public class StorageMap { public void Set(string k, string v) { } } +public class PolicyEnforcer { public void EnforceTransfer(object a, object b, int c) { } } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + [Fact] + public async Task BST010_NoEnforcement_InContract_NoDiagnostic() + { + var source = @" +[BasaltContract] +public class MyContract +{ + private StorageMap _balances = new StorageMap(); + + public void Transfer() + { + _balances.Set(""key"", ""value""); + } +} +public class BasaltContractAttribute : System.Attribute { } +public class StorageMap { public void Set(string k, string v) { } } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + // ======================================================================== + // BST011: CollectionOrderingAnalyzer + // ======================================================================== + + [Fact] + public async Task BST011_DictionaryCreation_InContract_ReportsDiagnostic() + { + var source = @" +using System.Collections.Generic; +[BasaltContract] +public class MyContract +{ + public void DoWork() + { + var d = new Dictionary(); + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST011"); + } + + [Fact] + public async Task BST011_HashSetCreation_InContract_ReportsDiagnostic() + { + var source = @" +using System.Collections.Generic; +[BasaltContract] +public class MyContract +{ + public void DoWork() + { + var h = new HashSet(); + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST011"); + } + + [Fact] + public async Task BST011_OutsideContract_NoDiagnostic() + { + var source = @" +using System.Collections.Generic; +public class NotAContract +{ + public void DoWork() + { + var d = new Dictionary(); + } +} +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + [Fact] + public async Task BST011_DictionaryField_InContract_ReportsDiagnostic() + { + var source = @" +using System.Collections.Generic; +[BasaltContract] +public class MyContract +{ + private Dictionary _cache; +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST011"); + } + + [Fact] + public async Task BST011_SortedDictionary_InContract_NoDiagnostic() + { + var source = @" +using System.Collections.Generic; +[BasaltContract] +public class MyContract +{ + public void DoWork() + { + var d = new SortedDictionary(); + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + // ======================================================================== + // BST012: PolicyEnforcementAnalyzer + // ======================================================================== + + [Fact] + public async Task BST012_TransferInternalWithoutEnforce_InDerivedContract_ReportsDiagnostic() + { + var source = @" +[BasaltContract] +public class BST20Token +{ +} + +[BasaltContract] +public class MyToken : BST20Token +{ + private bool TransferInternal(byte[] from, byte[] to, int amount) + { + // Missing EnforceTransfer call + return true; + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST012"); + } + + [Fact] + public async Task BST012_TransferInternalWithEnforce_InDerivedContract_NoDiagnostic() + { + var source = @" +[BasaltContract] +public class BST20Token +{ +} + +[BasaltContract] +public class MyToken : BST20Token +{ + private PolicyEnforcer _policyEnforcer = new PolicyEnforcer(); + + private bool TransferInternal(byte[] from, byte[] to, int amount) + { + _policyEnforcer.EnforceTransfer(from, to, amount); + return true; + } +} +public class BasaltContractAttribute : System.Attribute { } +public class PolicyEnforcer +{ + public void EnforceTransfer(byte[] a, byte[] b, int c) { } +} +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + [Fact] + public async Task BST012_BST721_TransferInternalWithoutEnforce_ReportsDiagnostic() + { + var source = @" +[BasaltContract] +public class BST721Token +{ +} + +[BasaltContract] +public class MyNFT : BST721Token +{ + private bool TransferInternal(byte[] from, byte[] to, ulong tokenId) + { + return true; + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST012"); + } + + [Fact] + public async Task BST012_BST1155_SafeTransferFromWithoutEnforce_ReportsDiagnostic() + { + var source = @" +[BasaltContract] +public class BST1155Token +{ +} + +[BasaltContract] +public class MyMultiToken : BST1155Token +{ + public void SafeTransferFrom(byte[] from, byte[] to, ulong id, ulong amount) + { + // Missing EnforceTransfer call + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST012"); + } + + [Fact] + public async Task BST012_BST3525_TransferValueFromWithoutEnforce_ReportsDiagnostic() + { + var source = @" +[BasaltContract] +public class BST3525Token +{ +} + +[BasaltContract] +public class MySFT : BST3525Token +{ + public void TransferValueFrom(ulong fromId, byte[] to, ulong value) + { + // Missing EnforceTransfer call + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().Contain(d => d.Id == "BST012"); + } + + [Fact] + public async Task BST012_BST1155_SafeTransferFromWithEnforce_NoDiagnostic() + { + var source = @" +[BasaltContract] +public class BST1155Token +{ +} + +[BasaltContract] +public class MyMultiToken : BST1155Token +{ + private PolicyEnforcer _policyEnforcer = new PolicyEnforcer(); + + public void SafeTransferFrom(byte[] from, byte[] to, ulong id, ulong amount) + { + _policyEnforcer.EnforceTransfer(from, to, amount); + } +} +public class BasaltContractAttribute : System.Attribute { } +public class PolicyEnforcer +{ + public void EnforceTransfer(byte[] a, byte[] b, ulong c) { } +} +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } + + [Fact] + public async Task BST012_NonBstClassWithTransferInternal_NoDiagnostic() + { + var source = @" +[BasaltContract] +public class MyContract +{ + private bool TransferInternal(byte[] from, byte[] to, int amount) + { + return true; + } +} +public class BasaltContractAttribute : System.Attribute { } +"; + var diags = await GetDiagnosticsAsync(source); + diags.Should().BeEmpty(); + } } diff --git a/tests/Basalt.Sdk.Analyzers.Tests/README.md b/tests/Basalt.Sdk.Analyzers.Tests/README.md index 95a57ba..28435d3 100644 --- a/tests/Basalt.Sdk.Analyzers.Tests/README.md +++ b/tests/Basalt.Sdk.Analyzers.Tests/README.md @@ -1,20 +1,20 @@ # Basalt.Sdk.Analyzers.Tests -Unit tests for the Basalt Roslyn analyzers and source generators using the Microsoft.CodeAnalysis.Testing framework. **96 tests.** +Unit tests for the Basalt Roslyn analyzers and source generators using the Microsoft.CodeAnalysis.Testing framework. **114 tests.** ## Test Coverage | Category | Tests | Description | |----------|-------|-------------| | Source Generators | 54 | Contract boilerplate generation, storage field detection, entry point generation, event type generation, ABI export, error handling for malformed contracts | -| Analyzers | 42 | BST001-BST008 diagnostic rules: forbidden API usage, missing attributes, invalid storage types, contract structure validation, unsafe operations detection | +| Analyzers | 60 | BST001-BST012 diagnostic rules: forbidden API usage, missing attributes, invalid storage types, contract structure validation, unsafe operations, unchecked returns (BST009), storage mutation ordering (BST010), non-deterministic collections (BST011), missing policy enforcement (BST012) | -**Total: 96 tests** +**Total: 114 tests** ## Test Files - `GeneratorTests.cs` -- Roslyn source generator tests: contract boilerplate, storage fields, entry points, events, ABI, error recovery -- `AnalyzerTests.cs` -- Roslyn analyzer tests: BST001-BST008 diagnostic rules, forbidden APIs, attribute validation, storage type checks +- `AnalyzerTests.cs` -- Roslyn analyzer tests: BST001-BST012 diagnostic rules, forbidden APIs, attribute validation, storage type checks, policy enforcement, collection ordering ## Running diff --git a/tests/Basalt.Sdk.Tests/CrossContractCallTests.cs b/tests/Basalt.Sdk.Tests/CrossContractCallTests.cs index 49fa642..5b3d97d 100644 --- a/tests/Basalt.Sdk.Tests/CrossContractCallTests.cs +++ b/tests/Basalt.Sdk.Tests/CrossContractCallTests.cs @@ -83,6 +83,70 @@ public void CrossContractCall_Restores_Context_After_Call() Context.CallDepth.Should().Be(0); } + [Fact] + public void ReentrantCallback_IsStaticCall_BlocksWrites() + { + // A (TokenContract) calls B (PolicyContract), B calls back A.BalanceOf (read — OK) + var tokenAddr = BasaltTestHost.CreateAddress(10); + var policyAddr = BasaltTestHost.CreateAddress(11); + + var token = new MintableBST20("Token", "TK", 18); + var policy = new CallbackPolicyContract(tokenAddr); + _host.Deploy(tokenAddr, token); + _host.Deploy(policyAddr, policy); + + var caller = BasaltTestHost.CreateAddress(1); + _host.SetCaller(caller); + _host.Call(() => token.MintPublic(caller, 1000)); + + // Token calls Policy.CheckBalance which calls back Token.BalanceOf (read) + Context.Self = tokenAddr; + var balance = Context.CallContract(policyAddr, "CheckBalance", caller); + balance.Should().Be((UInt256)1000); + } + + [Fact] + public void ReentrantCallback_IsStaticCall_WritesRevert() + { + // A calls B, B calls back A with a method that writes — should revert + var contractAAddr = BasaltTestHost.CreateAddress(10); + var contractBAddr = BasaltTestHost.CreateAddress(11); + + var contractA = new WritableContract(); + var contractB = new CallbackWriterContract(contractAAddr); + _host.Deploy(contractAAddr, contractA); + _host.Deploy(contractBAddr, contractB); + + var caller = BasaltTestHost.CreateAddress(1); + _host.SetCaller(caller); + Context.Self = contractAAddr; + + // A calls B.TriggerWriteBack, which calls A.WriteState — should fail with static call error + var act = () => Context.CallContract(contractBAddr, "TriggerWriteBack"); + act.Should().Throw() + .WithMessage("*Static call*"); + } + + [Fact] + public void IsStaticCall_RestoredAfterCallback() + { + var tokenAddr = BasaltTestHost.CreateAddress(10); + var policyAddr = BasaltTestHost.CreateAddress(11); + + var token = new MintableBST20("Token", "TK", 18); + var policy = new CallbackPolicyContract(tokenAddr); + _host.Deploy(tokenAddr, token); + _host.Deploy(policyAddr, policy); + + var caller = BasaltTestHost.CreateAddress(1); + _host.SetCaller(caller); + Context.Self = tokenAddr; + + Context.IsStaticCall.Should().BeFalse(); + Context.CallContract(policyAddr, "CheckBalance", caller); + Context.IsStaticCall.Should().BeFalse(); // Restored after call chain + } + public void Dispose() => _host.Dispose(); } @@ -99,3 +163,43 @@ public void Reenter() Context.CallContract(_selfAddr, "Reenter"); } } + +/// +/// Test policy contract that calls back into the token to read BalanceOf. +/// +public class CallbackPolicyContract +{ + private readonly byte[] _tokenAddr; + public CallbackPolicyContract(byte[] tokenAddr) => _tokenAddr = tokenAddr; + + public UInt256 CheckBalance(byte[] account) + { + // This is a callback from token→policy→token.BalanceOf (read-only) + return Context.CallContract(_tokenAddr, "BalanceOf", account); + } +} + +/// +/// Test contract that calls back into the caller and attempts a storage write. +/// +public class CallbackWriterContract +{ + private readonly byte[] _targetAddr; + public CallbackWriterContract(byte[] targetAddr) => _targetAddr = targetAddr; + + public void TriggerWriteBack() + { + Context.CallContract(_targetAddr, "WriteState"); + } +} + +/// +/// Test contract with a write method (used to verify static call blocks writes). +/// +public class WritableContract +{ + public void WriteState() + { + ContractStorage.Set("test_key", "test_value"); + } +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/BST1155PolicyIntegrationTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/BST1155PolicyIntegrationTests.cs new file mode 100644 index 0000000..a8862fb --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/BST1155PolicyIntegrationTests.cs @@ -0,0 +1,101 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Contracts.Standards; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class BST1155PolicyIntegrationTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF0); + private readonly byte[] _sanctionsAddr = BasaltTestHost.CreateAddress(0xF1); + private readonly BST1155Token _token; + private readonly SanctionsPolicy _sanctions; + + public BST1155PolicyIntegrationTests() + { + _host = new BasaltTestHost(); + + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token = new BST1155Token("https://example.com/"); + _host.Deploy(_tokenAddr, _token); + + Context.Self = _sanctionsAddr; + _sanctions = new SanctionsPolicy(); + _host.Deploy(_sanctionsAddr, _sanctions); + + Context.IsDeploying = false; + + // Mint some tokens to admin + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.Mint(_admin, 1, new UInt256(1000), ""); + } + + [Fact] + public void SafeTransferFrom_SucceedsWithNoPolicies() + { + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _host.Call(() => _token.SafeTransferFrom(_admin, _alice, 1, new UInt256(100))); + _token.BalanceOf(_alice, 1).Should().Be(new UInt256(100)); + } + + [Fact] + public void SafeTransferFrom_RevertsWhenPolicyDenies() + { + // Sanction Alice + _host.SetCaller(_admin); + Context.Self = _sanctionsAddr; + _sanctions.AddSanction(_alice); + + // Register policy + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + var msg = _host.ExpectRevert(() => _token.SafeTransferFrom(_admin, _alice, 1, new UInt256(100))); + msg.Should().Contain("transfer denied"); + } + + [Fact] + public void SafeBatchTransferFrom_ChecksAllItems() + { + // Mint token ID 2 + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.Mint(_admin, 2, new UInt256(500), ""); + + // Sanction Bob + Context.Self = _sanctionsAddr; + _sanctions.AddSanction(_bob); + + // Register policy + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + // Batch transfer to sanctioned Bob should fail + var msg = _host.ExpectRevert(() => + _token.SafeBatchTransferFrom(_admin, _bob, new ulong[] { 1, 2 }, new ulong[] { 10, 20 })); + msg.Should().Contain("transfer denied"); + } + + [Fact] + public void PolicyManagement_AdminOnly() + { + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + var msg = _host.ExpectRevert(() => _token.AddPolicy(_sanctionsAddr)); + msg.Should().Contain("not owner"); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/BST20PolicyIntegrationTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/BST20PolicyIntegrationTests.cs new file mode 100644 index 0000000..294a418 --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/BST20PolicyIntegrationTests.cs @@ -0,0 +1,190 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Contracts.Standards; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class BST20PolicyIntegrationTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF0); + private readonly byte[] _sanctionsAddr = BasaltTestHost.CreateAddress(0xF1); + private readonly byte[] _lockupAddr = BasaltTestHost.CreateAddress(0xF2); + private readonly BST20Token _token; + private readonly SanctionsPolicy _sanctions; + private readonly LockupPolicy _lockup; + + public BST20PolicyIntegrationTests() + { + _host = new BasaltTestHost(); + + // Deploy token + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token = new BST20Token("Test", "TST", 18, new UInt256(1_000_000)); + _host.Deploy(_tokenAddr, _token); + + // Deploy sanctions policy + Context.Self = _sanctionsAddr; + _sanctions = new SanctionsPolicy(); + _host.Deploy(_sanctionsAddr, _sanctions); + + // Deploy lockup policy + Context.Self = _lockupAddr; + _lockup = new LockupPolicy(); + _host.Deploy(_lockupAddr, _lockup); + + Context.IsDeploying = false; + } + + [Fact] + public void Transfer_SucceedsWithNoPolicies() + { + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + var result = _host.Call(() => _token.Transfer(_alice, new UInt256(100))); + result.Should().BeTrue(); + } + + [Fact] + public void Transfer_SucceedsWhenPolicyApproves() + { + // Register sanctions policy (no one sanctioned) + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + var result = _host.Call(() => _token.Transfer(_alice, new UInt256(100))); + result.Should().BeTrue(); + } + + [Fact] + public void Transfer_RevertsWhenPolicyDenies() + { + // Sanction Bob + _host.SetCaller(_admin); + Context.Self = _sanctionsAddr; + _sanctions.AddSanction(_bob); + + // Register sanctions policy on token + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + // Transfer to sanctioned Bob should revert + var msg = _host.ExpectRevert(() => _token.Transfer(_bob, new UInt256(100))); + msg.Should().Contain("transfer denied"); + } + + [Fact] + public void TransferFrom_RespectsPolicies() + { + // Give Alice some tokens and Bob approval + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.Transfer(_alice, new UInt256(500)); + + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _token.Approve(_bob, new UInt256(500)); + + // Sanction Alice (sender in TransferFrom) + _host.SetCaller(_admin); + Context.Self = _sanctionsAddr; + _sanctions.AddSanction(_alice); + + // Register policy + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + // Bob tries TransferFrom Alice (sanctioned) to himself + _host.SetCaller(_bob); + Context.Self = _tokenAddr; + var msg = _host.ExpectRevert(() => _token.TransferFrom(_alice, _bob, new UInt256(100))); + msg.Should().Contain("transfer denied"); + } + + [Fact] + public void AddPolicy_OnlyCallableByAdmin() + { + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + var msg = _host.ExpectRevert(() => _token.AddPolicy(_sanctionsAddr)); + msg.Should().Contain("not admin"); + } + + [Fact] + public void RemovePolicy_OnlyCallableByAdmin() + { + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + var msg = _host.ExpectRevert(() => _token.RemovePolicy(_sanctionsAddr)); + msg.Should().Contain("not admin"); + } + + [Fact] + public void PolicyCount_ReturnsCorrectCount() + { + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + + _token.PolicyCount().Should().Be(0); + _token.AddPolicy(_sanctionsAddr); + _token.PolicyCount().Should().Be(1); + _token.AddPolicy(_lockupAddr); + _token.PolicyCount().Should().Be(2); + } + + [Fact] + public void MultiplePolicies_AllMustPass() + { + // Set lockup on admin (sender) + _host.SetCaller(_admin); + Context.Self = _lockupAddr; + _host.SetBlockTimestamp(1_000_000); + _lockup.SetLockup(_tokenAddr, _admin, 2_000_000); + + // Register both policies + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + _token.AddPolicy(_lockupAddr); + + // Transfer should fail due to lockup (even though sanctions pass) + var msg = _host.ExpectRevert(() => _token.Transfer(_alice, new UInt256(100))); + msg.Should().Contain("transfer denied"); + } + + [Fact] + public void RemovePolicy_AllowsPreviouslyDeniedTransfer() + { + // Sanction Bob + _host.SetCaller(_admin); + Context.Self = _sanctionsAddr; + _sanctions.AddSanction(_bob); + + // Register then remove policy + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + _token.RemovePolicy(_sanctionsAddr); + + // Transfer to Bob should now succeed + var result = _host.Call(() => _token.Transfer(_bob, new UInt256(100))); + result.Should().BeTrue(); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/BST721PolicyIntegrationTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/BST721PolicyIntegrationTests.cs new file mode 100644 index 0000000..3dbdb50 --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/BST721PolicyIntegrationTests.cs @@ -0,0 +1,111 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Contracts.Standards; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class BST721PolicyIntegrationTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF0); + private readonly byte[] _sanctionsAddr = BasaltTestHost.CreateAddress(0xF1); + private readonly BST721Token _token; + private readonly SanctionsPolicy _sanctions; + + public BST721PolicyIntegrationTests() + { + _host = new BasaltTestHost(); + + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token = new BST721Token("TestNFT", "TNFT"); + _host.Deploy(_tokenAddr, _token); + + Context.Self = _sanctionsAddr; + _sanctions = new SanctionsPolicy(); + _host.Deploy(_sanctionsAddr, _sanctions); + + Context.IsDeploying = false; + } + + [Fact] + public void Transfer_SucceedsWithNoPolicies() + { + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + var tokenId = _host.Call(() => _token.Mint(_alice, "uri://1")); + + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_bob, tokenId)); + + _token.OwnerOf(tokenId).Should().BeEquivalentTo(_bob); + } + + [Fact] + public void Transfer_RevertsWhenPolicyDenies() + { + // Mint to Alice + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + var tokenId = _host.Call(() => _token.Mint(_alice, "uri://1")); + + // Sanction Bob + Context.Self = _sanctionsAddr; + _sanctions.AddSanction(_bob); + + // Register policy + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + // Alice tries to transfer to sanctioned Bob + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + var msg = _host.ExpectRevert(() => _token.Transfer(_bob, tokenId)); + msg.Should().Contain("transfer denied"); + } + + [Fact] + public void Transfer_SucceedsWhenPolicyApproves() + { + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + var tokenId = _host.Call(() => _token.Mint(_alice, "uri://1")); + + // Register policy (no one sanctioned) + _token.AddPolicy(_sanctionsAddr); + + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_bob, tokenId)); + _token.OwnerOf(tokenId).Should().BeEquivalentTo(_bob); + } + + [Fact] + public void PolicyManagement_AdminOnly() + { + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + var msg = _host.ExpectRevert(() => _token.AddPolicy(_sanctionsAddr)); + msg.Should().Contain("not owner"); + } + + [Fact] + public void PolicyCount_Tracks() + { + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.PolicyCount().Should().Be(0); + _token.AddPolicy(_sanctionsAddr); + _token.PolicyCount().Should().Be(1); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/EndToEndPolicyTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/EndToEndPolicyTests.cs new file mode 100644 index 0000000..e29ccb6 --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/EndToEndPolicyTests.cs @@ -0,0 +1,296 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Contracts.Standards; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +/// +/// End-to-end test: deploy a BST-20 token with multiple policies +/// (sanctions + lockup), demonstrate the full compliance lifecycle. +/// +public class EndToEndPolicyTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _charlie = BasaltTestHost.CreateAddress(4); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xA0); + private readonly byte[] _sanctionsAddr = BasaltTestHost.CreateAddress(0xA1); + private readonly byte[] _lockupAddr = BasaltTestHost.CreateAddress(0xA2); + private readonly BST20Token _token; + private readonly SanctionsPolicy _sanctions; + private readonly LockupPolicy _lockup; + + public EndToEndPolicyTests() + { + _host = new BasaltTestHost(); + _host.SetBlockTimestamp(1_000_000); + + // Deploy token with 1M supply + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token = new BST20Token("ComplianceToken", "CMPL", 18, new UInt256(1_000_000)); + _host.Deploy(_tokenAddr, _token); + + // Deploy sanctions policy + Context.Self = _sanctionsAddr; + _sanctions = new SanctionsPolicy(); + _host.Deploy(_sanctionsAddr, _sanctions); + + // Deploy lockup policy + Context.Self = _lockupAddr; + _lockup = new LockupPolicy(); + _host.Deploy(_lockupAddr, _lockup); + + Context.IsDeploying = false; + } + + [Fact] + public void FullComplianceLifecycle() + { + // --- Step 1: Register both policies --- + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + _token.AddPolicy(_lockupAddr); + _token.PolicyCount().Should().Be(2); + + // --- Step 2: Distribute tokens --- + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_alice, new UInt256(10_000))); + _host.Call(() => _token.Transfer(_bob, new UInt256(10_000))); + _token.BalanceOf(_alice).Should().Be(new UInt256(10_000)); + + // --- Step 3: Normal transfers work --- + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_charlie, new UInt256(100))); + _token.BalanceOf(_charlie).Should().Be(new UInt256(100)); + + // --- Step 4: Sanction Charlie --- transfers to Charlie blocked --- + _host.SetCaller(_admin); + Context.Self = _sanctionsAddr; + _sanctions.AddSanction(_charlie); + + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + var msg = _host.ExpectRevert(() => _token.Transfer(_charlie, new UInt256(50))); + msg.Should().Contain("transfer denied"); + + // --- Step 5: Set lockup on Bob --- + _host.SetCaller(_admin); + Context.Self = _lockupAddr; + _lockup.SetLockup(_tokenAddr, _bob, 2_000_000); // Unlocks at t=2M + + // Bob can receive tokens + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_bob, new UInt256(200))); + + // Bob cannot send tokens (locked) + _host.SetCaller(_bob); + Context.Self = _tokenAddr; + msg = _host.ExpectRevert(() => _token.Transfer(_alice, new UInt256(50))); + msg.Should().Contain("transfer denied"); + + // --- Step 6: Time passes, lockup expires --- + _host.SetBlockTimestamp(2_000_001); + + _host.SetCaller(_bob); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_alice, new UInt256(50))); + _token.BalanceOf(_alice).Should().Be(new UInt256(9_750)); // 10000 - 100 - 200 + 50 + + // --- Step 7: Remove sanctions, Charlie can receive again --- + _host.SetCaller(_admin); + Context.Self = _sanctionsAddr; + _sanctions.RemoveSanction(_charlie); + + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_charlie, new UInt256(25))); + _token.BalanceOf(_charlie).Should().Be(new UInt256(125)); + + // --- Step 8: Remove all policies, no restrictions --- + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.RemovePolicy(_sanctionsAddr); + _token.RemovePolicy(_lockupAddr); + _token.PolicyCount().Should().Be(0); + } + + [Fact] + public void PolicyEvents_EmittedCorrectly() + { + _host.ClearEvents(); + + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(_sanctionsAddr); + + var addEvents = _host.GetEvents().ToList(); + addEvents.Should().HaveCount(1); + addEvents[0].Policy.Should().BeEquivalentTo(_sanctionsAddr); + + _host.ClearEvents(); + _token.RemovePolicy(_sanctionsAddr); + + var removeEvents = _host.GetEvents().ToList(); + removeEvents.Should().HaveCount(1); + removeEvents[0].Policy.Should().BeEquivalentTo(_sanctionsAddr); + } + + [Fact] + public void StaticCall_PolicyCallbackCanReadButNotWrite() + { + // Deploy a HoldingLimitPolicy that calls back token.BalanceOf during enforcement + var holdingAddr = BasaltTestHost.CreateAddress(0xA5); + _host.SetCaller(_admin); + Context.Self = holdingAddr; + Context.IsDeploying = true; + var holding = new HoldingLimitPolicy(); + _host.Deploy(holdingAddr, holding); + Context.IsDeploying = false; + + // Set a limit so CheckTransfer will call back token.BalanceOf (read) + _host.SetCaller(_admin); + Context.Self = holdingAddr; + holding.SetDefaultLimit(_tokenAddr, new UInt256(50_000)); + + // Register policy on token + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(holdingAddr); + + // Distribute tokens + _host.Call(() => _token.Transfer(_alice, new UInt256(10_000))); + + // Transfer should succeed — HoldingLimitPolicy reads BalanceOf via static callback + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_bob, new UInt256(1_000))); + _token.BalanceOf(_bob).Should().Be(new UInt256(1_000)); + } + + [Fact] + public void StaticCall_BlocksWritesDuringReentrantCallback() + { + // Use HoldingLimitPolicy which triggers the A→B→A pattern: + // Token calls HoldingLimitPolicy.CheckTransfer, which calls back + // Token.BalanceOf — this re-entry into the token forces static mode. + var holdingAddr = BasaltTestHost.CreateAddress(0xA5); + _host.SetCaller(_admin); + Context.Self = holdingAddr; + Context.IsDeploying = true; + var holding = new HoldingLimitPolicy(); + _host.Deploy(holdingAddr, holding); + Context.IsDeploying = false; + + // Set a holding limit so CheckTransfer will callback token.BalanceOf + _host.SetCaller(_admin); + Context.Self = holdingAddr; + holding.SetDefaultLimit(_tokenAddr, new UInt256(50_000)); + + // Register holding policy on token + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _token.AddPolicy(holdingAddr); + _host.Call(() => _token.Transfer(_alice, new UInt256(1_000))); + + // Now intercept the callback from holding policy → token.BalanceOf + // to verify IsStaticCall is enforced and writes are blocked. + var previousHandler = Context.CrossContractCallHandler; + bool staticCallWasTrue = false; + bool writeWasBlocked = false; + + Context.CrossContractCallHandler = (target, method, args) => + { + // Intercept the BalanceOf callback (A→B→A: token→holding→token) + if (method == "BalanceOf" && target.SequenceEqual(_tokenAddr)) + { + staticCallWasTrue = Context.IsStaticCall; + // Attempt to write during re-entrant callback — should be blocked + try + { + ContractStorage.Set("attack_key", "evil_value"); + } + catch (ContractRevertException ex) + { + writeWasBlocked = true; + ex.Message.Should().Contain("Static call"); + } + // Return a balance so the policy check can complete + return (UInt256)0; + } + return previousHandler?.Invoke(target, method, args); + }; + + // Trigger: Alice transfers to Bob → token calls holding.CheckTransfer + // → holding calls back token.BalanceOf (re-entry, forced static) + _host.SetCaller(_alice); + Context.Self = _tokenAddr; + _host.Call(() => _token.Transfer(_bob, new UInt256(100))); + + Context.CrossContractCallHandler = previousHandler; + + staticCallWasTrue.Should().BeTrue("re-entrant callback should execute in static mode"); + writeWasBlocked.Should().BeTrue("storage writes should be blocked during static callbacks"); + _token.BalanceOf(_bob).Should().Be(new UInt256(100)); + } + + [Fact] + public void JurisdictionPolicy_WorksWithNftTransfers() + { + // Deploy BST-721 token + var nftAddr = BasaltTestHost.CreateAddress(0xB0); + var jurisdictionAddr = BasaltTestHost.CreateAddress(0xB1); + + _host.SetCaller(_admin); + Context.IsDeploying = true; + + Context.Self = nftAddr; + var nft = new BST721Token("TestNFT", "TNFT"); + _host.Deploy(nftAddr, nft); + + Context.Self = jurisdictionAddr; + var jurisdiction = new JurisdictionPolicy(); + _host.Deploy(jurisdictionAddr, jurisdiction); + + Context.IsDeploying = false; + + // Set whitelist mode and whitelist US only + _host.SetCaller(_admin); + Context.Self = jurisdictionAddr; + jurisdiction.SetMode(nftAddr, true); // whitelist + jurisdiction.SetJurisdiction(nftAddr, 840, true); // US + jurisdiction.SetAddressJurisdiction(_admin, 840); // Admin = US + jurisdiction.SetAddressJurisdiction(_alice, 840); // Alice = US + jurisdiction.SetAddressJurisdiction(_bob, 392); // Bob = Japan (not whitelisted) + + // Register on NFT + _host.SetCaller(_admin); + Context.Self = nftAddr; + nft.AddPolicy(jurisdictionAddr); + + // Mint to admin (first token = ID 0) + var tokenId = nft.Mint(_admin, "uri://1"); + + // Transfer to Alice (US, whitelisted) — should work + nft.Transfer(_alice, tokenId); + nft.OwnerOf(tokenId).Should().BeEquivalentTo(_alice); + + // Alice → Bob (Japan, not whitelisted) — should revert + _host.SetCaller(_alice); + Context.Self = nftAddr; + var msg = _host.ExpectRevert(() => nft.Transfer(_bob, tokenId)); + msg.Should().Contain("transfer denied"); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/HoldingLimitPolicyTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/HoldingLimitPolicyTests.cs new file mode 100644 index 0000000..f8a1b34 --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/HoldingLimitPolicyTests.cs @@ -0,0 +1,121 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Contracts.Standards; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class HoldingLimitPolicyTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _policyAddr = BasaltTestHost.CreateAddress(0xF1); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF2); + private readonly HoldingLimitPolicy _policy; + private readonly BST20Token _token; + + public HoldingLimitPolicyTests() + { + _host = new BasaltTestHost(); + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy = new HoldingLimitPolicy(); + Context.Self = _tokenAddr; + _token = new BST20Token("Test", "TST", 18, new UInt256(1_000_000)); + _host.Deploy(_policyAddr, _policy); + _host.Deploy(_tokenAddr, _token); + Context.IsDeploying = false; + } + + [Fact] + public void SetDefaultLimit_StoresLimit() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetDefaultLimit(_tokenAddr, new UInt256(500)); + + var limit = _policy.GetEffectiveLimit(_tokenAddr, _alice); + limit.Should().Be(new UInt256(500)); + } + + [Fact] + public void SetAddressLimit_OverridesDefault() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetDefaultLimit(_tokenAddr, new UInt256(500)); + _policy.SetAddressLimit(_tokenAddr, _alice, new UInt256(100)); + + _policy.GetEffectiveLimit(_tokenAddr, _alice).Should().Be(new UInt256(100)); + _policy.GetEffectiveLimit(_tokenAddr, _bob).Should().Be(new UInt256(500)); + } + + [Fact] + public void CheckTransfer_AllowsUnderLimit() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetDefaultLimit(_tokenAddr, new UInt256(1_000_000)); + + var result = _host.Call(() => _policy.CheckTransfer(_tokenAddr, _admin, _alice, new UInt256(100))); + result.Should().BeTrue(); + } + + [Fact] + public void CheckTransfer_DeniesOverLimit() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetDefaultLimit(_tokenAddr, new UInt256(50)); + + // Admin has 1M tokens, transfer 100 to Alice who has 0 — but limit is 50 + var result = _host.Call(() => _policy.CheckTransfer(_tokenAddr, _admin, _alice, new UInt256(100))); + result.Should().BeFalse(); + } + + [Fact] + public void CheckTransfer_AllowsWhenNoLimitConfigured() + { + Context.Self = _policyAddr; + var result = _host.Call(() => _policy.CheckTransfer(_tokenAddr, _admin, _alice, new UInt256(999))); + result.Should().BeTrue(); + } + + [Fact] + public void SetDefaultLimit_RevertsForNonAdmin() + { + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.SetDefaultLimit(_tokenAddr, new UInt256(100))); + msg.Should().Contain("not admin"); + } + + [Fact] + public void SetAddressLimit_RevertsForNonAdmin() + { + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.SetAddressLimit(_tokenAddr, _bob, new UInt256(100))); + msg.Should().Contain("not admin"); + } + + [Fact] + public void CheckTransfer_DeniesWhenBalancePlusAmountOverflows() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetDefaultLimit(_tokenAddr, new UInt256(100)); + + // Transfer a huge amount that would overflow when added to any balance + var result = _host.Call(() => + _policy.CheckTransfer(_tokenAddr, _admin, _alice, UInt256.MaxValue)); + result.Should().BeFalse(); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/JurisdictionPolicyTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/JurisdictionPolicyTests.cs new file mode 100644 index 0000000..9975cda --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/JurisdictionPolicyTests.cs @@ -0,0 +1,136 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class JurisdictionPolicyTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _policyAddr = BasaltTestHost.CreateAddress(0xF1); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF2); + private readonly JurisdictionPolicy _policy; + + public JurisdictionPolicyTests() + { + _host = new BasaltTestHost(); + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy = new JurisdictionPolicy(); + _host.Deploy(_policyAddr, _policy); + Context.IsDeploying = false; + } + + [Fact] + public void WhitelistMode_AllowsListedJurisdiction() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetMode(_tokenAddr, true); // whitelist + _policy.SetJurisdiction(_tokenAddr, 840, true); // US allowed + _policy.SetAddressJurisdiction(_alice, 840); + _policy.SetAddressJurisdiction(_bob, 840); + + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeTrue(); + } + + [Fact] + public void WhitelistMode_DeniesUnlistedJurisdiction() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetMode(_tokenAddr, true); // whitelist + _policy.SetJurisdiction(_tokenAddr, 840, true); // US allowed + _policy.SetAddressJurisdiction(_alice, 840); + _policy.SetAddressJurisdiction(_bob, 410); // South Korea, not listed + + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeFalse(); + } + + [Fact] + public void BlacklistMode_DeniesListedJurisdiction() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetMode(_tokenAddr, false); // blacklist + _policy.SetJurisdiction(_tokenAddr, 408, true); // North Korea blocked + _policy.SetAddressJurisdiction(_alice, 408); + + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeFalse(); + } + + [Fact] + public void BlacklistMode_AllowsUnlistedJurisdiction() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetMode(_tokenAddr, false); // blacklist + _policy.SetJurisdiction(_tokenAddr, 408, true); // North Korea blocked + _policy.SetAddressJurisdiction(_alice, 840); // US + _policy.SetAddressJurisdiction(_bob, 276); // Germany + + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeTrue(); + } + + [Fact] + public void NoJurisdictionRegistered_DeniesInWhitelistMode() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetMode(_tokenAddr, true); // whitelist + + // Neither Alice nor Bob has a registered jurisdiction — denied in whitelist mode + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeFalse(); + } + + [Fact] + public void NoJurisdictionRegistered_AllowsInBlacklistMode() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetMode(_tokenAddr, false); // blacklist + + // Neither Alice nor Bob has a registered jurisdiction — allowed in blacklist mode + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeTrue(); + } + + [Fact] + public void GetAddressJurisdiction_ReturnsStoredValue() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetAddressJurisdiction(_alice, 840); + + _policy.GetAddressJurisdiction(_alice).Should().Be(840); + } + + [Fact] + public void CheckTransfer_DeniesReceiverInBlockedJurisdiction() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetMode(_tokenAddr, false); // blacklist + _policy.SetJurisdiction(_tokenAddr, 408, true); // blocked + _policy.SetAddressJurisdiction(_bob, 408); + + // Sender has no jurisdiction (allowed), receiver blocked + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeFalse(); + } + + [Fact] + public void SetMode_RevertsForNonAdmin() + { + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.SetMode(_tokenAddr, true)); + msg.Should().Contain("not admin"); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/LockupPolicyTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/LockupPolicyTests.cs new file mode 100644 index 0000000..6de2615 --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/LockupPolicyTests.cs @@ -0,0 +1,122 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class LockupPolicyTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _policyAddr = BasaltTestHost.CreateAddress(0xF1); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF2); + private readonly LockupPolicy _policy; + + public LockupPolicyTests() + { + _host = new BasaltTestHost(); + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy = new LockupPolicy(); + _host.Deploy(_policyAddr, _policy); + Context.IsDeploying = false; + } + + [Fact] + public void SetLockup_StoresUnlockTimestamp() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetLockup(_tokenAddr, _alice, 2_000_000); + + _policy.GetUnlockTime(_tokenAddr, _alice).Should().Be(2_000_000); + } + + [Fact] + public void RemoveLockup_ClearsLockup() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetLockup(_tokenAddr, _alice, 2_000_000); + _policy.RemoveLockup(_tokenAddr, _alice); + + _policy.GetUnlockTime(_tokenAddr, _alice).Should().Be(0); + } + + [Fact] + public void IsLocked_ReturnsTrueBeforeUnlock() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _host.SetBlockTimestamp(1_000_000); + _policy.SetLockup(_tokenAddr, _alice, 2_000_000); + + _policy.IsLocked(_tokenAddr, _alice).Should().BeTrue(); + } + + [Fact] + public void IsLocked_ReturnsFalseAfterUnlock() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetLockup(_tokenAddr, _alice, 2_000_000); + + _host.SetBlockTimestamp(2_000_001); + _policy.IsLocked(_tokenAddr, _alice).Should().BeFalse(); + } + + [Fact] + public void CheckTransfer_AllowsWhenNoLockup() + { + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeTrue(); + } + + [Fact] + public void CheckTransfer_DeniesWhenLocked() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _host.SetBlockTimestamp(1_000_000); + _policy.SetLockup(_tokenAddr, _alice, 2_000_000); + + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeFalse(); + } + + [Fact] + public void CheckTransfer_AllowsAfterUnlock() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.SetLockup(_tokenAddr, _alice, 2_000_000); + + _host.SetBlockTimestamp(2_000_000); + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeTrue(); + } + + [Fact] + public void CheckNftTransfer_DeniesWhenLocked() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _host.SetBlockTimestamp(1_000_000); + _policy.SetLockup(_tokenAddr, _alice, 2_000_000); + + _policy.CheckNftTransfer(_tokenAddr, _alice, _bob, 1).Should().BeFalse(); + } + + [Fact] + public void SetLockup_RevertsForNonAdmin() + { + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.SetLockup(_tokenAddr, _bob, 2_000_000)); + msg.Should().Contain("not admin"); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/PolicyEnforcerTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/PolicyEnforcerTests.cs new file mode 100644 index 0000000..811e2af --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/PolicyEnforcerTests.cs @@ -0,0 +1,235 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class PolicyEnforcerTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF0); + private readonly byte[] _policyAddr1 = BasaltTestHost.CreateAddress(0xF1); + private readonly byte[] _policyAddr2 = BasaltTestHost.CreateAddress(0xF2); + private readonly PolicyEnforcer _enforcer; + + public PolicyEnforcerTests() + { + _host = new BasaltTestHost(); + _host.SetCaller(_admin); + Context.Self = _tokenAddr; + _enforcer = new PolicyEnforcer("test_pol"); + Context.IsDeploying = false; + } + + [Fact] + public void AddPolicy_IncrementsCount() + { + _enforcer.AddPolicy(_policyAddr1); + _enforcer.Count.Should().Be(1); + + _enforcer.AddPolicy(_policyAddr2); + _enforcer.Count.Should().Be(2); + } + + [Fact] + public void AddPolicy_RejectsDuplicates() + { + _enforcer.AddPolicy(_policyAddr1); + var msg = _host.ExpectRevert(() => _enforcer.AddPolicy(_policyAddr1)); + msg.Should().Contain("already registered"); + } + + [Fact] + public void AddPolicy_RejectsInvalidAddress() + { + var msg = _host.ExpectRevert(() => _enforcer.AddPolicy(new byte[10])); + msg.Should().Contain("invalid address"); + } + + [Fact] + public void RemovePolicy_DecrementsAndShifts() + { + _enforcer.AddPolicy(_policyAddr1); + _enforcer.AddPolicy(_policyAddr2); + + _enforcer.RemovePolicy(_policyAddr1); + _enforcer.Count.Should().Be(1); + + // Second policy should now be at index 0 + _enforcer.GetPolicy(0).Should().BeEquivalentTo(_policyAddr2); + } + + [Fact] + public void RemovePolicy_RevertsForUnregistered() + { + var msg = _host.ExpectRevert(() => _enforcer.RemovePolicy(_policyAddr1)); + msg.Should().Contain("not registered"); + } + + [Fact] + public void GetPolicy_ReturnsCorrectAddress() + { + _enforcer.AddPolicy(_policyAddr1); + _enforcer.GetPolicy(0).Should().BeEquivalentTo(_policyAddr1); + } + + [Fact] + public void GetPolicy_RevertsOnOutOfBounds() + { + var msg = _host.ExpectRevert(() => _enforcer.GetPolicy(0)); + msg.Should().Contain("index out of bounds"); + } + + [Fact] + public void EnforceTransfer_PassesWithNoPolicies() + { + // Should not revert + _enforcer.EnforceTransfer(_alice, _bob, new UInt256(100)); + } + + [Fact] + public void EnforceTransfer_PassesWhenPolicyApproves() + { + // Deploy an approving sanctions policy (nothing sanctioned) + var sanctionsAddr = BasaltTestHost.CreateAddress(0xE0); + _host.SetCaller(_admin); + Context.Self = sanctionsAddr; + Context.IsDeploying = true; + var sanctions = new SanctionsPolicy(); + _host.Deploy(sanctionsAddr, sanctions); + Context.IsDeploying = false; + + Context.Self = _tokenAddr; + _enforcer.AddPolicy(sanctionsAddr); + + // Should not revert — no one is sanctioned + _enforcer.EnforceTransfer(_alice, _bob, new UInt256(100)); + } + + [Fact] + public void EnforceTransfer_RevertsWhenPolicyDenies() + { + // Deploy a sanctions policy and sanction Alice + var sanctionsAddr = BasaltTestHost.CreateAddress(0xE0); + _host.SetCaller(_admin); + Context.Self = sanctionsAddr; + Context.IsDeploying = true; + var sanctions = new SanctionsPolicy(); + _host.Deploy(sanctionsAddr, sanctions); + Context.IsDeploying = false; + + _host.SetCaller(_admin); + Context.Self = sanctionsAddr; + sanctions.AddSanction(_alice); + + Context.Self = _tokenAddr; + _enforcer.AddPolicy(sanctionsAddr); + + var msg = _host.ExpectRevert(() => _enforcer.EnforceTransfer(_alice, _bob, new UInt256(100))); + msg.Should().Contain("transfer denied"); + } + + [Fact] + public void RemovePolicy_AllowsReAddingRemovedPolicy() + { + _enforcer.AddPolicy(_policyAddr1); + _enforcer.RemovePolicy(_policyAddr1); + _enforcer.Count.Should().Be(0); + + // Should succeed — existence flag was cleared on remove + _enforcer.AddPolicy(_policyAddr1); + _enforcer.Count.Should().Be(1); + } + + [Fact] + public void EnforceNftTransfer_PassesWithNoPolicies() + { + _enforcer.EnforceNftTransfer(_alice, _bob, 1); + } + + [Fact] + public void EnforceNftTransfer_RevertsWhenPolicyDenies() + { + // Deploy a sanctions policy and sanction Alice + var sanctionsAddr = BasaltTestHost.CreateAddress(0xE0); + _host.SetCaller(_admin); + Context.Self = sanctionsAddr; + Context.IsDeploying = true; + var sanctions = new SanctionsPolicy(); + _host.Deploy(sanctionsAddr, sanctions); + Context.IsDeploying = false; + + _host.SetCaller(_admin); + Context.Self = sanctionsAddr; + sanctions.AddSanction(_alice); + + Context.Self = _tokenAddr; + _enforcer.AddPolicy(sanctionsAddr); + + var msg = _host.ExpectRevert(() => _enforcer.EnforceNftTransfer(_alice, _bob, 1)); + msg.Should().Contain("NFT transfer denied"); + } + + [Fact] + public void EnforceTransfer_MultiplePolicies_FirstPassesSecondDenies() + { + // Deploy two sanctions policies — second one sanctions Bob + var sanctions1Addr = BasaltTestHost.CreateAddress(0xE0); + var sanctions2Addr = BasaltTestHost.CreateAddress(0xE1); + + _host.SetCaller(_admin); + + Context.Self = sanctions1Addr; + Context.IsDeploying = true; + var sanctions1 = new SanctionsPolicy(); + _host.Deploy(sanctions1Addr, sanctions1); + + Context.Self = sanctions2Addr; + var sanctions2 = new SanctionsPolicy(); + _host.Deploy(sanctions2Addr, sanctions2); + Context.IsDeploying = false; + + // Only sanction Bob on the second policy + _host.SetCaller(_admin); + Context.Self = sanctions2Addr; + sanctions2.AddSanction(_bob); + + // Register both policies + Context.Self = _tokenAddr; + _enforcer.AddPolicy(sanctions1Addr); + _enforcer.AddPolicy(sanctions2Addr); + + // Both policies see Bob as sanctioned (BasaltTestHost uses global storage, + // so both SanctionsPolicy instances share the same sanctions list). + // The first policy in registration order denies the transfer. + var msg = _host.ExpectRevert(() => _enforcer.EnforceTransfer(_alice, _bob, new UInt256(100))); + msg.Should().Contain("transfer denied by"); + // Verify the revert message identifies the denying policy address + msg.Should().Contain(Convert.ToHexString(sanctions1Addr)); + } + + [Fact] + public void AddPolicy_RevertsAtMaxPolicies() + { + // Fill up to max + for (byte i = 0; i < (byte)PolicyEnforcer.MaxPolicies; i++) + { + var addr = BasaltTestHost.CreateAddress((byte)(0xA0 + i)); + _enforcer.AddPolicy(addr); + } + _enforcer.Count.Should().Be(PolicyEnforcer.MaxPolicies); + + // One more should fail + var overflow = BasaltTestHost.CreateAddress(0xC0); + var msg = _host.ExpectRevert(() => _enforcer.AddPolicy(overflow)); + msg.Should().Contain("max policy count"); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/PolicyTests/SanctionsPolicyTests.cs b/tests/Basalt.Sdk.Tests/PolicyTests/SanctionsPolicyTests.cs new file mode 100644 index 0000000..3c5ff42 --- /dev/null +++ b/tests/Basalt.Sdk.Tests/PolicyTests/SanctionsPolicyTests.cs @@ -0,0 +1,162 @@ +using Basalt.Core; +using Basalt.Sdk.Contracts; +using Basalt.Sdk.Contracts.Policies; +using Basalt.Sdk.Testing; +using FluentAssertions; +using Xunit; + +namespace Basalt.Sdk.Tests.PolicyTests; + +public class SanctionsPolicyTests : IDisposable +{ + private readonly BasaltTestHost _host; + private readonly byte[] _admin = BasaltTestHost.CreateAddress(1); + private readonly byte[] _alice = BasaltTestHost.CreateAddress(2); + private readonly byte[] _bob = BasaltTestHost.CreateAddress(3); + private readonly byte[] _policyAddr = BasaltTestHost.CreateAddress(0xF1); + private readonly byte[] _tokenAddr = BasaltTestHost.CreateAddress(0xF2); + private readonly SanctionsPolicy _policy; + + public SanctionsPolicyTests() + { + _host = new BasaltTestHost(); + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy = new SanctionsPolicy(); + _host.Deploy(_policyAddr, _policy); + Context.IsDeploying = false; + } + + [Fact] + public void AddSanction_MarksSanctioned() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.AddSanction(_alice); + + _policy.IsSanctioned(_alice).Should().BeTrue(); + _policy.IsSanctioned(_bob).Should().BeFalse(); + } + + [Fact] + public void RemoveSanction_ClearsSanction() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.AddSanction(_alice); + _policy.RemoveSanction(_alice); + + _policy.IsSanctioned(_alice).Should().BeFalse(); + } + + [Fact] + public void CheckTransfer_DeniesFromSanctioned() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.AddSanction(_alice); + + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeFalse(); + } + + [Fact] + public void CheckTransfer_DeniesToSanctioned() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.AddSanction(_bob); + + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeFalse(); + } + + [Fact] + public void CheckTransfer_AllowsUnsanctioned() + { + _policy.CheckTransfer(_tokenAddr, _alice, _bob, new UInt256(100)).Should().BeTrue(); + } + + [Fact] + public void CheckNftTransfer_DeniesFromSanctioned() + { + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.AddSanction(_alice); + + _policy.CheckNftTransfer(_tokenAddr, _alice, _bob, 1).Should().BeFalse(); + } + + [Fact] + public void AddSanction_RevertsForNonAdmin() + { + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.AddSanction(_bob)); + msg.Should().Contain("not admin"); + } + + [Fact] + public void TransferAdmin_TwoStepPattern() + { + var newAdmin = BasaltTestHost.CreateAddress(5); + + // Step 1: Current admin proposes new admin + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.TransferAdmin(newAdmin); + + // New admin cannot use admin functions yet + _host.SetCaller(newAdmin); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.AddSanction(_alice)); + msg.Should().Contain("not admin"); + + // Step 2: New admin accepts + _policy.AcceptAdmin(); + + // New admin can now use admin functions + _policy.AddSanction(_alice); + _policy.IsSanctioned(_alice).Should().BeTrue(); + + // Old admin can no longer use admin functions + _host.SetCaller(_admin); + Context.Self = _policyAddr; + msg = _host.ExpectRevert(() => _policy.AddSanction(_bob)); + msg.Should().Contain("not admin"); + } + + [Fact] + public void TransferAdmin_RevertsForNonAdmin() + { + var newAdmin = BasaltTestHost.CreateAddress(5); + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.TransferAdmin(newAdmin)); + msg.Should().Contain("not admin"); + } + + [Fact] + public void AcceptAdmin_RevertsWhenNoPending() + { + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.AcceptAdmin()); + msg.Should().Contain("no pending admin"); + } + + [Fact] + public void AcceptAdmin_RevertsForWrongCaller() + { + var newAdmin = BasaltTestHost.CreateAddress(5); + _host.SetCaller(_admin); + Context.Self = _policyAddr; + _policy.TransferAdmin(newAdmin); + + // Alice tries to accept (she's not the pending admin) + _host.SetCaller(_alice); + Context.Self = _policyAddr; + var msg = _host.ExpectRevert(() => _policy.AcceptAdmin()); + msg.Should().Contain("not pending admin"); + } + + public void Dispose() => _host.Dispose(); +} diff --git a/tests/Basalt.Sdk.Tests/README.md b/tests/Basalt.Sdk.Tests/README.md index a04de5c..9fb3ffd 100644 --- a/tests/Basalt.Sdk.Tests/README.md +++ b/tests/Basalt.Sdk.Tests/README.md @@ -1,40 +1,80 @@ # Basalt.Sdk.Tests -Unit tests for the Basalt smart contract SDK: storage primitives, contract context, BST-20/BST-721/BST-1155/BST-3525/BST-4626/BST-VC/BST-DID reference implementations, system contracts, cross-contract calls, and the test host. **574 tests.** +Unit tests for the Basalt smart contract SDK: storage primitives, contract context, all BST token standards, system contracts, policy hooks, cross-contract calls, and the test host. **652 tests.** ## Test Coverage | Category | Tests | Description | |----------|-------|-------------| -| BST-1155 | 27 | Multi-token standard: mint, burn, batch transfer, balance queries, approval, URI management, supply tracking | -| BST-DID | 27 | Decentralized identity: DID creation, resolution, document updates, deactivation, controller management, service endpoints | -| BST-20 | 24 | Fungible token standard: initialize, transfer, approve, transferFrom, balanceOf, totalSupply, allowance, edge cases | -| BST-721 | 21 | Non-fungible token standard: mint, transfer, approve, balanceOf, ownerOf, token URI, unauthorized access, burn | -| StoragePrimitives | 21 | StorageValue, StorageMap, StorageList: get/set/delete/contains, type safety, boundary conditions | -| BSTDIDRegistry | 16 | DID registry: registration, lookup, document management, access control, batch operations | -| Context | 15 | Contract context: caller, block timestamp, block height, chain ID, emit events, require/revert, cross-contract calls, reentrancy guard | +| BridgeETH | 68 | EVM bridge: lock/unlock, multisig relayer verification, admin pattern, pause/unpause, deposit lifecycle | +| Governance | 61 | On-chain governance: proposals, quadratic voting, delegation, timelock, executable proposals | +| BST-3525 | 49 | Semi-fungible token (ERC-3525): slots, value transfers, approvals, minting, burning | +| BST-VC | 42 | Verifiable credentials registry: issuance, verification, revocation, schema validation | +| IssuerRegistry | 37 | ZK issuer registry: registration, credential verification, trust anchoring | +| BST-4626 | 31 | Tokenized vault (ERC-4626): deposit, withdraw, share pricing, yield accrual | +| BST-1155 | 27 | Multi-token standard: mint, burn, batch transfer, balance queries, approval, URI, supply | +| BST-DID | 27 | Decentralized identity: DID creation, resolution, document updates, deactivation, controllers | +| StakingPool | 26 | Staking pool: delegation, reward distribution, withdrawal, validator management | +| BST-20 | 24 | Fungible token standard: initialize, transfer, approve, transferFrom, balanceOf, totalSupply | +| BST-721 | 21 | Non-fungible token standard: mint, transfer, approve, balanceOf, ownerOf, token URI, burn | +| StoragePrimitives | 21 | StorageValue, StorageMap, StorageList: get/set/delete/contains, type safety, boundaries | +| Escrow | 18 | Escrow contract: create, release, dispute, refund, deadline enforcement | +| SchemaRegistry | 17 | ZK schema registry: registration, lookup, schema management | +| BSTDIDRegistry | 16 | DID registry: registration, lookup, document management, access control | +| BasaltNameService | 16 | BNS: name registration, resolution, ownership transfer | +| PolicyEnforcer | 15 | Policy registration, multi-policy enforcement, NFT enforcement, max policy limit | +| Context | 15 | Contract context: caller, block info, events, require/revert, reentrancy guard | | BST-1155Token | 14 | BST-1155 token instance: mint, transfer, batch operations, approval management | +| WBSLT | 12 | Wrapped BSLT: deposit, withdraw, transfer, ERC-20 compatibility | +| SanctionsPolicy | 11 | Sanctions screening: add/remove sanctioned addresses, two-step admin transfer | | BST-20Token | 10 | BST-20 token instance: deployment, transfer, approval flows | +| BST-20 Policy | 9 | BST-20 transfer policy integration: holding limit, lockup, jurisdiction, sanctions | +| JurisdictionPolicy | 9 | Jurisdiction whitelist/blacklist modes, country registration, unregistered address handling | +| LockupPolicy | 9 | Time-based lockup: period management, transfer blocking during lockup, admin transfer | | BST-721Token | 9 | BST-721 token instance: minting, transfer, ownership verification | -| TestHost | 8 | BasaltTestHost: deploy, call, view, expect revert, snapshots, block advancement, event capture | -| CrossContractCall | 5 | Cross-contract call mechanism: invocation, return value forwarding, reentrancy checks | +| CrossContractCall | 8 | Cross-contract calls: invocation, return value forwarding, reentrancy checks | +| HoldingLimitPolicy | 8 | Max balance enforcement: limit configuration, overflow prevention, admin transfer | +| TestHost | 8 | BasaltTestHost: deploy, call, view, expect revert, snapshots, event capture | +| BST-721 Policy | 5 | BST-721 NFT transfer policy integration: per-token policy enforcement | +| EndToEndPolicy | 5 | Multi-policy enforcement, static call protection, cross-contract re-entry | +| BST-1155 Policy | 4 | BST-1155 multi-token policy integration: batch transfer enforcement | -**Total: 574 tests** +**Total: 652 tests** ## Test Files -- `BST1155Tests.cs` -- BST-1155 multi-token standard: mint, burn, batch transfer, balances, approvals +- `BridgeETHTests.cs` -- EVM bridge: lock/unlock, multisig, admin, pause, deposit lifecycle +- `GovernanceTests.cs` -- On-chain governance: proposals, voting, delegation, timelock +- `BST3525TokenTests.cs` -- BST-3525 semi-fungible token: slots, value transfers, approvals +- `BSTVCRegistryTests.cs` -- Verifiable credentials: issuance, verification, revocation +- `IssuerRegistryTests.cs` -- ZK issuer registry: registration, credential verification +- `BST4626VaultTests.cs` -- BST-4626 tokenized vault: deposit, withdraw, share pricing +- `BST1155Tests.cs` -- BST-1155 multi-token: mint, burn, batch transfer, balances, approvals - `BSTDIDTests.cs` -- Decentralized identity: DID creation, resolution, updates, deactivation +- `StakingPoolTests.cs` -- Staking pool: delegation, rewards, withdrawal - `BST20Tests.cs` -- BST-20 fungible token: initialization, transfer, approval, allowance - `BST721Tests.cs` -- BST-721 non-fungible token: minting, transfer, ownership, token URI - `StorageTests.cs` -- Storage primitives: StorageValue, StorageMap, StorageList +- `EscrowTests.cs` -- Escrow: create, release, dispute, refund +- `SchemaRegistryTests.cs` -- ZK schema registry: registration, lookup - `BSTDIDRegistryTests.cs` -- DID registry: registration, lookup, document management +- `BasaltNameServiceTests.cs` -- BNS: name registration, resolution, ownership - `ContextTests.cs` -- Contract execution context: caller, block info, events, revert, reentrancy - `BST1155TokenTests.cs` -- BST-1155 token instance operations +- `WBSLTTests.cs` -- Wrapped BSLT: deposit, withdraw, transfer - `BST20TokenTests.cs` -- BST-20 token instance operations - `BST721TokenTests.cs` -- BST-721 token instance operations -- `TestHostTests.cs` -- Test host: contract deployment, invocation, snapshots, event capture - `CrossContractCallTests.cs` -- Cross-contract call: invocation, return values, reentrancy guard +- `TestHostTests.cs` -- Test host: contract deployment, invocation, snapshots, event capture +- `PolicyTests/PolicyEnforcerTests.cs` -- Policy registration, multi-policy enforcement, max limit +- `PolicyTests/SanctionsPolicyTests.cs` -- Sanctions screening, admin transfer pattern +- `PolicyTests/BST20PolicyIntegrationTests.cs` -- BST-20 transfer policy integration +- `PolicyTests/JurisdictionPolicyTests.cs` -- Jurisdiction whitelist/blacklist modes +- `PolicyTests/LockupPolicyTests.cs` -- Time-based lockup enforcement +- `PolicyTests/HoldingLimitPolicyTests.cs` -- Max balance enforcement +- `PolicyTests/BST721PolicyIntegrationTests.cs` -- BST-721 NFT policy integration +- `PolicyTests/EndToEndPolicyTests.cs` -- Multi-policy enforcement, static call, re-entry +- `PolicyTests/BST1155PolicyIntegrationTests.cs` -- BST-1155 batch transfer policy ## Running diff --git a/tests/Basalt.Sdk.Wallet.Tests/DexSwapIntentBuilderTests.cs b/tests/Basalt.Sdk.Wallet.Tests/DexSwapIntentBuilderTests.cs new file mode 100644 index 0000000..7c7792c --- /dev/null +++ b/tests/Basalt.Sdk.Wallet.Tests/DexSwapIntentBuilderTests.cs @@ -0,0 +1,156 @@ +using System.Buffers.Binary; +using Xunit; +using FluentAssertions; +using Basalt.Core; +using Basalt.Crypto; +using Basalt.Execution; +using Basalt.Execution.Dex; +using Basalt.Sdk.Wallet.Transactions; + +namespace Basalt.Sdk.Wallet.Tests; + +public sealed class DexSwapIntentBuilderTests +{ + private static readonly Address TokenA = new(new byte[20] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }); + private static readonly Address TokenB = new(new byte[20] { 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }); + + [Fact] + public void Plaintext_Build_SetsTypeAndDefaults() + { + var tx = DexSwapIntentBuilder.Create() + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(new UInt256(1000)) + .WithMinAmountOut(new UInt256(900)) + .Build(); + + tx.Type.Should().Be(TransactionType.DexSwapIntent); + tx.To.Should().Be(DexState.DexAddress); + tx.GasLimit.Should().Be(80_000UL); + tx.Data.Should().HaveCount(114); + } + + [Fact] + public void Plaintext_Roundtrip_MatchesParsedIntent() + { + var amountIn = new UInt256(5_000_000); + var minAmountOut = new UInt256(4_500_000); + ulong deadline = 12345; + + var tx = DexSwapIntentBuilder.Create() + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(amountIn) + .WithMinAmountOut(minAmountOut) + .WithDeadline(deadline) + .WithAllowPartialFill(true) + .WithSender(TokenA) // use TokenA as sender for roundtrip + .Build(); + + var parsed = ParsedIntent.Parse(tx); + + parsed.Should().NotBeNull(); + parsed!.Value.TokenIn.Should().Be(TokenA); + parsed.Value.TokenOut.Should().Be(TokenB); + parsed.Value.AmountIn.Should().Be(amountIn); + parsed.Value.MinAmountOut.Should().Be(minAmountOut); + parsed.Value.Deadline.Should().Be(deadline); + parsed.Value.AllowPartialFill.Should().BeTrue(); + } + + [Fact] + public void Encrypted_Build_ProducesLargerPayload() + { + // Use the real BLS12-381 G1 generator — a known-valid compressed point + var gpk = new BlsPublicKey(BlsCrypto.G1Generator()); + + var tx = DexSwapIntentBuilder.CreateEncrypted(gpk, epoch: 42) + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(new UInt256(1000)) + .WithMinAmountOut(new UInt256(900)) + .Build(); + + tx.Type.Should().Be(TransactionType.DexEncryptedSwapIntent); + tx.Data.Length.Should().BeGreaterThan(114); + // Encrypted format: 8 (epoch) + 48 (C1) + 12 (nonce) + 114 (ciphertext) + 16 (tag) = 198 + tx.Data.Length.Should().BeGreaterOrEqualTo(EncryptedIntent.MinDataLength); + } + + [Fact] + public void AllowPartialFill_SetsFlag() + { + var txWithPartial = DexSwapIntentBuilder.Create() + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(new UInt256(1000)) + .WithMinAmountOut(new UInt256(900)) + .WithAllowPartialFill(true) + .Build(); + + var txWithoutPartial = DexSwapIntentBuilder.Create() + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(new UInt256(1000)) + .WithMinAmountOut(new UInt256(900)) + .WithAllowPartialFill(false) + .Build(); + + (txWithPartial.Data[113] & 0x01).Should().Be(1); + (txWithoutPartial.Data[113] & 0x01).Should().Be(0); + } + + [Fact] + public void Deadline_EncodedAsBigEndian() + { + ulong deadline = 0x0102030405060708; + + var tx = DexSwapIntentBuilder.Create() + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(new UInt256(1000)) + .WithMinAmountOut(new UInt256(900)) + .WithDeadline(deadline) + .Build(); + + var encoded = BinaryPrimitives.ReadUInt64BigEndian(tx.Data.AsSpan(105, 8)); + encoded.Should().Be(deadline); + } + + [Fact] + public void Version_Byte_IsOne() + { + var tx = DexSwapIntentBuilder.Create() + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(new UInt256(1000)) + .WithMinAmountOut(new UInt256(900)) + .Build(); + + tx.Data[0].Should().Be(1); + } + + [Fact] + public void FluentMethods_ChainCorrectly() + { + var tx = DexSwapIntentBuilder.Create() + .WithTokenIn(TokenA) + .WithTokenOut(TokenB) + .WithAmountIn(new UInt256(1000)) + .WithMinAmountOut(new UInt256(900)) + .WithGasLimit(100_000) + .WithGasPrice(new UInt256(5)) + .WithMaxFeePerGas(new UInt256(20)) + .WithMaxPriorityFeePerGas(new UInt256(2)) + .WithChainId(42) + .WithNonce(7) + .Build(); + + tx.GasLimit.Should().Be(100_000UL); + tx.GasPrice.Should().Be(new UInt256(5)); + tx.MaxFeePerGas.Should().Be(new UInt256(20)); + tx.MaxPriorityFeePerGas.Should().Be(new UInt256(2)); + tx.ChainId.Should().Be(42U); + tx.Nonce.Should().Be(7UL); + } +}