Skip to content

Policy hooks for BST token standards#73

Merged
0xZunia merged 10 commits intomainfrom
feature/policy-hooks
Mar 14, 2026
Merged

Policy hooks for BST token standards#73
0xZunia merged 10 commits intomainfrom
feature/policy-hooks

Conversation

@0xZunia
Copy link
Copy Markdown
Contributor

@0xZunia 0xZunia commented Mar 12, 2026

Summary

  • Adds a composable policy hook system for all BST token standards (BST-20, BST-721, BST-1155, BST-3525), enabling compliance enforcement (sanctions, holding limits, lockups, jurisdiction restrictions) via cross-contract calls before every transfer
  • Includes 4 reference policy contracts, PolicyEnforcer engine, 4 new Roslyn analyzers (BST009–012), example contracts, and comprehensive test coverage
  • Adds GET /v1/dex/quote REST endpoint returning expected swap output, price impact, fees, TWAP, and volatility for the Caldera Fusion DEX
  • Adds DexSwapIntentBuilder wallet SDK builder for plaintext and encrypted swap intent transactions, plus GetDexQuoteAsync on the RPC client and provider

What's included

Policy infrastructure

  • ITransferPolicy / INftTransferPolicy interfaces — return true/false to allow/deny
  • PolicyEnforcer — storage-backed policy list with add/remove/enforce, max 16 policies per token
  • Two-step TransferAdmin/AcceptAdmin on all policy contracts

4 reference policies

Contract Type ID Purpose
HoldingLimitPolicy 0x0008 Max balance caps per holder (queries BalanceOf via callback)
LockupPolicy 0x0009 Time-based transfer lockups
JurisdictionPolicy 0x000A Country whitelist/blacklist (denies unregistered addresses in whitelist mode)
SanctionsPolicy 0x000B Sanctioned address blocking

Token standard hooks

  • BST-20, BST-721, BST-1155, BST-3525 all call PolicyEnforcer before state mutations
  • Zero policies = single storage read (backward compatible)
  • Mint/burn bypass policies (admin-only operations)
  • BST-4626 Vault inherits hooks from BST-20 automatically

Re-entrancy model

  • Relaxed from hard-block to static-mode re-entry — policies can read state (e.g. BalanceOf) via callback but cannot write
  • Context.IsStaticCall enforced at ContractStorage.Set/Delete level (internal setter, not escapable by contract code)
  • ActiveCallers uses ref-counting (Dictionary<string, int>) so nested outgoing calls from the same contract are tracked correctly

4 new analyzers

ID Rule
BST009 Unchecked CallContract<bool> return value
BST010 Storage .Set()/.Delete() before EnforceTransfer()
BST011 Dictionary<>/HashSet<> fields in contracts (non-deterministic iteration)
BST012 Transfer methods without policy enforcement (covers all 4 token standards)

DEX quote endpoint

  • GET /v1/dex/quote?tokenIn={addr}&tokenOut={addr}&amountIn={uint256}&feeBps={uint?} — returns best-pool output across fee tiers [1,5,30,100]
  • Response includes: amountOut, effectiveFeeBps, priceImpactBps, spotPrice, TWAP, volatilityBps, isConcentrated
  • Handles both constant-product and concentrated liquidity pools
  • Proper input validation (address format, non-zero amount, distinct tokens)

Wallet SDK additions

  • DexSwapIntentBuilder — fluent builder for plaintext (type 10) and encrypted (type 18) swap intent transactions
  • DexQuoteInfo model + GetDexQuoteAsync on IBasaltClient, BasaltClient, and BasaltProvider
  • 114-byte payload format matching ParsedIntent.Parse() exactly

Examples

  • ComplianceTokenExample — BST-20 with holding limits + jurisdiction + lockup
  • PolicyVaultExample — BST-4626 vault with dual-layer policy enforcement

Test plan

  • 2,891 tests pass, 0 failures, 0 build warnings
  • Unit tests for each policy contract (holding limit, lockup, jurisdiction, sanctions)
  • PolicyEnforcer tests (add/remove/shift, duplicates, max cap, NFT enforcement, multi-policy deny)
  • Admin transfer two-step pattern tests (success, unauthorized, no-pending, wrong-caller)
  • Integration tests per token standard (BST-20, BST-721, BST-1155)
  • End-to-end lifecycle (deploy → configure → enforce → time-pass → remove)
  • Static call protection verified (A→B→A callback, write blocked, read allowed)
  • Analyzer tests (positive, negative, outside-contract for all 4 analyzers)
  • Jurisdiction whitelist-mode denies unregistered addresses
  • DexSwapIntentBuilder: plaintext build, roundtrip with ParsedIntent.Parse, encrypted build, partial fill flag, deadline encoding, version byte, fluent chaining
  • DEX quote math: known reserves output, large swap price impact, best-pool selection, price impact calculation, spot price, dynamic fee thresholds

0xZunia added 3 commits March 9, 2026 16:53
…alyzers

Add modular transfer policy enforcement to all BST token standards (BST-20,
BST-721, BST-1155, BST-3525, BST-4626). Policies are deployed as independent
contracts implementing ITransferPolicy/INftTransferPolicy and registered on
tokens via PolicyEnforcer. Zero policies = zero overhead.

Reference policies: HoldingLimitPolicy (0x0008), LockupPolicy (0x0009),
JurisdictionPolicy (0x000A), SanctionsPolicy (0x000B).

New Roslyn analyzers: BST009 (unchecked cross-contract return), BST010
(storage write before policy check), BST011 (non-deterministic collections),
BST012 (missing policy enforcement in transfer override).

Reentrancy guard relaxed to allow A→B→A callback chains (policy contracts
querying token.BalanceOf). 79 new tests, all 2,868 passing.
…ision, edge cases

Static call enforcement: re-entrant callbacks (A→B→A) are now automatically
forced into read-only mode. ContractStorage.Set/Delete revert with "Static
call: state modification not allowed" when IsStaticCall is true. ActiveCallers
tracking detects re-entry without blocking view-method callbacks. Closes the
reentrancy attack vector where a malicious policy could call back into
state-mutating entrypoints.

PolicyEnforcer: remove dead _policySlots field, replace O(n) duplicate scan
with O(1) _policyExists StorageMap, remove dead null guard in GetPolicy.

HoldingLimitPolicy: catch BalanceOf call failures (incompatible token) and
UInt256 overflow gracefully — return false instead of crashing.

JurisdictionPolicy: implement INftTransferPolicy so it works when registered
on BST-721/3525 tokens.

CrossContractReturnAnalyzer (BST009): use Roslyn semantic model to resolve
Context.CallContract<bool> instead of string matching.

PolicyOrderingAnalyzer (BST010): verify .Set() receiver is a Basalt storage
type via semantic model to avoid false positives on unrelated Set methods.

8 new tests covering static call protection, re-add after remove, overflow
handling, and jurisdiction+NFT integration.
…s, test coverage

- Context.IsStaticCall: public set → internal set (prevent contract escape)
- JurisdictionPolicy: deny unregistered addresses in whitelist mode
- PolicyEnforcer: max 16 policies cap, revert on corrupted slots
- Two-step TransferAdmin/AcceptAdmin on all 4 policy contracts
- CollectionOrderingAnalyzer: StartsWith → exact match, fix field detection
- PolicyEnforcementAnalyzer: extend to BST1155/BST3525 transfer methods
- PolicyOrderingAnalyzer: also flag .Delete() before enforcement
- Fix StaticCall E2E test (real A→B→A pattern with assertions)
- Add 15 new tests (NFT enforcement, multi-policy deny, max cap, admin transfer)
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a composable “policy hook” layer to Basalt’s BST token standards so transfers can be approved/denied by registered policy contracts (sanctions, lockups, holding limits, jurisdiction rules), plus supporting analyzers, docs, examples, and tests.

Changes:

  • Introduces PolicyEnforcer plus policy interfaces/events and four reference policy contracts.
  • Integrates policy enforcement into BST-20 / 721 / 1155 / 3525 transfer paths and adds a static-callback reentrancy model (Context.IsStaticCall + storage write blocking).
  • Adds Roslyn analyzers BST009–BST012 and extensive unit/integration coverage, with docs/examples and registry updates.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/Basalt.Sdk.Tests/PolicyTests/SanctionsPolicyTests.cs Unit tests for sanctions list and admin two-step.
tests/Basalt.Sdk.Tests/PolicyTests/PolicyEnforcerTests.cs Unit tests for policy list mgmt and enforcement behavior.
tests/Basalt.Sdk.Tests/PolicyTests/LockupPolicyTests.cs Unit tests for lockup enforcement and admin gating.
tests/Basalt.Sdk.Tests/PolicyTests/JurisdictionPolicyTests.cs Unit tests for whitelist/blacklist jurisdiction enforcement.
tests/Basalt.Sdk.Tests/PolicyTests/HoldingLimitPolicyTests.cs Unit tests for holding limits including overflow deny behavior.
tests/Basalt.Sdk.Tests/PolicyTests/EndToEndPolicyTests.cs End-to-end lifecycle tests + static-callback behavior + NFT integration.
tests/Basalt.Sdk.Tests/PolicyTests/BST721PolicyIntegrationTests.cs BST-721 transfer integration with policy enforcement.
tests/Basalt.Sdk.Tests/PolicyTests/BST20PolicyIntegrationTests.cs BST-20 transfer/transferFrom integration with policies.
tests/Basalt.Sdk.Tests/PolicyTests/BST1155PolicyIntegrationTests.cs BST-1155 transfer and batch transfer integration with policies.
tests/Basalt.Sdk.Tests/CrossContractCallTests.cs Tests for static-mode re-entrant callbacks and state-write blocking.
tests/Basalt.Sdk.Analyzers.Tests/AnalyzerTests.cs Adds analyzer tests for BST009–BST012.
src/sdk/Basalt.Sdk.Contracts/Storage.cs Blocks storage Set/Delete when Context.IsStaticCall is true.
src/sdk/Basalt.Sdk.Contracts/Standards/BST721Token.cs Adds policy management + enforces policies before NFT state mutation.
src/sdk/Basalt.Sdk.Contracts/Standards/BST3525Token.cs Adds policy mgmt + enforces on value transfers and token transfers.
src/sdk/Basalt.Sdk.Contracts/Standards/BST20Token.cs Adds policy mgmt + enforces before fungible state mutation.
src/sdk/Basalt.Sdk.Contracts/Standards/BST1155Token.cs Adds policy mgmt + enforces (including batch pre-check) before mutations.
src/sdk/Basalt.Sdk.Contracts/README.md Documents the new policy hook architecture and usage.
src/sdk/Basalt.Sdk.Contracts/Policies/SanctionsPolicy.cs Implements sanctions list policy + two-step admin transfer.
src/sdk/Basalt.Sdk.Contracts/Policies/PolicyEnforcer.cs Storage-backed policy registry and enforcement via cross-contract calls.
src/sdk/Basalt.Sdk.Contracts/Policies/LockupPolicy.cs Implements per-address per-token time lockups + two-step admin transfer.
src/sdk/Basalt.Sdk.Contracts/Policies/JurisdictionPolicy.cs Implements whitelist/blacklist jurisdiction checks + admin controls.
src/sdk/Basalt.Sdk.Contracts/Policies/ITransferPolicy.cs Adds policy interfaces plus policy-added/removed/denied events.
src/sdk/Basalt.Sdk.Contracts/Policies/HoldingLimitPolicy.cs Implements recipient holding limit checks via token balance callback.
src/sdk/Basalt.Sdk.Contracts/Context.cs Implements static-mode re-entry detection and restores static state across call chains.
src/sdk/Basalt.Sdk.Analyzers/README.md Documents new analyzer rules BST009–BST012.
src/sdk/Basalt.Sdk.Analyzers/PolicyOrderingAnalyzer.cs New BST010 analyzer: detects storage writes before enforcement.
src/sdk/Basalt.Sdk.Analyzers/PolicyEnforcementAnalyzer.cs New BST012 analyzer: detects transfer overrides missing enforcement.
src/sdk/Basalt.Sdk.Analyzers/DiagnosticIds.cs Registers descriptors for BST009–BST012.
src/sdk/Basalt.Sdk.Analyzers/CrossContractReturnAnalyzer.cs New BST009 analyzer: discarded CallContract<bool> return value.
src/sdk/Basalt.Sdk.Analyzers/CollectionOrderingAnalyzer.cs New BST011 analyzer: bans Dictionary/HashSet usage in contracts.
src/sdk/Basalt.Sdk.Analyzers/AnalyzerReleases.Unshipped.md Records newly added analyzer IDs.
src/execution/Basalt.Execution/VM/ContractRegistry.cs Registers policy contracts type IDs 0x0008–0x000B.
examples/Basalt.Example.Contracts/PolicyVaultExample.cs Example showing dual-layer (asset + vault share) policy enforcement.
examples/Basalt.Example.Contracts/ComplianceTokenExample.cs Example showing BST-20 with sanctions/lockup/jurisdiction policies.
examples/Basalt.Example.Contracts/Basalt.Example.Contracts.csproj Adds a non-packable example project referencing SDK/testing.
README.md Mentions policy hooks and updates overall test count blurb.
Basalt.sln Adds the example contracts project to the solution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

0xZunia added 2 commits March 12, 2026 21:19
Update test counts in 5 test READMEs to match current suite (2,891 total).
Rewrite Sdk.Tests README with all 32 test categories including policy hooks.
Add implementation status banners (DONE/PARTIAL/READY/NEEDS ORACLE) to 28
smart-contract specs and create smart-contracts/README.md catalog.
Update root README with current project/test counts and structure.
…static guard

Fix BST010 diagnostic to show actual method name (.Set/.Delete) instead
of hardcoded ".Set()". Correct JurisdictionPolicy doc comment to match
admin-only implementation. Add IsStaticCall guard to ContractStorage.Clear.
Reword README overhead claim to "near-zero (single storage read)".
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 70 out of 70 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +36 to +45
// === 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);

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example deploys both a BST20Token asset and a BST4626Vault (which inherits BST20Token) into the same BasaltTestHost. BasaltTestHost uses a single global ContractStorage without per-contract address scoping, and BST20Token uses fixed storage prefixes (e.g., "balances"/"allowances"), so the asset and vault share-token storage can collide and the example can behave incorrectly. Consider isolating storage per deployed contract in the test host (or avoid co-deploying multiple BST20-based contracts in one host in this example).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix in this PR. This is a known limitation of BasaltTestHost which uses global (non-address-scoped) storage. The example is intentionally simple - it demonstrates the policy pattern, not production storage isolation. Fixing this requires per-contract storage scoping in BasaltTestHost, which is a separate infrastructure improvement. The example already works correctly for its purpose (showing policy enforcement flow).

Comment on lines +81 to +86
[Fact]
public void CheckTransfer_AllowsWhenNoLimitConfigured()
{
var result = _host.Call(() => _policy.CheckTransfer(_tokenAddr, _admin, _alice, new UInt256(999)));
result.Should().BeTrue();
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, the call to _policy.CheckTransfer is executed without setting Context.Self back to the policy address. Because the constructor leaves Context.Self as _tokenAddr, this test currently depends on BasaltTestHost’s global (non-address-scoped) storage behavior and won’t accurately model real execution where Self would be the policy contract. Set Context.Self = _policyAddr (and caller as needed) before invoking policy methods to keep tests representative and robust.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed. The tests DO set Context.Self = _policyAddr before every policy method call (see lines 39, 50, 63, 74, 84, 93). BasaltTestHost.PrepareContext() only sets Caller/BlockTimestamp/BlockHeight/CallDepth - it does not touch Context.Self, which is managed explicitly. The constructor sets Self = _tokenAddr at the end (for the BST20Token deploy), but each test method resets it to _policyAddr before invoking policy methods.

0xZunia added 2 commits March 12, 2026 22:04
…rd, test fixes

Use semantic model in BST012 analyzer to walk full inheritance chain
(catches indirect BST token derivation). Add null guard to
PolicyEnforcer.GetPolicy. Fix JurisdictionPolicy class doc. Set
Context.Self in HoldingLimitPolicyTests. Tighten multi-policy assertion.
GET /v1/dex/quote returns expected output, price impact, fees, TWAP,
and volatility for a token pair swap. Searches all fee tiers for the
best output, supports both constant-product and concentrated liquidity
pools (with proper sqrtPrice-derived spot price for CL pools).

DexSwapIntentBuilder encodes the 114-byte intent payload for plaintext
(type 10) and encrypted (type 18) swap intents, matching ParsedIntent
wire format exactly. Wallet SDK client and provider expose GetDexQuoteAsync.

14 new tests covering builder roundtrip, payload encoding, quote math,
best-pool selection, price impact formula, and dynamic fee behavior.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 80 out of 80 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +62 to +77
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<UInt256>(token, "BalanceOf", to);
}
catch
{
return false;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed - documented the constraint. Added <remarks> explicitly stating this policy targets BalanceOf(byte[]) -> UInt256 (BST-20/BST-721) and that BST-1155/BST-3525 will fail and be denied conservatively. We chose not to introduce a universal balance interface or standard-specific policy variants in this PR because: (1) the try/catch already handles incompatible signatures safely (deny, not crash), (2) a universal IBalanceQuery interface would require modifying all 4 token standards and their source-generated dispatch - a larger refactor best done in a dedicated PR, and (3) holding limits are most commonly needed on fungible tokens (BST-20) where the signature already works.

0xZunia added 3 commits March 13, 2026 12:42
…uit breaker cooldown

Move behind-detection before the circuit breaker check so sync can
trigger even during cooldown. Lower the peer gap threshold from >10
to >0 so a single-block gap is enough to initiate catch-up sync.
Previously, the circuit breaker's `continue` skipped behind-detection
entirely, and the >10 threshold meant small gaps never triggered sync,
causing a permanent stall when all validators tripped simultaneously.
@0xZunia 0xZunia merged commit baca698 into main Mar 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants