From 3c99509dc6f0cf25d04cd84939ca4e79cf941596 Mon Sep 17 00:00:00 2001 From: Festivemena Date: Tue, 9 Sep 2025 19:42:07 +0100 Subject: [PATCH 01/14] fixed sidebar for Linkdrop --- website/docusaurus.config.js | 14 -------------- website/sidebars.js | 5 +++++ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 7b98d2f5d95..6fe3d1b41db 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -238,20 +238,6 @@ const config = { ] }, - { - label: 'Near Drop', to: '#', description: "Create contracts that drops assets", - subitems: [ - { label: 'Introduction', to: '/tutorials/neardrop/introduction', description: "Create your first smart contract" }, - { label: 'Contract Architecture', to: '/tutorials/neardrop/contract-architecture', description: "Understand how the NEAR Drop contract works" }, - { label: 'Near Drops', to: '/tutorials/neardrop/near-drops', description: "Learn how to create Native Near drop contract " }, - { label: 'Ft Drops', to: '/tutorials/neardrop/ft-drops', description: "Learn to create NEP-141 Fungible Token drop contract" }, - { label: 'NFT Drops', to: '/tutorials/neardrop/nft-drops', description: "Learn to create NFT drop contract" }, - { label: 'Access Keys', to: '/tutorials/neardrop/access-keys', description: "Enable gasless operations in your drop contract" }, - { label: 'Account Creation', to: '/tutorials/neardrop/account-creation', description: "Enable Users create new accounts to claim the drops" }, - { label: 'Frontend Integration', to: '/tutorials/neardrop/frontend', description: "Enable easy creation and claims for your contract" }, - - ] - }, { label: 'App Development', to: '#', description: "Supercharge your apps with NEAR", subitems: [ diff --git a/website/sidebars.js b/website/sidebars.js index 1435042a99a..9866645786c 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -273,6 +273,11 @@ const sidebar = { 'tutorials/neardrop/introduction', 'tutorials/neardrop/contract-architecture', 'tutorials/neardrop/near-drops', + 'tutorials/neardrop/ft-drops', + 'tutorials/neardrop/nft-drops', + 'tutorials/neardrop/access-keys', + 'tutorials/neardrop/account-creation', + 'tutorials/neardrop/frontend' ]}, 'tutorials/examples/xcc', 'tutorials/examples/advanced-xcc', From 6b9d373047894ec143ffbb7567fea4f1de8b55e2 Mon Sep 17 00:00:00 2001 From: Festivemena Date: Thu, 11 Sep 2025 10:01:31 +0100 Subject: [PATCH 02/14] Added Tutorials for Advanced-XCC, Donation and Coin Flip --- .gitignore | 1 + docs/tutorials/advanced-xcc/0-introduction.md | 99 + docs/tutorials/advanced-xcc/1-setup.md | 302 +++ .../tutorials/advanced-xcc/2-batch-actions.md | 404 ++++ .../advanced-xcc/3-parallel-execution.md | 668 +++++++ .../advanced-xcc/4-response-handling.md | 1703 +++++++++++++++++ .../advanced-xcc/5-testing-deployment.md | 0 docs/tutorials/coin-flip/0-introduction.md | 82 + .../coin-flip/1-understanding-randomness.md | 161 ++ docs/tutorials/coin-flip/2-setup.md | 314 +++ docs/tutorials/coin-flip/3-contract.md | 540 ++++++ .../coin-flip/4-randomness-implementation.md | 516 +++++ docs/tutorials/coin-flip/5-testing.md | 946 +++++++++ .../coin-flip/6-advanced-patterns.md | 735 +++++++ docs/tutorials/donation/0-introduction.md | 54 + docs/tutorials/donation/1-setup.md | 222 +++ docs/tutorials/donation/2-contract.md | 377 ++++ docs/tutorials/donation/3-tracking.md | 499 +++++ docs/tutorials/donation/4-queries.md | 820 ++++++++ docs/tutorials/donation/5-deploy.md | 743 +++++++ docs/tutorials/donation/6-frontend.md | 1230 ++++++++++++ 21 files changed, 10416 insertions(+) create mode 100644 docs/tutorials/advanced-xcc/0-introduction.md create mode 100644 docs/tutorials/advanced-xcc/1-setup.md create mode 100644 docs/tutorials/advanced-xcc/2-batch-actions.md create mode 100644 docs/tutorials/advanced-xcc/3-parallel-execution.md create mode 100644 docs/tutorials/advanced-xcc/4-response-handling.md create mode 100644 docs/tutorials/advanced-xcc/5-testing-deployment.md create mode 100644 docs/tutorials/coin-flip/0-introduction.md create mode 100644 docs/tutorials/coin-flip/1-understanding-randomness.md create mode 100644 docs/tutorials/coin-flip/2-setup.md create mode 100644 docs/tutorials/coin-flip/3-contract.md create mode 100644 docs/tutorials/coin-flip/4-randomness-implementation.md create mode 100644 docs/tutorials/coin-flip/5-testing.md create mode 100644 docs/tutorials/coin-flip/6-advanced-patterns.md create mode 100644 docs/tutorials/donation/0-introduction.md create mode 100644 docs/tutorials/donation/1-setup.md create mode 100644 docs/tutorials/donation/2-contract.md create mode 100644 docs/tutorials/donation/3-tracking.md create mode 100644 docs/tutorials/donation/4-queries.md create mode 100644 docs/tutorials/donation/5-deploy.md create mode 100644 docs/tutorials/donation/6-frontend.md diff --git a/.gitignore b/.gitignore index 9707d0204ec..315b1d302c1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ website/i18n/* website/.docusaurus/* website/package-lock.json website/yarn.lock +website/pnpm-lock.yaml neardev .idea .docz diff --git a/docs/tutorials/advanced-xcc/0-introduction.md b/docs/tutorials/advanced-xcc/0-introduction.md new file mode 100644 index 00000000000..2fe1552078d --- /dev/null +++ b/docs/tutorials/advanced-xcc/0-introduction.md @@ -0,0 +1,99 @@ +--- +id: advanced-xcc-introduction +title: Advanced Cross-Contract Calls on NEAR +sidebar_label: Introduction +description: "Master complex cross-contract call patterns in NEAR Protocol, including batch operations, parallel execution, and advanced error handling." +--- + +Cross-contract calls are one of NEAR's most powerful features, enabling smart contracts to interact with each other seamlessly. While basic cross-contract calls allow simple interactions, advanced patterns unlock the full potential of NEAR's composable architecture. + +In this comprehensive tutorial, you'll learn to build sophisticated multi-contract interactions that can batch operations, execute calls in parallel, and handle complex response patterns - all while maintaining proper error handling and gas management. + +## What You'll Build + +By the end of this tutorial, you'll have a complete understanding of how to: + +- **Batch multiple actions** to the same contract with atomic rollback capabilities +- **Execute parallel calls** to different contracts simultaneously +- **Handle complex responses** from multiple contract interactions +- **Manage gas efficiently** across multiple contract calls +- **Implement robust error handling** for multi-contract scenarios + +## Real-World Applications + +These advanced patterns are essential for building: + +- **DeFi protocols** that interact with multiple token contracts and AMMs +- **Cross-chain bridges** that coordinate with multiple validator contracts +- **Gaming platforms** that manage assets across different contract systems +- **DAO governance** systems that execute proposals across multiple contracts +- **NFT marketplaces** that coordinate with various collection contracts + +## Tutorial Structure + +This tutorial is organized into focused, hands-on chapters: + +1. **[Project Setup](1-setup.md)** - Get the example project running locally +2. **[Batch Actions](2-batch-actions.md)** - Learn to batch multiple calls with atomic rollback +3. **[Parallel Execution](3-parallel-execution.md)** - Execute multiple contracts simultaneously +4. **[Response Handling](4-response-handling.md)** - Master complex callback patterns +5. **[Testing & Deployment](5-testing-deployment.md)** - Test and deploy your contracts + +## Prerequisites + +Before starting, ensure you have: + +- Basic understanding of [NEAR smart contracts](../../smart-contracts/intro.md) +- Familiarity with [simple cross-contract calls](../simple-xcc.md) +- [NEAR CLI](../../tooling/near-cli.md) installed and configured +- A NEAR testnet account with some tokens + +:::info Understanding Cross-Contract Calls + +If you're new to cross-contract calls, we recommend starting with our [simple cross-contract calls tutorial](../simple-xcc.md) before diving into these advanced patterns. + +::: + +## How Advanced Cross-Contract Calls Work + +Advanced cross-contract calls leverage NEAR's promise-based architecture to create sophisticated interaction patterns: + +```mermaid +graph TD + A[Your Contract] --> B[Batch Actions] + A --> C[Parallel Calls] + + B --> D[Contract A - Action 1] + B --> E[Contract A - Action 2] + B --> F[Contract A - Action 3] + + C --> G[Contract B] + C --> H[Contract C] + C --> I[Contract D] + + D --> J[Single Response] + E --> J + F --> J + + G --> K[Multiple Responses] + H --> K + I --> K + + J --> L[Your Callback] + K --> L +``` + +The key differences from simple cross-contract calls: + +- **Atomicity**: Batch actions either all succeed or all fail +- **Parallelism**: Multiple contracts can be called simultaneously +- **Complex responses**: Handle arrays of results with individual success/failure states +- **Gas optimization**: Efficient gas distribution across multiple calls + +Ready to dive in? Let's [get started with the project setup](1-setup.md)! + +:::tip Expert Tip + +Advanced cross-contract calls are powerful but complex. Always test thoroughly in a sandbox environment before deploying to mainnet, as gas costs and failure modes can be difficult to predict. + +::: \ No newline at end of file diff --git a/docs/tutorials/advanced-xcc/1-setup.md b/docs/tutorials/advanced-xcc/1-setup.md new file mode 100644 index 00000000000..a8f17128d87 --- /dev/null +++ b/docs/tutorials/advanced-xcc/1-setup.md @@ -0,0 +1,302 @@ +--- +id: setup +title: Setting up the Advanced Cross-Contract Calls Project +sidebar_label: Project Setup +description: "Get the advanced cross-contract calls example project running locally with all necessary dependencies and test contracts." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Before we dive into building advanced cross-contract calls, let's set up the complete project environment. This includes the main contract, external test contracts, and all necessary tooling. + +## Obtaining the Project + +You have two options to start with the Advanced Cross-Contract Calls example: + +| GitHub Codespaces | Clone Locally | +|------------------|---------------| +| [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/near-examples/cross-contract-calls?quickstart=1) | 🌐 `https://github.com/near-examples/cross-contract-calls` | + + + + +Click the "Open in GitHub Codespaces" button above to get a fully configured development environment in your browser. + + + + +```bash +# Clone the repository +git clone https://github.com/near-examples/cross-contract-calls +cd cross-contract-calls + +# Choose your preferred language +cd contract-advanced-ts # for TypeScript +# or +cd contract-advanced-rs # for Rust +``` + + + + +## Project Structure + +The project includes everything you need to build and test advanced cross-contract calls: + + + + +```bash +contract-advanced-ts/ +┌── sandbox-ts/ # Sandbox testing environment +│ ├── external-contracts/ # Pre-compiled test contracts +│ │ ├── counter.wasm # Simple counter contract +│ │ ├── guest-book.wasm # Guest book contract +│ │ └── hello-near.wasm # Hello world contract +│ └── main.ava.ts # Integration tests +├── src/ # Main contract code +│ ├── internal/ # Internal modules +│ │ ├── batch_actions.ts # Batch operations logic +│ │ ├── constants.ts # Contract constants +│ │ ├── multiple_contracts.ts # Parallel execution logic +│ │ ├── similar_contracts.ts # Same-type contract calls +│ │ └── utils.ts # Utility functions +│ └── contract.ts # Main contract entry point +├── package.json # Dependencies and scripts +├── README.md +└── tsconfig.json # TypeScript configuration +``` + + + + +```bash +contract-advanced-rs/ +┌── tests/ # Sandbox testing environment +│ ├── external-contracts/ # Pre-compiled test contracts +│ │ ├── counter.wasm # Simple counter contract +│ │ ├── guest-book.wasm # Guest book contract +│ │ └── hello-near.wasm # Hello world contract +│ └── test_basics.rs # Integration tests +├── src/ # Main contract code +│ ├── batch_actions.rs # Batch operations logic +│ ├── lib.rs # Main contract entry point +│ ├── multiple_contracts.rs # Parallel execution logic +│ └── similar_contracts.rs # Same-type contract calls +├── Cargo.toml # Dependencies and metadata +├── README.md +└── rust-toolchain.toml # Rust toolchain configuration +``` + + + + +## Installing Dependencies + + + + +```bash +# Navigate to the TypeScript contract directory +cd contract-advanced-ts + +# Install dependencies +npm install +# or +yarn install +``` + + + + +```bash +# Navigate to the Rust contract directory +cd contract-advanced-rs + +# Rust dependencies are managed automatically by Cargo +# Verify your setup +cargo check +``` + + + + +## Understanding the Test Contracts + +The project includes three pre-compiled contracts that we'll interact with: + +### 1. Hello NEAR Contract +A simple contract that returns greeting messages. + +**Key Methods:** +- `get_greeting()` - Returns the current greeting +- `set_greeting(message: string)` - Updates the greeting + +### 2. Guest Book Contract +A contract for storing visitor messages. + +**Key Methods:** +- `add_message(text: string)` - Adds a new message +- `get_messages()` - Returns all messages + +### 3. Counter Contract +A simple counter with increment/decrement functionality. + +**Key Methods:** +- `get_num()` - Returns current count +- `increment()` - Increases count by 1 +- `decrement()` - Decreases count by 1 + +## Building the Main Contract + +Let's build and test the main contract to ensure everything is working: + + + + +```bash +# Build the contract +npm run build + +# Run tests to verify everything works +npm test +``` + + + + +```bash +# Build the contract +cargo build --target wasm32-unknown-unknown --release + +# Run tests to verify everything works +cargo test +``` + + + + +If the tests pass, you're ready to proceed! The output should look something like: + +```bash +✓ Test batch actions with sequential execution +✓ Test multiple contracts with parallel execution +✓ Test similar contracts with response handling +✓ Integration tests passed +``` + +## Deploying Test Contracts (Optional) + +For this tutorial, we'll primarily use the sandbox environment, but if you want to deploy the test contracts to testnet: + + + + +```bash +# Create accounts for test contracts +near create-account hello-test.testnet --useFaucet +near create-account guestbook-test.testnet --useFaucet +near create-account counter-test.testnet --useFaucet + +# Deploy the contracts +near deploy hello-test.testnet ./sandbox-ts/external-contracts/hello-near.wasm +near deploy guestbook-test.testnet ./sandbox-ts/external-contracts/guest-book.wasm +near deploy counter-test.testnet ./sandbox-ts/external-contracts/counter.wasm +``` + + + + +```bash +# Create test contract accounts +near account create-account sponsor-by-faucet-service hello-test.testnet autogenerate-new-keypair save-to-keychain network-config testnet create +near account create-account sponsor-by-faucet-service guestbook-test.testnet autogenerate-new-keypair save-to-keychain network-config testnet create +near account create-account sponsor-by-faucet-service counter-test.testnet autogenerate-new-keypair save-to-keychain network-config testnet create + +# Deploy contracts +near contract deploy hello-test.testnet use-file ./sandbox-ts/external-contracts/hello-near.wasm with-init-call new json-args '{}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +near contract deploy guestbook-test.testnet use-file ./sandbox-ts/external-contracts/guest-book.wasm with-init-call new json-args '{}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +near contract deploy counter-test.testnet use-file ./sandbox-ts/external-contracts/counter.wasm with-init-call new json-args '{}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +``` + + + + +## Project Configuration + +The main contract needs to know which external contracts to interact with. This is configured during initialization: + + + + +```typescript +// Example initialization in contract.ts +@NearBindgen({}) +export class CrossContractCalls { + hello_account: AccountId; + guestbook_account: AccountId; + counter_account: AccountId; + + @initialize({}) + new({ + hello_account, + guestbook_account, + counter_account, + }: { + hello_account: AccountId; + guestbook_account: AccountId; + counter_account: AccountId; + }) { + this.hello_account = hello_account; + this.guestbook_account = guestbook_account; + this.counter_account = counter_account; + } +} +``` + + + + +```rust +// Example initialization in lib.rs +#[near_bindgen] +impl CrossContractCalls { + #[init] + pub fn new( + hello_account: AccountId, + guestbook_account: AccountId, + counter_account: AccountId, + ) -> Self { + Self { + hello_account, + guestbook_account, + counter_account, + } + } +} +``` + + + + +## Next Steps + +With your development environment set up and working, you're ready to dive into the core concepts: + +1. **[Batch Actions](2-batch-actions.md)** - Learn to execute multiple actions atomically +2. **[Parallel Execution](3-parallel-execution.md)** - Execute multiple contracts simultaneously +3. **[Response Handling](4-response-handling.md)** - Handle complex response patterns + +:::tip Troubleshooting + +If you encounter issues during setup: + +- Ensure you have the latest version of NEAR CLI +- For Rust: Make sure you have the `wasm32-unknown-unknown` target installed: `rustup target add wasm32-unknown-unknown` +- For TypeScript: Ensure Node.js version 18 or higher +- Check that all test contracts are present in the `external-contracts` directory + +::: + +Let's move on to [implementing batch actions](2-batch-actions.md)! \ No newline at end of file diff --git a/docs/tutorials/advanced-xcc/2-batch-actions.md b/docs/tutorials/advanced-xcc/2-batch-actions.md new file mode 100644 index 00000000000..1c5b03d49a2 --- /dev/null +++ b/docs/tutorials/advanced-xcc/2-batch-actions.md @@ -0,0 +1,404 @@ +--- +id: batch-actions +title: Implementing Batch Actions with Atomic Rollback +sidebar_label: Batch Actions +description: "Learn to batch multiple function calls to the same contract with atomic rollback - if one fails, they all get reverted." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from "@site/src/components/codetabs" + +Batch actions allow you to execute multiple function calls to the same contract sequentially, with a powerful guarantee: **if any action fails, all actions in the batch are reverted**. This atomic behavior is crucial for maintaining consistency in complex operations. + +## Understanding Batch Actions + +When you batch multiple actions together: + +1. **Sequential execution**: Actions execute one after another in the specified order +2. **Atomic rollback**: If any action fails, the entire batch is reverted +3. **Single gas payment**: All actions share gas from the initial call +4. **Last result access**: Your callback receives the result of the final action + +```mermaid +graph TD + A[Start Batch] --> B[Action 1] + B --> C[Action 2] + C --> D[Action 3] + D --> E{All Successful?} + E -->|Yes| F[Commit All Changes] + E -->|No| G[Rollback All Changes] + F --> H[Return Last Result] + G --> I[Return Error] +``` + +## Implementing Batch Actions + +Let's examine how to implement batch actions in your contract: + + + + + + + + + + + + + + + + + + +### Key Components Explained + +**1. Promise Chaining**: Each action is chained using `.then()`, ensuring sequential execution: + + + + +```typescript +// Actions execute in sequence: set_greeting → increment → decrement +const promise = NearPromise.new(this.hello_account) + .functionCall("set_greeting", JSON.stringify({message: "XCC"}), 0n, 30_000_000_000_000n) + .then( + NearPromise.new(this.counter_account) + .functionCall("increment", JSON.stringify({}), 0n, 5_000_000_000_000n) + ) + .then( + NearPromise.new(this.counter_account) + .functionCall("decrement", JSON.stringify({}), 0n, 5_000_000_000_000n) + ); +``` + + + + +```rust +// Actions execute in sequence: set_greeting → increment → decrement +let promise = Promise::new(self.hello_account.clone()) + .function_call( + "set_greeting".to_owned(), + json!({"message": "XCC"}).to_string().into_bytes(), + 0, + Gas::from_tgas(30), + ) + .then(Promise::new(self.counter_account.clone()).function_call( + "increment".to_owned(), + json!({}).to_string().into_bytes(), + 0, + Gas::from_tgas(5), + )) + .then(Promise::new(self.counter_account.clone()).function_call( + "decrement".to_owned(), + json!({}).to_string().into_bytes(), + 0, + Gas::from_tgas(5), + )); +``` + + + + +**2. Gas Management**: Each action specifies its own gas allocation. The total gas must not exceed your available gas. + +**3. Callback Registration**: The final `.then()` registers a callback to handle the result. + +## Handling Batch Responses + +The callback method receives only the result from the **last action** in the chain: + + + + + + + + + + + + + + + + +### Response Processing + + + + + + + + + +```rust +// In Rust, you can directly access the promise result +match env::promise_result(0) { + PromiseResult::Successful(result) => { + let counter_value: i8 = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| 0); + format!("Batch completed. Final counter value: {}", counter_value) + } + PromiseResult::Failed => "Batch failed - all actions reverted".to_string(), +} +``` + + + + +## Real-World Example: Token Transfer Batch + +Here's a practical example that demonstrates batch actions in a DeFi context: + + + + +```typescript +// Example: Batch token operations (approve + transfer + stake) +@call({}) +batch_token_operations({ + token_account, + amount, + recipient, + staking_pool +}: { + token_account: AccountId; + amount: string; + recipient: AccountId; + staking_pool: AccountId; +}) { + // 1. Approve token spending + const promise = NearPromise.new(token_account) + .functionCall( + "ft_approve", + JSON.stringify({spender_id: env.current_account_id(), amount}), + 1n, // 1 yoctoNEAR for storage + 10_000_000_000_000n + ) + // 2. Transfer tokens + .then( + NearPromise.new(token_account) + .functionCall( + "ft_transfer", + JSON.stringify({receiver_id: recipient, amount}), + 1n, + 15_000_000_000_000n + ) + ) + // 3. Stake in pool + .then( + NearPromise.new(staking_pool) + .functionCall( + "deposit_and_stake", + JSON.stringify({}), + BigInt(amount), // Attach the tokens as deposit + 20_000_000_000_000n + ) + ) + // 4. Handle the result + .then( + NearPromise.new(env.current_account_id()) + .functionCall( + "batch_token_callback", + JSON.stringify({original_amount: amount}), + 0n, + 10_000_000_000_000n + ) + ); + + return promise; +} +``` + + + + +```rust +// Example: Batch token operations (approve + transfer + stake) +#[payable] +pub fn batch_token_operations( + &mut self, + token_account: AccountId, + amount: U128, + recipient: AccountId, + staking_pool: AccountId, +) -> Promise { + // 1. Approve token spending + Promise::new(token_account.clone()) + .function_call( + "ft_approve".to_owned(), + json!({"spender_id": env::current_account_id(), "amount": amount}) + .to_string().into_bytes(), + 1, // 1 yoctoNEAR for storage + Gas::from_tgas(10), + ) + // 2. Transfer tokens + .then(Promise::new(token_account).function_call( + "ft_transfer".to_owned(), + json!({"receiver_id": recipient, "amount": amount}) + .to_string().into_bytes(), + 1, + Gas::from_tgas(15), + )) + // 3. Stake in pool + .then(Promise::new(staking_pool).function_call( + "deposit_and_stake".to_owned(), + json!({}).to_string().into_bytes(), + amount.0, // Attach the tokens as deposit + Gas::from_tgas(20), + )) + // 4. Handle the result + .then(Promise::new(env::current_account_id()).function_call( + "batch_token_callback".to_owned(), + json!({"original_amount": amount}).to_string().into_bytes(), + 0, + Gas::from_tgas(10), + )) +} +``` + + + + +## Testing Batch Actions + +Let's test our batch actions to see both success and failure scenarios: + + + + +```typescript +// Test successful batch +test('batch actions execute sequentially', async (t) => { + const contract = t.context.contract; + + // Execute batch actions + const result = await contract.call( + 'batch_actions', + {}, + { gas: '300000000000000' } + ); + + // Should return result from last action (counter decrement) + t.is(result, 'Batch completed. Final counter value: 0'); +}); + +// Test batch failure and rollback +test('batch actions rollback on failure', async (t) => { + const contract = t.context.contract; + + // This batch will fail on the second action + const result = await contract.call( + 'batch_actions_with_failure', + {}, + { gas: '300000000000000' } + ); + + // Should indicate failure + t.is(result, 'Batch failed - all actions reverted'); + + // Verify the first action was also rolled back + const greeting = await contract.view('get_greeting'); + t.not(greeting, 'XCC'); // Should not be the value from failed batch +}); +``` + + + + +```rust +#[tokio::test] +async fn test_batch_actions_success() -> Result<(), Box> { + let sandbox = near_workspaces::sandbox().await?; + let contract = sandbox.dev_account().await?; + + // Deploy and initialize contract + let wasm = near_workspaces::compile_project("./").await?; + let contract = contract.deploy(&wasm).await?.unwrap(); + + // Execute batch actions + let result = contract + .call("batch_actions") + .gas(300_000_000_000_000) + .transact() + .await?; + + assert!(result.is_success()); + + Ok(()) +} + +#[tokio::test] +async fn test_batch_actions_rollback() -> Result<(), Box> { + // Test that failed batch actions rollback properly + // Implementation details... + Ok(()) +} +``` + + + + +## Common Pitfalls and Best Practices + +### ❌ Common Mistakes + +1. **Insufficient Gas**: Not allocating enough gas for all actions in the chain +2. **Wrong Order**: Placing dependent actions before their dependencies +3. **Ignoring Failures**: Not handling callback failures properly + +### ✅ Best Practices + +1. **Gas Planning**: Always allocate sufficient gas for each action plus callback overhead +2. **Error Handling**: Implement comprehensive error handling in callbacks +3. **State Validation**: Verify contract state before and after batch operations +4. **Atomic Design**: Design batches to be truly atomic - either all succeed or all fail + +## Gas Optimization Tips + +```typescript +// Good: Allocate appropriate gas for each action +.functionCall("simple_action", args, 0n, 5_000_000_000_000n) // 5 TGas +.functionCall("complex_action", args, 0n, 15_000_000_000_000n) // 15 TGas +.functionCall("callback", args, 0n, 10_000_000_000_000n) // 10 TGas callback + +// Bad: Over-allocating gas wastes user funds +.functionCall("simple_action", args, 0n, 50_000_000_000_000n) // Too much! +``` + +## Next Steps + +Now that you understand batch actions, let's explore [parallel execution](3-parallel-execution.md) where you'll learn to call multiple different contracts simultaneously for maximum efficiency. + +:::tip When to Use Batch Actions + +Batch actions are perfect when you need: +- **Atomic operations** across multiple function calls +- **Sequential execution** where later actions depend on earlier ones +- **Single contract interactions** with multiple related operations +- **Rollback guarantees** - all or nothing execution + +For independent operations across different contracts, consider [parallel execution](3-parallel-execution.md) instead. + +::: \ No newline at end of file diff --git a/docs/tutorials/advanced-xcc/3-parallel-execution.md b/docs/tutorials/advanced-xcc/3-parallel-execution.md new file mode 100644 index 00000000000..446bccba754 --- /dev/null +++ b/docs/tutorials/advanced-xcc/3-parallel-execution.md @@ -0,0 +1,668 @@ +--- +id: parallel-execution +title: Parallel Contract Execution for Maximum Efficiency +sidebar_label: Parallel Execution +description: "Learn to execute multiple contracts simultaneously for maximum efficiency. Unlike batch actions, parallel calls don't rollback if one fails." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from "@site/src/components/codetabs" + +Parallel execution allows you to call multiple contracts simultaneously, dramatically improving efficiency when you need to gather data or perform operations across different contracts. Unlike batch actions, **parallel calls execute independently - if one fails, the others continue**. + +## Understanding Parallel Execution + +When you execute contracts in parallel: + +1. **Simultaneous execution**: All contracts are called at the same time +2. **Independent results**: Each call succeeds or fails independently +3. **Faster completion**: Total execution time is limited by the slowest call +4. **Array of results**: Your callback receives all results as an array + +```mermaid +graph TD + A[Start Parallel Calls] --> B[Contract A] + A --> C[Contract B] + A --> D[Contract C] + B --> E[Result A] + C --> F[Result B - Failed] + D --> G[Result C] + E --> H[Callback with All Results] + F --> H + G --> H + H --> I[Process Mixed Results] +``` + +## Implementing Parallel Execution + +Let's examine how to implement parallel contract calls: + + + + + + + + + + + + + + + + + + +### Key Components Explained + +**1. Independent Promises**: Each contract call creates a separate promise: + + + + +```typescript +// Three independent promises execute simultaneously +const hello_promise = NearPromise.new(this.hello_account) + .functionCall("get_greeting", JSON.stringify({}), 0n, 5_000_000_000_000n); + +const counter_promise = NearPromise.new(this.counter_account) + .functionCall("get_num", JSON.stringify({}), 0n, 5_000_000_000_000n); + +const guestbook_promise = NearPromise.new(this.guestbook_account) + .functionCall("get_messages", JSON.stringify({}), 0n, 5_000_000_000_000n); +``` + + + + +```rust +// Three independent promises execute simultaneously +let hello_promise = Promise::new(self.hello_account.clone()) + .function_call( + "get_greeting".to_owned(), + json!({}).to_string().into_bytes(), + 0, + Gas::from_tgas(5), + ); + +let counter_promise = Promise::new(self.counter_account.clone()) + .function_call( + "get_num".to_owned(), + json!({}).to_string().into_bytes(), + 0, + Gas::from_tgas(5), + ); + +let guestbook_promise = Promise::new(self.guestbook_account.clone()) + .function_call( + "get_messages".to_owned(), + json!({}).to_string().into_bytes(), + 0, + Gas::from_tgas(5), + ); +``` + + + + +**2. Promise Combination**: Use `and()` to combine promises for parallel execution: + + + + +```typescript +// Combine promises for parallel execution +return hello_promise + .and(counter_promise) + .and(guestbook_promise) + .then( + NearPromise.new(env.current_account_id()) + .functionCall( + "multiple_contracts_callback", + JSON.stringify({}), + 0n, + 10_000_000_000_000n + ) + ); +``` + + + + +```rust +// Combine promises for parallel execution +hello_promise + .and(counter_promise) + .and(guestbook_promise) + .then(Promise::new(env::current_account_id()).function_call( + "multiple_contracts_callback".to_owned(), + json!({}).to_string().into_bytes(), + 0, + Gas::from_tgas(10), + )) +``` + + + + +## Handling Parallel Responses + +The callback method receives an **array of results**, where each element corresponds to one of your parallel calls: + + + + + + + + + + + + + + + + +### Response Processing Pattern + + + + +```typescript +// Process each result individually +for (let i = 0; i < promiseResults.length; i++) { + const result = getValueFromPromise(i); + + if (result.success) { + switch(i) { + case 0: // Hello contract result + responses.push(`Hello: ${result.value}`); + break; + case 1: // Counter contract result + responses.push(`Counter: ${result.value}`); + break; + case 2: // Guestbook contract result + const messages = JSON.parse(result.value); + responses.push(`Messages: ${messages.length} total`); + break; + } + } else { + responses.push(`Contract ${i} failed: ${result.error || 'Unknown error'}`); + } +} +``` + + + + +```rust +// Process each result individually +for i in 0..3 { + match env::promise_result(i) { + PromiseResult::Successful(result) => { + match i { + 0 => { + // Hello contract result + let greeting: String = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| "Failed to parse greeting".to_string()); + responses.push(format!("Hello: {}", greeting)); + } + 1 => { + // Counter contract result + let count: i8 = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| 0); + responses.push(format!("Counter: {}", count)); + } + 2 => { + // Guestbook contract result + let messages: Vec = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| Vec::new()); + responses.push(format!("Messages: {} total", messages.len())); + } + _ => {} + } + } + PromiseResult::Failed => { + responses.push(format!("Contract {} failed", i)); + } + } +} +``` + + + + +## Real-World Example: DeFi Portfolio Dashboard + +Here's a practical example that fetches portfolio data from multiple DeFi protocols: + + + + +```typescript +@call({}) +get_portfolio_summary({ + user_account, + token_contract, + lending_contract, + staking_contract, + dex_contract +}: { + user_account: AccountId; + token_contract: AccountId; + lending_contract: AccountId; + staking_contract: AccountId; + dex_contract: AccountId; +}) { + // Get token balance + const balance_promise = NearPromise.new(token_contract) + .functionCall( + "ft_balance_of", + JSON.stringify({account_id: user_account}), + 0n, + 5_000_000_000_000n + ); + + // Get lending position + const lending_promise = NearPromise.new(lending_contract) + .functionCall( + "get_account_positions", + JSON.stringify({account_id: user_account}), + 0n, + 10_000_000_000_000n + ); + + // Get staking rewards + const staking_promise = NearPromise.new(staking_contract) + .functionCall( + "get_unclaimed_rewards", + JSON.stringify({account_id: user_account}), + 0n, + 5_000_000_000_000n + ); + + // Get DEX liquidity positions + const dex_promise = NearPromise.new(dex_contract) + .functionCall( + "get_user_liquidity", + JSON.stringify({account_id: user_account}), + 0n, + 8_000_000_000_000n + ); + + // Execute all in parallel + return balance_promise + .and(lending_promise) + .and(staking_promise) + .and(dex_promise) + .then( + NearPromise.new(env.current_account_id()) + .functionCall( + "portfolio_callback", + JSON.stringify({user_account}), + 0n, + 15_000_000_000_000n + ) + ); +} + +@call({privateFunction: true}) +portfolio_callback({user_account}: {user_account: AccountId}) { + const results = []; + + // Process token balance + const balance_result = getValueFromPromise(0); + const balance = balance_result.success ? + JSON.parse(balance_result.value) : "0"; + + // Process lending position + const lending_result = getValueFromPromise(1); + const lending_data = lending_result.success ? + JSON.parse(lending_result.value) : {supplied: "0", borrowed: "0"}; + + // Process staking rewards + const staking_result = getValueFromPromise(2); + const rewards = staking_result.success ? + JSON.parse(staking_result.value) : "0"; + + // Process DEX positions + const dex_result = getValueFromPromise(3); + const liquidity = dex_result.success ? + JSON.parse(dex_result.value) : []; + + return { + user_account, + portfolio: { + token_balance: balance, + lending: lending_data, + staking_rewards: rewards, + liquidity_positions: liquidity, + timestamp: env.block_timestamp_ms() + } + }; +} +``` + + + + +```rust +pub fn get_portfolio_summary( + &mut self, + user_account: AccountId, + token_contract: AccountId, + lending_contract: AccountId, + staking_contract: AccountId, + dex_contract: AccountId, +) -> Promise { + // Get token balance + let balance_promise = Promise::new(token_contract) + .function_call( + "ft_balance_of".to_owned(), + json!({"account_id": user_account}).to_string().into_bytes(), + 0, + Gas::from_tgas(5), + ); + + // Get lending position + let lending_promise = Promise::new(lending_contract) + .function_call( + "get_account_positions".to_owned(), + json!({"account_id": user_account}).to_string().into_bytes(), + 0, + Gas::from_tgas(10), + ); + + // Get staking rewards + let staking_promise = Promise::new(staking_contract) + .function_call( + "get_unclaimed_rewards".to_owned(), + json!({"account_id": user_account}).to_string().into_bytes(), + 0, + Gas::from_tgas(5), + ); + + // Get DEX liquidity positions + let dex_promise = Promise::new(dex_contract) + .function_call( + "get_user_liquidity".to_owned(), + json!({"account_id": user_account}).to_string().into_bytes(), + 0, + Gas::from_tgas(8), + ); + + // Execute all in parallel + balance_promise + .and(lending_promise) + .and(staking_promise) + .and(dex_promise) + .then(Promise::new(env::current_account_id()).function_call( + "portfolio_callback".to_owned(), + json!({"user_account": user_account}).to_string().into_bytes(), + 0, + Gas::from_tgas(15), + )) +} + +#[private] +pub fn portfolio_callback(&mut self, user_account: AccountId) -> serde_json::Value { + let mut portfolio = serde_json::Map::new(); + + // Process token balance (index 0) + if let PromiseResult::Successful(result) = env::promise_result(0) { + let balance: String = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| "0".to_string()); + portfolio.insert("token_balance".to_string(), json!(balance)); + } + + // Process lending position (index 1) + if let PromiseResult::Successful(result) = env::promise_result(1) { + let lending_data: serde_json::Value = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| json!({"supplied": "0", "borrowed": "0"})); + portfolio.insert("lending".to_string(), lending_data); + } + + // Process staking rewards (index 2) + if let PromiseResult::Successful(result) = env::promise_result(2) { + let rewards: String = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| "0".to_string()); + portfolio.insert("staking_rewards".to_string(), json!(rewards)); + } + + // Process DEX positions (index 3) + if let PromiseResult::Successful(result) = env::promise_result(3) { + let liquidity: Vec = near_sdk::serde_json::from_slice(&result) + .unwrap_or_else(|_| Vec::new()); + portfolio.insert("liquidity_positions".to_string(), json!(liquidity)); + } + + json!({ + "user_account": user_account, + "portfolio": portfolio, + "timestamp": env::block_timestamp_ms() + }) +} +``` + + + + +## Testing Parallel Execution + +Let's test parallel execution to verify both successful and mixed result scenarios: + + + + +```typescript +test('parallel execution handles mixed results', async (t) => { + const contract = t.context.contract; + + // Execute parallel calls + const result = await contract.call( + 'multiple_contracts', + {}, + { gas: '300000000000000' } + ); + + // Should contain results from all contracts + t.true(result.includes('Hello:')); + t.true(result.includes('Counter:')); + t.true(result.includes('Messages:')); +}); + +test('parallel execution continues on individual failures', async (t) => { + // Test with one contract failing + const contract = t.context.contract; + + // Simulate a scenario where one contract is unavailable + const result = await contract.call( + 'multiple_contracts_with_failure', + {}, + { gas: '300000000000000' } + ); + + // Should show some successes and some failures + t.true(result.includes('failed')); + t.true(result.includes('Hello:') || result.includes('Counter:')); +}); +``` + + + + +```rust +#[tokio::test] +async fn test_parallel_execution_success() -> Result<(), Box> { + let sandbox = near_workspaces::sandbox().await?; + let contract = sandbox.dev_account().await?; + + // Deploy and test parallel execution + let wasm = near_workspaces::compile_project("./").await?; + let contract = contract.deploy(&wasm).await?.unwrap(); + + let result = contract + .call("multiple_contracts") + .gas(300_000_000_000_000) + .transact() + .await?; + + assert!(result.is_success()); + + // Verify response contains data from all contracts + let response: String = result.json()?; + assert!(response.contains("Hello:")); + assert!(response.contains("Counter:")); + + Ok(()) +} + +#[tokio::test] +async fn test_parallel_execution_partial_failure() -> Result<(), Box> { + // Test that parallel execution handles partial failures gracefully + // Implementation details... + Ok(()) +} +``` + + + + +## Performance Benefits + +Parallel execution provides significant performance improvements: + +```typescript +// Sequential execution: ~300ms total +await contract1.method(); // 100ms +await contract2.method(); // 100ms +await contract3.method(); // 100ms + +// Parallel execution: ~100ms total (limited by slowest call) +Promise.all([ + contract1.method(), // 100ms + contract2.method(), // 50ms + contract3.method() // 75ms +]); // Completes in ~100ms +``` + +## Error Handling Strategies + +### Strategy 1: Graceful Degradation + +```typescript +// Continue with available data even if some calls fail +portfolio_callback() { + const results = { + balance: "0", + rewards: "0", + positions: [] + }; + + // Try to get balance, use default if failed + const balance_result = getValueFromPromise(0); + if (balance_result.success) { + results.balance = JSON.parse(balance_result.value); + } + + // Try to get rewards, use default if failed + const rewards_result = getValueFromPromise(1); + if (rewards_result.success) { + results.rewards = JSON.parse(rewards_result.value); + } + + return results; // Always return something useful +} +``` + +### Strategy 2: Partial Retry + +```typescript +// Retry failed calls with exponential backoff +portfolio_callback_with_retry() { + const failed_calls = []; + + for (let i = 0; i < promise_count; i++) { + const result = getValueFromPromise(i); + if (!result.success) { + failed_calls.push(i); + } + } + + if (failed_calls.length > 0) { + // Initiate retry for failed calls + return this.retry_failed_calls(failed_calls); + } + + return this.process_all_results(); +} +``` + +## Common Pitfalls and Best Practices + +### ❌ Common Mistakes + +1. **Result Index Confusion**: Not matching promise results to the correct contract calls +2. **Insufficient Error Handling**: Not handling individual contract failures gracefully +3. **Gas Miscalculation**: Not accounting for callback gas in total gas estimation + +### ✅ Best Practices + +1. **Document Result Order**: Clearly document which promise index corresponds to which contract +2. **Implement Fallbacks**: Always have fallback values for failed calls +3. **Monitor Performance**: Track which contracts are slow or frequently failing +4. **Use Timeouts**: Consider implementing timeouts for slow contract calls + +## Gas Optimization for Parallel Calls + +```typescript +// Good: Allocate appropriate gas for each contract type +const light_call_gas = 5_000_000_000_000n; // 5 TGas for simple view calls +const medium_call_gas = 15_000_000_000_000n; // 15 TGas for complex operations +const heavy_call_gas = 30_000_000_000_000n; // 30 TGas for heavy computations + +// Estimate total gas needed +const estimated_gas = (light_calls * light_call_gas) + + (medium_calls * medium_call_gas) + + (callback_gas); + +// Add buffer for safety (10-20%) +const total_gas = estimated_gas * 1.2; +``` + +## When to Use Parallel vs Batch + +| Use Parallel When: | Use Batch When: | +|-------------------|-----------------| +| ✅ Calling different contracts | ✅ Calling same contract multiple times | +| ✅ Independent operations | ✅ Dependent sequential operations | +| ✅ Can handle partial failures | ✅ Need atomic rollback | +| ✅ Performance is critical | ✅ Consistency is critical | +| ✅ Gathering data from multiple sources | ✅ Executing related business logic | + +## Next Steps + +Now that you understand parallel execution, let's dive into [advanced response handling](4-response-handling.md) where you'll learn sophisticated patterns for processing complex results from multiple contracts. + +:::tip Performance vs Reliability Trade-off + +Parallel execution optimizes for performance and availability - you get results faster and can handle individual failures. However, if you need strong consistency guarantees, consider using [batch actions](2-batch-actions.md) instead. + +::: \ No newline at end of file diff --git a/docs/tutorials/advanced-xcc/4-response-handling.md b/docs/tutorials/advanced-xcc/4-response-handling.md new file mode 100644 index 00000000000..0413c33fb12 --- /dev/null +++ b/docs/tutorials/advanced-xcc/4-response-handling.md @@ -0,0 +1,1703 @@ +--- +id: response-handling +title: Advanced Response Handling Patterns +sidebar_label: Response Handling +description: "Master sophisticated patterns for processing complex results from multiple contract interactions, including error handling and data transformation." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from "@site/src/components/codetabs" + +Response handling is where the complexity of advanced cross-contract calls really becomes apparent. You need to process multiple results, handle various error conditions, and transform data into useful formats - all while maintaining performance and reliability. + +## Understanding Response Types + +NEAR provides different response patterns depending on your call structure: + +| Call Type | Response Format | Use Case | +|-----------|----------------|----------| +| **Single Call** | Single value or error | Basic interactions | +| **Batch Actions** | Last action result | Sequential operations | +| **Parallel Calls** | Array of results | Independent operations | +| **Similar Contracts** | Array of same-type results | Data aggregation | + +## Processing Similar Contract Results + +When calling multiple contracts that return the same data type, you can use specialized handling patterns: + + + + + + + + + + + + + + + + + + +### Processing Similar Results + +The callback can iterate through results more efficiently when all contracts return the same type: + + + + + + + + + + + + + + + + +## Advanced Response Processing Patterns + +### Pattern 1: Data Aggregation + +Combine results from multiple contracts into a unified response: + + + + +```typescript +@call({privateFunction: true}) +aggregate_token_data_callback() { + const aggregated_data = { + total_supply: 0n, + total_holders: 0, + contracts_responding: 0, + contract_details: [] + }; + + for (let i = 0; i < env.promise_results_count(); i++) { + const result = getValueFromPromise(i); + + if (result.success) { + const token_data = JSON.parse(result.value); + + // Aggregate the data + aggregated_data.total_supply += BigInt(token_data.total_supply); + aggregated_data.total_holders += token_data.holder_count; + aggregated_data.contracts_responding++; + + aggregated_data.contract_details.push({ + contract_id: token_data.contract_id, + supply: token_data.total_supply, + holders: token_data.holder_count, + last_updated: token_data.timestamp + }); + } + } + + // Calculate averages and ratios + const avg_holders = aggregated_data.contracts_responding > 0 + ? aggregated_data.total_holders / aggregated_data.contracts_responding + : 0; + + return { + summary: { + total_supply: aggregated_data.total_supply.toString(), + total_holders: aggregated_data.total_holders, + average_holders_per_contract: avg_holders, + response_rate: `${aggregated_data.contracts_responding}/${env.promise_results_count()}` + }, + details: aggregated_data.contract_details + }; +} +``` + + + + +```rust +#[derive(Serialize, Deserialize)] +pub struct TokenData { + contract_id: AccountId, + total_supply: U128, + holder_count: u32, + timestamp: u64, +} + +#[derive(Serialize, Deserialize)] +pub struct AggregatedData { + total_supply: U128, + total_holders: u32, + contracts_responding: u32, + contract_details: Vec, +} + +#[private] +pub fn aggregate_token_data_callback(&mut self) -> serde_json::Value { + let mut aggregated = AggregatedData { + total_supply: U128(0), + total_holders: 0, + contracts_responding: 0, + contract_details: Vec::new(), + }; + + for i in 0..env::promise_results_count() { + match env::promise_result(i) { + PromiseResult::Successful(result) => { + if let Ok(token_data) = near_sdk::serde_json::from_slice::(&result) { + // Aggregate the data + aggregated.total_supply.0 += token_data.total_supply.0; + aggregated.total_holders += token_data.holder_count; + aggregated.contracts_responding += 1; + aggregated.contract_details.push(token_data); + } + } + PromiseResult::Failed => { + // Log failure but continue processing + env::log_str(&format!("Contract {} failed to respond", i)); + } + } + } + + // Calculate metrics + let avg_holders = if aggregated.contracts_responding > 0 { + aggregated.total_holders / aggregated.contracts_responding + } else { + 0 + }; + + json!({ + "summary": { + "total_supply": aggregated.total_supply, + "total_holders": aggregated.total_holders, + "average_holders_per_contract": avg_holders, + "response_rate": format!("{}/{}", aggregated.contracts_responding, env::promise_results_count()) + }, + "details": aggregated.contract_details + }) +} +``` + + + + +### Pattern 2: Conditional Processing + +Process results differently based on their content or source: + + + + +```typescript +@call({privateFunction: true}) +conditional_processing_callback({ + contract_types +}: { + contract_types: string[] +}) { + const results = { + defi_data: [], + nft_data: [], + governance_data: [], + errors: [] + }; + + for (let i = 0; i < contract_types.length; i++) { + const result = getValueFromPromise(i); + const contract_type = contract_types[i]; + + if (result.success) { + const data = JSON.parse(result.value); + + switch(contract_type) { + case 'defi': + results.defi_data.push({ + ...data, + apy_formatted: `${(data.apy * 100).toFixed(2)}%`, + tvl_formatted: this.format_currency(data.total_value_locked) + }); + break; + + case 'nft': + results.nft_data.push({ + ...data, + floor_price_formatted: this.format_currency(data.floor_price), + volume_24h_formatted: this.format_currency(data.volume_24h) + }); + break; + + case 'governance': + results.governance_data.push({ + ...data, + proposal_status: data.active_proposals > 0 ? 'Active' : 'Inactive', + participation_rate: `${(data.votes_cast / data.total_eligible * 100).toFixed(1)}%` + }); + break; + + default: + env.log(`Unknown contract type: ${contract_type}`); + } + } else { + results.errors.push({ + index: i, + contract_type, + error: result.error || 'Unknown error' + }); + } + } + + return { + ...results, + summary: { + total_contracts: contract_types.length, + successful_responses: contract_types.length - results.errors.length, + defi_contracts: results.defi_data.length, + nft_contracts: results.nft_data.length, + governance_contracts: results.governance_data.length, + failed_contracts: results.errors.length + } + }; +} + +format_currency(amount: string): string { + const num = parseFloat(amount); + if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`; + if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`; + if (num >= 1e3) return `${(num / 1e3).toFixed(2)}K`; + return `${num.toFixed(2)}`; +} +``` + + + + +```rust +#[derive(Serialize, Deserialize)] +pub struct ConditionalResults { + defi_data: Vec, + nft_data: Vec, + governance_data: Vec, + errors: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct ErrorInfo { + index: usize, + contract_type: String, + error: String, +} + +#[private] +pub fn conditional_processing_callback( + &mut self, + contract_types: Vec +) -> serde_json::Value { + let mut results = ConditionalResults { + defi_data: Vec::new(), + nft_data: Vec::new(), + governance_data: Vec::new(), + errors: Vec::new(), + }; + + for (i, contract_type) in contract_types.iter().enumerate() { + match env::promise_result(i) { + PromiseResult::Successful(result) => { + if let Ok(mut data) = near_sdk::serde_json::from_slice::(&result) { + match contract_type.as_str() { + "defi" => { + // Add formatted fields for DeFi data + if let Some(apy) = data.get("apy").and_then(|v| v.as_f64()) { + data["apy_formatted"] = json!(format!("{:.2}%", apy * 100.0)); + } + if let Some(tvl) = data.get("total_value_locked").and_then(|v| v.as_str()) { + data["tvl_formatted"] = json!(self.format_currency(tvl)); + } + results.defi_data.push(data); + } + + "nft" => { + // Add formatted fields for NFT data + if let Some(floor_price) = data.get("floor_price").and_then(|v| v.as_str()) { + data["floor_price_formatted"] = json!(self.format_currency(floor_price)); + } + if let Some(volume) = data.get("volume_24h").and_then(|v| v.as_str()) { + data["volume_24h_formatted"] = json!(self.format_currency(volume)); + } + results.nft_data.push(data); + } + + "governance" => { + // Add computed fields for governance data + if let Some(active_proposals) = data.get("active_proposals").and_then(|v| v.as_u64()) { + data["proposal_status"] = json!(if active_proposals > 0 { "Active" } else { "Inactive" }); + } + + if let (Some(votes_cast), Some(total_eligible)) = ( + data.get("votes_cast").and_then(|v| v.as_u64()), + data.get("total_eligible").and_then(|v| v.as_u64()) + ) { + let participation_rate = if total_eligible > 0 { + (votes_cast as f64 / total_eligible as f64) * 100.0 + } else { + 0.0 + }; + data["participation_rate"] = json!(format!("{:.1}%", participation_rate)); + } + results.governance_data.push(data); + } + + _ => { + env::log_str(&format!("Unknown contract type: {}", contract_type)); + } + } + } + } + PromiseResult::Failed => { + results.errors.push(ErrorInfo { + index: i, + contract_type: contract_type.clone(), + error: "Contract call failed".to_string(), + }); + } + } + } + + json!({ + "defi_data": results.defi_data, + "nft_data": results.nft_data, + "governance_data": results.governance_data, + "errors": results.errors, + "summary": { + "total_contracts": contract_types.len(), + "successful_responses": contract_types.len() - results.errors.len(), + "defi_contracts": results.defi_data.len(), + "nft_contracts": results.nft_data.len(), + "governance_contracts": results.governance_data.len(), + "failed_contracts": results.errors.len() + } + }) +} + +fn format_currency(&self, amount: &str) -> String { + if let Ok(num) = amount.parse::() { + if num >= 1e9 { + format!("${:.2}B", num / 1e9) + } else if num >= 1e6 { + format!("${:.2}M", num / 1e6) + } else if num >= 1e3 { + format!("${:.2}K", num / 1e3) + } else { + format!("${:.2}", num) + } + } else { + format!("${}", amount) + } +} +``` + + + + +### Pattern 3: Response Validation and Sanitization + +Validate and sanitize data from external contracts: + + + + +```typescript +@call({privateFunction: true}) +validated_response_callback() { + const validated_results = []; + const validation_errors = []; + + for (let i = 0; i < env.promise_results_count(); i++) { + const result = getValueFromPromise(i); + + if (result.success) { + try { + const raw_data = JSON.parse(result.value); + const validation_result = this.validate_and_sanitize(raw_data, i); + + if (validation_result.valid) { + validated_results.push({ + index: i, + data: validation_result.sanitized_data, + timestamp: env.block_timestamp_ms() + }); + } else { + validation_errors.push({ + index: i, + errors: validation_result.errors, + raw_data: raw_data + }); + } + } catch (error) { + validation_errors.push({ + index: i, + errors: [`JSON parse error: ${error}`], + raw_data: result.value + }); + } + } else { + validation_errors.push({ + index: i, + errors: [`Contract call failed: ${result.error || 'Unknown error'}`], + raw_data: null + }); + } + } + + return { + valid_results: validated_results, + validation_errors: validation_errors, + summary: { + total_calls: env.promise_results_count(), + valid_responses: validated_results.length, + invalid_responses: validation_errors.length, + success_rate: `${(validated_results.length / env.promise_results_count() * 100).toFixed(1)}%` + } + }; +} + +validate_and_sanitize(data: any, index: number): {valid: boolean, sanitized_data?: any, errors?: string[]} { + const errors = []; + const sanitized = {...data}; + + // Required field validation + if (!data.hasOwnProperty('id') || typeof data.id !== 'string') { + errors.push('Missing or invalid id field'); + } + + if (!data.hasOwnProperty('timestamp') || typeof data.timestamp !== 'number') { + errors.push('Missing or invalid timestamp field'); + } + + // Range validation for numeric fields + if (data.hasOwnProperty('amount')) { + const amount = parseFloat(data.amount); + if (isNaN(amount) || amount < 0) { + errors.push('Invalid amount: must be a positive number'); + } else if (amount > 1e18) { + errors.push('Amount too large: exceeds maximum allowed value'); + } else { + sanitized.amount = amount.toString(); // Normalize to string + } + } + + // String sanitization + if (data.hasOwnProperty('description')) { + if (typeof data.description !== 'string') { + errors.push('Description must be a string'); + } else if (data.description.length > 1000) { + errors.push('Description too long: max 1000 characters'); + } else { + // Sanitize string (remove potentially harmful content) + sanitized.description = data.description + .replace(/[<>]/g, '') // Remove angle brackets + .substring(0, 1000); // Truncate to max length + } + } + + // Array validation + if (data.hasOwnProperty('tags') && Array.isArray(data.tags)) { + sanitized.tags = data.tags + .filter(tag => typeof tag === 'string' && tag.length <= 50) + .slice(0, 10); // Max 10 tags + } + + return { + valid: errors.length === 0, + sanitized_data: errors.length === 0 ? sanitized : undefined, + errors: errors.length > 0 ? errors : undefined + }; +} +``` + + + + +```rust +#[derive(Serialize, Deserialize)] +pub struct ValidationResult { + valid: bool, + sanitized_data: Option, + errors: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct ValidatedResponse { + index: usize, + data: serde_json::Value, + timestamp: u64, +} + +#[private] +pub fn validated_response_callback(&mut self) -> serde_json::Value { + let mut validated_results = Vec::new(); + let mut validation_errors = Vec::new(); + + for i in 0..env::promise_results_count() { + match env::promise_result(i) { + PromiseResult::Successful(result) => { + match near_sdk::serde_json::from_slice::(&result) { + Ok(raw_data) => { + let validation_result = self.validate_and_sanitize(&raw_data, i); + + if validation_result.valid { + if let Some(sanitized_data) = validation_result.sanitized_data { + validated_results.push(ValidatedResponse { + index: i, + data: sanitized_data, + timestamp: env::block_timestamp_ms(), + }); + } + } else { + validation_errors.push(json!({ + "index": i, + "errors": validation_result.errors, + "raw_data": raw_data + })); + } + } + Err(e) => { + validation_errors.push(json!({ + "index": i, + "errors": [format!("JSON parse error: {}", e)], + "raw_data": String::from_utf8_lossy(&result) + })); + } + } + } + PromiseResult::Failed => { + validation_errors.push(json!({ + "index": i, + "errors": ["Contract call failed"], + "raw_data": null + })); + } + } + } + + let total_calls = env::promise_results_count(); + let valid_count = validated_results.len(); + let success_rate = if total_calls > 0 { + (valid_count as f64 / total_calls as f64) * 100.0 + } else { + 0.0 + }; + + json!({ + "valid_results": validated_results, + "validation_errors": validation_errors, + "summary": { + "total_calls": total_calls, + "valid_responses": valid_count, + "invalid_responses": validation_errors.len(), + "success_rate": format!("{:.1}%", success_rate) + } + }) +} + +fn validate_and_sanitize(&self, data: &serde_json::Value, index: usize) -> ValidationResult { + let mut errors = Vec::new(); + let mut sanitized = data.clone(); + + // Required field validation + if !data.get("id").map_or(false, |v| v.is_string()) { + errors.push("Missing or invalid id field".to_string()); + } + + if !data.get("timestamp").map_or(false, |v| v.is_number()) { + errors.push("Missing or invalid timestamp field".to_string()); + } + + // Range validation for numeric fields + if let Some(amount_val) = data.get("amount") { + if let Some(amount_str) = amount_val.as_str() { + match amount_str.parse::() { + Ok(amount) if amount >= 0.0 && amount <= 1e18 => { + sanitized["amount"] = json!(amount.to_string()); + } + Ok(amount) if amount < 0.0 => { + errors.push("Invalid amount: must be positive".to_string()); + } + Ok(_) => { + errors.push("Amount too large: exceeds maximum".to_string()); + } + Err(_) => { + errors.push("Invalid amount format".to_string()); + } + } + } else { + errors.push("Amount must be a string".to_string()); + } + } + + // String sanitization + if let Some(desc) = data.get("description").and_then(|v| v.as_str()) { + if desc.len() > 1000 { + errors.push("Description too long: max 1000 characters".to_string()); + } else { + // Sanitize string + let sanitized_desc = desc + .chars() + .filter(|&c| c != '<' && c != '>') + .take(1000) + .collect::(); + sanitized["description"] = json!(sanitized_desc); + } + } + + // Array validation + if let Some(tags_array) = data.get("tags").and_then(|v| v.as_array()) { + let sanitized_tags: Vec = tags_array + .iter() + .filter_map(|tag| tag.as_str()) + .filter(|tag| tag.len() <= 50) + .take(10) + .map(|s| s.to_string()) + .collect(); + sanitized["tags"] = json!(sanitized_tags); + } + + ValidationResult { + valid: errors.is_empty(), + sanitized_data: if errors.is_empty() { Some(sanitized) } else { None }, + errors: if errors.is_empty() { None } else { Some(errors) }, + } +} +``` + + + + +## Error Recovery Strategies + +### Strategy 1: Circuit Breaker Pattern + +Automatically handle failing contracts by implementing a circuit breaker: + + + + +```typescript +// Track contract failure rates +contract_failures: LookupMap = new LookupMap("failures"); +contract_success_counts: LookupMap = new LookupMap("successes"); + +@call({privateFunction: true}) +circuit_breaker_callback({contracts}: {contracts: AccountId[]}) { + const results = []; + + for (let i = 0; i < contracts.length; i++) { + const contract_id = contracts[i]; + const result = getValueFromPromise(i); + + if (result.success) { + // Reset failure count on success + this.contract_failures.set(contract_id, 0); + const success_count = this.contract_success_counts.get(contract_id) || 0; + this.contract_success_counts.set(contract_id, success_count + 1); + + results.push({ + contract_id, + status: 'success', + data: JSON.parse(result.value) + }); + } else { + // Increment failure count + const failure_count = this.contract_failures.get(contract_id) || 0; + this.contract_failures.set(contract_id, failure_count + 1); + + const circuit_open = failure_count >= 3; // Circuit opens after 3 failures + + results.push({ + contract_id, + status: circuit_open ? 'circuit_open' : 'failed', + error: result.error, + failure_count: failure_count + 1, + circuit_open + }); + } + } + + return { + results, + circuit_status: this.get_circuit_status(contracts) + }; +} + +get_circuit_status(contracts: AccountId[]): any { + const status = {}; + for (const contract of contracts) { + const failures = this.contract_failures.get(contract) || 0; + const successes = this.contract_success_counts.get(contract) || 0; + const total_calls = failures + successes; + + status[contract] = { + failure_count: failures, + success_count: successes, + success_rate: total_calls > 0 ? (successes / total_calls * 100).toFixed(1) + '%' : 'N/A', + circuit_open: failures >= 3 + }; + } + return status; +} +``` + + + + +```rust +#[near_bindgen] +impl Contract { + #[private] + pub fn circuit_breaker_callback(&mut self, contracts: Vec) -> serde_json::Value { + let mut results = Vec::new(); + + for (i, contract_id) in contracts.iter().enumerate() { + match env::promise_result(i) { + PromiseResult::Successful(result) => { + // Reset failure count on success + self.contract_failures.insert(contract_id, &0); + let success_count = self.contract_success_counts.get(contract_id).unwrap_or(0); + self.contract_success_counts.insert(contract_id, &(success_count + 1)); + + if let Ok(data) = near_sdk::serde_json::from_slice::(&result) { + results.push(json!({ + "contract_id": contract_id, + "status": "success", + "data": data + })); + } + } + PromiseResult::Failed => { + // Increment failure count + let failure_count = self.contract_failures.get(contract_id).unwrap_or(0); + self.contract_failures.insert(contract_id, &(failure_count + 1)); + + let circuit_open = failure_count >= 2; // Circuit opens after 3 total failures + + results.push(json!({ + "contract_id": contract_id, + "status": if circuit_open { "circuit_open" } else { "failed" }, + "failure_count": failure_count + 1, + "circuit_open": circuit_open + })); + } + } + } + + json!({ + "results": results, + "circuit_status": self.get_circuit_status(&contracts) + }) + } + + fn get_circuit_status(&self, contracts: &[AccountId]) -> serde_json::Value { + let mut status = serde_json::Map::new(); + + for contract in contracts { + let failures = self.contract_failures.get(contract).unwrap_or(0); + let successes = self.contract_success_counts.get(contract).unwrap_or(0); + let total_calls = failures + successes; + + let success_rate = if total_calls > 0 { + format!("{:.1}%", (successes as f64 / total_calls as f64) * 100.0) + } else { + "N/A".to_string() + }; + + status.insert(contract.to_string(), json!({ + "failure_count": failures, + "success_count": successes, + "success_rate": success_rate, + "circuit_open": failures >= 3 + })); + } + + json!(status) + } +} +``` + + + + +### Strategy 2: Fallback Data Sources + +Implement fallback mechanisms when primary data sources fail: + + + + +```typescript +@call({}) +get_price_with_fallbacks({ + primary_oracles, + fallback_oracles, + token_id +}: { + primary_oracles: AccountId[]; + fallback_oracles: AccountId[]; + token_id: string; +}) { + // Try primary oracles first + const primary_promises = primary_oracles.map(oracle => + NearPromise.new(oracle) + .functionCall( + "get_price", + JSON.stringify({token_id}), + 0n, + 10_000_000_000_000n + ) + ); + + // Combine all primary promises + let combined_promise = primary_promises[0]; + for (let i = 1; i < primary_promises.length; i++) { + combined_promise = combined_promise.and(primary_promises[i]); + } + + return combined_promise.then( + NearPromise.new(env.current_account_id()) + .functionCall( + "price_callback_with_fallback", + JSON.stringify({ + token_id, + fallback_oracles, + attempt: 1 + }), + 0n, + 30_000_000_000_000n + ) + ); +} + +@call({privateFunction: true}) +price_callback_with_fallback({ + token_id, + fallback_oracles, + attempt +}: { + token_id: string; + fallback_oracles: AccountId[]; + attempt: number; +}) { + const primary_results = []; + let valid_prices = 0; + let price_sum = 0; + + // Check primary oracle results + for (let i = 0; i < env.promise_results_count(); i++) { + const result = getValueFromPromise(i); + if (result.success) { + try { + const price_data = JSON.parse(result.value); + if (price_data.price && price_data.price > 0) { + primary_results.push(price_data.price); + price_sum += parseFloat(price_data.price); + valid_prices++; + } + } catch (error) { + // Invalid price data, continue + } + } + } + + // If we have enough valid prices, return average + if (valid_prices >= 2) { + const average_price = (price_sum / valid_prices).toString(); + return { + token_id, + price: average_price, + source: 'primary_oracles', + oracle_count: valid_prices, + attempt, + confidence: valid_prices >= 3 ? 'high' : 'medium' + }; + } + + // Not enough valid prices, try fallback oracles + if (fallback_oracles.length > 0 && attempt <= 2) { + return this.try_fallback_oracles(token_id, fallback_oracles, attempt + 1); + } + + // All oracles failed, return cached price or error + return { + token_id, + price: null, + source: 'failed', + error: `All ${attempt} attempts failed. Primary: ${env.promise_results_count()}, Fallback: ${fallback_oracles.length}`, + attempt + }; +} + +try_fallback_oracles(token_id: string, fallback_oracles: AccountId[], attempt: number): NearPromise { + const fallback_promises = fallback_oracles.map(oracle => + NearPromise.new(oracle) + .functionCall( + "get_price", + JSON.stringify({token_id}), + 0n, + 10_000_000_000_000n + ) + ); + + let combined_promise = fallback_promises[0]; + for (let i = 1; i < fallback_promises.length; i++) { + combined_promise = combined_promise.and(fallback_promises[i]); + } + + return combined_promise.then( + NearPromise.new(env.current_account_id()) + .functionCall( + "fallback_price_callback", + JSON.stringify({token_id, attempt}), + 0n, + 15_000_000_000_000n + ) + ); +} +``` + + + + +```rust +pub fn get_price_with_fallbacks( + &mut self, + primary_oracles: Vec, + fallback_oracles: Vec, + token_id: String, +) -> Promise { + // Create promises for all primary oracles + let mut promise_chain = Promise::new(primary_oracles[0].clone()) + .function_call( + "get_price".to_owned(), + json!({"token_id": token_id}).to_string().into_bytes(), + 0, + Gas::from_tgas(10), + ); + + // Chain additional primary oracles + for oracle in primary_oracles.iter().skip(1) { + promise_chain = promise_chain.and( + Promise::new(oracle.clone()).function_call( + "get_price".to_owned(), + json!({"token_id": token_id}).to_string().into_bytes(), + 0, + Gas::from_tgas(10), + ) + ); + } + + // Add callback with fallback information + promise_chain.then( + Promise::new(env::current_account_id()).function_call( + "price_callback_with_fallback".to_owned(), + json!({ + "token_id": token_id, + "fallback_oracles": fallback_oracles, + "attempt": 1 + }).to_string().into_bytes(), + 0, + Gas::from_tgas(30), + ) + ) +} + +#[private] +pub fn price_callback_with_fallback( + &mut self, + token_id: String, + fallback_oracles: Vec, + attempt: u8, +) -> serde_json::Value { + let mut valid_prices = Vec::new(); + + // Process primary oracle results + for i in 0..env::promise_results_count() { + if let PromiseResult::Successful(result) = env::promise_result(i) { + if let Ok(price_data) = near_sdk::serde_json::from_slice::(&result) { + if let Some(price) = price_data.get("price").and_then(|p| p.as_str()) { + if let Ok(price_num) = price.parse::() { + if price_num > 0.0 { + valid_prices.push(price_num); + } + } + } + } + } + } + + // If we have enough valid prices, return average + if valid_prices.len() >= 2 { + let average_price = valid_prices.iter().sum::() / valid_prices.len() as f64; + let confidence = if valid_prices.len() >= 3 { "high" } else { "medium" }; + + return json!({ + "token_id": token_id, + "price": average_price.to_string(), + "source": "primary_oracles", + "oracle_count": valid_prices.len(), + "attempt": attempt, + "confidence": confidence + }); + } + + // Not enough valid prices, try fallback if available + if !fallback_oracles.is_empty() && attempt <= 2 { + // This would trigger fallback oracle calls + return json!({ + "token_id": token_id, + "price": null, + "source": "retrying_with_fallback", + "attempt": attempt + }); + } + + // All attempts failed + json!({ + "token_id": token_id, + "price": null, + "source": "failed", + "error": format!("All {} attempts failed", attempt), + "attempt": attempt + }) +} +``` + + + + +## Testing Response Handling + +Testing complex response handling requires simulating various success and failure scenarios: + + + + +```typescript +test('handles mixed success and failure responses', async (t) => { + const contract = t.context.contract; + + // Mock contracts that will return different results + const result = await contract.call( + 'multiple_contracts', + {}, + { gas: '300000000000000' } + ); + + // Verify response contains both successes and handles failures gracefully + t.true(result.includes('Hello:')); + t.true(result.includes('Counter:')); + t.true(typeof result === 'string'); +}); + +test('validates and sanitizes response data', async (t) => { + const contract = t.context.contract; + + // Test with various data formats + const result = await contract.call( + 'validated_response_test', + { + test_data: [ + { id: 'valid1', amount: '100', description: 'Valid data' }, + { id: 'invalid1', amount: 'not_a_number', description: 'Invalid amount' }, + { amount: '200', description: 'Missing ID' }, // Missing required field + { id: 'too_large', amount: '999999999999999999999', description: 'Amount too large' } + ] + }, + { gas: '300000000000000' } + ); + + const parsed = JSON.parse(result); + + // Should have 1 valid result and 3 validation errors + t.is(parsed.valid_results.length, 1); + t.is(parsed.validation_errors.length, 3); + t.is(parsed.summary.success_rate, '25.0%'); +}); + +test('circuit breaker prevents repeated calls to failing contracts', async (t) => { + const contract = t.context.contract; + + // Simulate multiple failures to trigger circuit breaker + for (let i = 0; i < 4; i++) { + await contract.call('call_failing_contract', {}, { gas: '100000000000000' }); + } + + const status = await contract.view('get_circuit_status', { + contracts: ['failing-contract.testnet'] + }); + + t.true(status['failing-contract.testnet'].circuit_open); + t.is(status['failing-contract.testnet'].failure_count, 3); +}); + +test('fallback data sources work correctly', async (t) => { + const contract = t.context.contract; + + const result = await contract.call( + 'get_price_with_fallbacks', + { + primary_oracles: ['unreliable-oracle.testnet'], + fallback_oracles: ['reliable-oracle.testnet', 'backup-oracle.testnet'], + token_id: 'NEAR' + }, + { gas: '300000000000000' } + ); + + const parsed = JSON.parse(result); + + // Should eventually get a price from fallback oracles + t.truthy(parsed.price); + t.is(parsed.source, 'primary_oracles'); // Or 'fallback_oracles' depending on test setup +}); +``` + + + + +```rust +#[tokio::test] +async fn test_mixed_response_handling() -> Result<(), Box> { + let sandbox = near_workspaces::sandbox().await?; + let contract_account = sandbox.dev_account().await?; + + // Deploy main contract and test contracts + let wasm = near_workspaces::compile_project("./").await?; + let contract = contract_account.deploy(&wasm).await?.unwrap(); + + let result = contract + .call("multiple_contracts") + .gas(300_000_000_000_000) + .transact() + .await?; + + assert!(result.is_success()); + + // Parse response and verify structure + let response: serde_json::Value = result.json()?; + assert!(response.get("summary").is_some()); + assert!(response.get("results").is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_response_validation() -> Result<(), Box> { + let sandbox = near_workspaces::sandbox().await?; + let contract_account = sandbox.dev_account().await?; + let wasm = near_workspaces::compile_project("./").await?; + let contract = contract_account.deploy(&wasm).await?.unwrap(); + + // Test data validation with various formats + let test_data = json!([ + {"id": "valid1", "amount": "100", "description": "Valid data"}, + {"id": "invalid1", "amount": "not_a_number", "description": "Invalid amount"}, + {"amount": "200", "description": "Missing ID"}, + {"id": "too_large", "amount": "999999999999999999999", "description": "Too large"} + ]); + + let result = contract + .call("validated_response_test") + .args_json(json!({"test_data": test_data})) + .gas(300_000_000_000_000) + .transact() + .await?; + + let response: serde_json::Value = result.json()?; + + // Verify validation results + assert_eq!(response["valid_results"].as_array().unwrap().len(), 1); + assert_eq!(response["validation_errors"].as_array().unwrap().len(), 3); + assert_eq!(response["summary"]["success_rate"], "25.0%"); + + Ok(()) +} + +#[tokio::test] +async fn test_circuit_breaker_functionality() -> Result<(), Box> { + let sandbox = near_workspaces::sandbox().await?; + let contract_account = sandbox.dev_account().await?; + let wasm = near_workspaces::compile_project("./").await?; + let contract = contract_account.deploy(&wasm).await?.unwrap(); + + // Trigger multiple failures + for _ in 0..4 { + let _ = contract + .call("call_failing_contract") + .gas(100_000_000_000_000) + .transact() + .await; + } + + // Check circuit breaker status + let status = contract + .view("get_circuit_status") + .args_json(json!({"contracts": ["failing-contract.testnet"]})) + .await?; + + let circuit_status: serde_json::Value = status.json()?; + assert_eq!(circuit_status["failing-contract.testnet"]["circuit_open"], true); + + Ok(()) +} +``` + + + + +## Performance Optimization Techniques + +### Technique 1: Response Caching + +Cache frequently accessed data to reduce redundant contract calls: + + + + +```typescript +// Cache structure for responses +response_cache: LookupMap = new LookupMap("cache"); + +interface CachedResponse { + data: any; + timestamp: number; + ttl: number; // Time to live in milliseconds +} + +@call({privateFunction: true}) +cached_response_callback({cache_key, ttl_ms}: {cache_key: string, ttl_ms: number}) { + const responses = []; + const current_time = env.block_timestamp_ms(); + + for (let i = 0; i < env.promise_results_count(); i++) { + const result = getValueFromPromise(i); + + if (result.success) { + const data = JSON.parse(result.value); + + // Cache the response + const cached_response: CachedResponse = { + data, + timestamp: current_time, + ttl: ttl_ms + }; + + const contract_cache_key = `${cache_key}_${i}`; + this.response_cache.set(contract_cache_key, cached_response); + + responses.push({ + index: i, + data, + cached: true, + timestamp: current_time + }); + } else { + responses.push({ + index: i, + error: result.error, + cached: false + }); + } + } + + return { + responses, + cache_info: { + cache_key, + ttl_ms, + cached_at: current_time + } + }; +} + +// Method to get cached data before making calls +get_cached_or_fetch({ + contracts, + method_name, + cache_key, + ttl_ms = 300000 // 5 minutes default +}: { + contracts: AccountId[]; + method_name: string; + cache_key: string; + ttl_ms?: number; +}): any { + const current_time = env.block_timestamp_ms(); + const cached_responses = []; + const contracts_to_call = []; + + // Check cache for each contract + for (let i = 0; i < contracts.length; i++) { + const contract_cache_key = `${cache_key}_${i}`; + const cached = this.response_cache.get(contract_cache_key); + + if (cached && (current_time - cached.timestamp) < cached.ttl) { + // Use cached data + cached_responses[i] = { + index: i, + data: cached.data, + cached: true, + cached_at: cached.timestamp + }; + } else { + // Need to fetch fresh data + contracts_to_call.push({index: i, contract: contracts[i]}); + } + } + + if (contracts_to_call.length === 0) { + // All data was cached + return { + responses: cached_responses, + all_cached: true + }; + } + + // Make calls only for non-cached contracts + return this.fetch_and_cache_responses(contracts_to_call, method_name, cache_key, ttl_ms); +} +``` + + + + +```rust +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +pub struct CachedResponse { + data: serde_json::Value, + timestamp: u64, + ttl: u64, +} + +#[near_bindgen] +impl Contract { + #[private] + pub fn cached_response_callback(&mut self, cache_key: String, ttl_ms: u64) -> serde_json::Value { + let mut responses = Vec::new(); + let current_time = env::block_timestamp_ms(); + + for i in 0..env::promise_results_count() { + match env::promise_result(i) { + PromiseResult::Successful(result) => { + if let Ok(data) = near_sdk::serde_json::from_slice::(&result) { + // Cache the response + let cached_response = CachedResponse { + data: data.clone(), + timestamp: current_time, + ttl: ttl_ms, + }; + + let contract_cache_key = format!("{}_{}", cache_key, i); + self.response_cache.insert(&contract_cache_key, &cached_response); + + responses.push(json!({ + "index": i, + "data": data, + "cached": true, + "timestamp": current_time + })); + } + } + PromiseResult::Failed => { + responses.push(json!({ + "index": i, + "error": "Contract call failed", + "cached": false + })); + } + } + } + + json!({ + "responses": responses, + "cache_info": { + "cache_key": cache_key, + "ttl_ms": ttl_ms, + "cached_at": current_time + } + }) + } + + pub fn get_cached_or_fetch( + &mut self, + contracts: Vec, + method_name: String, + cache_key: String, + ttl_ms: Option, + ) -> serde_json::Value { + let ttl_ms = ttl_ms.unwrap_or(300000); // 5 minutes default + let current_time = env::block_timestamp_ms(); + let mut cached_responses = Vec::new(); + let mut contracts_to_call = Vec::new(); + + // Check cache for each contract + for (i, contract) in contracts.iter().enumerate() { + let contract_cache_key = format!("{}_{}", cache_key, i); + + if let Some(cached) = self.response_cache.get(&contract_cache_key) { + if (current_time - cached.timestamp) < cached.ttl { + // Use cached data + cached_responses.push(json!({ + "index": i, + "data": cached.data, + "cached": true, + "cached_at": cached.timestamp + })); + continue; + } + } + + // Need to fetch fresh data + contracts_to_call.push((i, contract.clone())); + } + + if contracts_to_call.is_empty() { + // All data was cached + return json!({ + "responses": cached_responses, + "all_cached": true + }); + } + + // Make calls only for non-cached contracts (implementation would continue here) + json!({ + "cached_count": cached_responses.len(), + "fresh_calls_needed": contracts_to_call.len() + }) + } +} +``` + + + + +### Technique 2: Response Streaming + +For large datasets, implement streaming responses: + + + + +```typescript +@call({privateFunction: true}) +streaming_response_callback({ + batch_size, + offset, + total_expected +}: { + batch_size: number; + offset: number; + total_expected: number; +}) { + const batch_results = []; + let processed = 0; + + for (let i = 0; i < env.promise_results_count(); i++) { + const result = getValueFromPromise(i); + + if (result.success) { + const data = JSON.parse(result.value); + + // Process data in chunks + if (Array.isArray(data)) { + const chunk_start = offset + processed; + const chunk_end = Math.min(chunk_start + batch_size, data.length); + const chunk = data.slice(chunk_start, chunk_end); + + batch_results.push({ + contract_index: i, + chunk_start, + chunk_end, + data: chunk, + has_more: chunk_end < data.length + }); + + processed += chunk.length; + } else { + batch_results.push({ + contract_index: i, + data, + has_more: false + }); + } + } + } + + const has_more_batches = processed < total_expected; + + return { + batch: { + offset, + size: processed, + results: batch_results + }, + pagination: { + has_more: has_more_batches, + next_offset: offset + processed, + total_processed: offset + processed, + total_expected + } + }; +} +``` + + + + +```rust +#[derive(Serialize, Deserialize)] +pub struct BatchResult { + contract_index: usize, + chunk_start: usize, + chunk_end: usize, + data: serde_json::Value, + has_more: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct PaginationInfo { + has_more: bool, + next_offset: usize, + total_processed: usize, + total_expected: usize, +} + +#[private] +pub fn streaming_response_callback( + &mut self, + batch_size: usize, + offset: usize, + total_expected: usize, +) -> serde_json::Value { + let mut batch_results = Vec::new(); + let mut processed = 0; + + for i in 0..env::promise_results_count() { + match env::promise_result(i) { + PromiseResult::Successful(result) => { + if let Ok(data) = near_sdk::serde_json::from_slice::(&result) { + if let Some(array_data) = data.as_array() { + // Process array data in chunks + let chunk_start = offset + processed; + let chunk_end = std::cmp::min(chunk_start + batch_size, array_data.len()); + + if chunk_start < array_data.len() { + let chunk: Vec<_> = array_data[chunk_start..chunk_end].to_vec(); + + batch_results.push(BatchResult { + contract_index: i, + chunk_start, + chunk_end, + data: json!(chunk), + has_more: chunk_end < array_data.len(), + }); + + processed += chunk.len(); + } + } else { + // Single data item + batch_results.push(BatchResult { + contract_index: i, + chunk_start: 0, + chunk_end: 1, + data, + has_more: false, + }); + } + } + } + PromiseResult::Failed => { + env::log_str(&format!("Contract {} failed in streaming callback", i)); + } + } + } + + let has_more_batches = processed < total_expected; + + json!({ + "batch": { + "offset": offset, + "size": processed, + "results": batch_results + }, + "pagination": PaginationInfo { + has_more: has_more_batches, + next_offset: offset + processed, + total_processed: offset + processed, + total_expected, + } + }) +} +``` + + + + +## Common Pitfalls and Best Practices + +### ❌ Common Mistakes + +1. **Index Misalignment**: Not properly tracking which promise result corresponds to which contract +2. **Insufficient Error Handling**: Not handling all possible failure scenarios gracefully +3. **Memory Overload**: Processing too much data at once without pagination +4. **Cache Invalidation**: Not properly managing cache expiration and updates +5. **Gas Estimation**: Underestimating gas needed for complex response processing + +### ✅ Best Practices + +1. **Structured Response Types**: Define clear interfaces for different response types +2. **Comprehensive Error Handling**: Handle partial failures, timeouts, and data corruption +3. **Response Validation**: Always validate data from external contracts +4. **Performance Monitoring**: Track response times and failure rates +5. **Graceful Degradation**: Provide meaningful responses even when some calls fail +6. **Documentation**: Document response formats and error conditions clearly + +## Summary + +Advanced response handling is crucial for building robust cross-contract applications. Key takeaways: + +- **Design for Failure**: Always expect some contract calls to fail +- **Validate Everything**: Never trust data from external contracts without validation +- **Cache Wisely**: Use caching to improve performance but manage invalidation carefully +- **Monitor Performance**: Track metrics to identify slow or failing contracts +- **Plan for Scale**: Use streaming and pagination for large datasets + +## Next Steps + +You now understand advanced response handling patterns! The final step is to learn about [testing and deployment](5-testing-deployment.md) to ensure your contracts work reliably in production. + +:::tip Response Handling Strategy + +Choose your response handling strategy based on your use case: +- **Real-time applications**: Prioritize speed with caching and circuit breakers +- **Data accuracy critical**: Focus on validation and multiple source verification +- **High availability**: Implement comprehensive fallback mechanisms +- **Large datasets**: Use streaming and pagination for scalability + +::: \ No newline at end of file diff --git a/docs/tutorials/advanced-xcc/5-testing-deployment.md b/docs/tutorials/advanced-xcc/5-testing-deployment.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/tutorials/coin-flip/0-introduction.md b/docs/tutorials/coin-flip/0-introduction.md new file mode 100644 index 00000000000..8d04a0796f7 --- /dev/null +++ b/docs/tutorials/coin-flip/0-introduction.md @@ -0,0 +1,82 @@ +--- +id: introduction +title: Building a Coin Flip Game with Secure Randomness +sidebar_label: Introduction +description: "Learn how to implement secure and fair randomness in NEAR smart contracts through a practical coin flip game tutorial." +--- + +Generating truly random numbers in blockchain applications is one of the most challenging aspects of smart contract development. Unlike traditional applications where you can use `Math.random()` or system entropy, blockchain requires all nodes to reach consensus on the same result - making randomness both critical and complex. + +This tutorial will guide you through building a **Coin Flip Game** on NEAR Protocol, teaching you how to handle randomness securely and fairly using NEAR's built-in Verifiable Random Function (VRF). + +## The Challenge of Blockchain Randomness + +In traditional programming, generating randomness seems straightforward: + +```javascript +// ❌ This won't work on blockchain! +const outcome = Math.random() > 0.5 ? 'heads' : 'tails'; +``` + +But on a blockchain, this approach fails because: + +- **Consensus Requirement**: Every validator must compute the same result +- **Predictability**: Malicious actors could manipulate predictable inputs +- **Determinism**: The same inputs must always produce the same outputs + +## NEAR's Solution: Verifiable Random Function + +NEAR Protocol solves this elegantly with a **Verifiable Random Function (VRF)** that provides: + +- **Security**: Cryptographically secure and manipulation-resistant +- **Consistency**: All nodes get the same random seed per block +- **Simplicity**: Easy-to-use APIs in both Rust and JavaScript +- **No External Dependencies**: Built directly into the protocol + +## What You'll Build + +By the end of this tutorial, you'll have created a fully functional coin flip game where: + +- Players guess "heads" or "tails" +- Correct guesses earn points, wrong guesses lose points +- All randomness is cryptographically secure and fair +- The contract handles edge cases and validates inputs + +## What You'll Learn + +This tutorial covers: + +1. [Understanding NEAR's randomness system](1-understanding-randomness.md) +2. [Setting up your development environment](2-setup.md) +3. [Building the smart contract](3-contract.md) +4. [Implementing secure random logic](4-randomness-implementation.md) +5. [Testing your contract](5-testing.md) +6. [Advanced randomness patterns](6-advanced-patterns.md) + +## Prerequisites + +Before starting, you should have: + +- Basic understanding of NEAR Protocol +- Familiarity with either Rust or JavaScript +- NEAR CLI installed ([installation guide](https://docs.near.org/tools/near-cli)) +- A NEAR testnet account + +:::info Complete Code Repository + +The complete source code for this tutorial is available in the [GitHub repository](https://github.com/near-examples/coin-flip-randomness-tutorial). + +You can also interact with the deployed contract on testnet at `coin-flip.examples.testnet`. + +::: + +## Why This Matters + +Understanding randomness on NEAR opens doors to building: + +- **Gaming Applications**: Dice games, card shuffles, loot drops +- **DeFi Protocols**: Lotteries, random reward distributions +- **NFT Projects**: Random trait generation, mystery boxes +- **Governance Tools**: Random jury selection, tie-breaking + +Let's dive in and start building your first randomness-powered dApp on NEAR! \ No newline at end of file diff --git a/docs/tutorials/coin-flip/1-understanding-randomness.md b/docs/tutorials/coin-flip/1-understanding-randomness.md new file mode 100644 index 00000000000..c8bd7e5b879 --- /dev/null +++ b/docs/tutorials/coin-flip/1-understanding-randomness.md @@ -0,0 +1,161 @@ +--- +id: understanding-randomness +title: Understanding NEAR's Randomness System +sidebar_label: How NEAR Randomness Works +description: "Deep dive into NEAR's Verifiable Random Function (VRF) and how it provides secure, consensus-friendly randomness." +--- + +Before we start building our coin flip game, it's crucial to understand how NEAR's randomness system works and why it's different from traditional random number generation. + +## The Blockchain Randomness Problem + +Traditional applications can generate randomness easily: + +```javascript +// Traditional approach - won't work on blockchain +const randomNumber = Math.random(); +const timestamp = Date.now(); +const systemEntropy = crypto.getRandomValues(new Uint32Array(1))[0]; +``` + +These methods fail on blockchain because: + +1. **Different Results**: Each validator would get different random values +2. **Consensus Breakdown**: Validators couldn't agree on the final state +3. **Manipulation Risk**: Predictable inputs can be gamed by malicious actors + +## NEAR's VRF Solution + +NEAR Protocol implements a **Verifiable Random Function (VRF)** that generates randomness using: + +- Block producer's cryptographic signature +- Previous epoch's random value +- Block height and timestamp +- Network-specific constants + +This creates a 32-byte random seed that is: + +- **Unpredictable**: Cannot be guessed before block production +- **Deterministic**: Same seed across all validators for consensus +- **Cryptographically Secure**: Suitable for most dApp use cases + +## Accessing Random Values + +NEAR provides simple APIs to access this randomness: + +### Rust Implementation + +```rust +use near_sdk::{env, near_bindgen}; + +#[near_bindgen] +impl MyContract { + pub fn get_random_value(&self) -> u8 { + let random_seed = env::random_seed(); + // Returns a 32-byte array [u8; 32] + random_seed[0] // Use first byte for simple randomness + } +} +``` + +### JavaScript Implementation + +```javascript +import { near } from 'near-sdk-js'; + +export function getRandomValue() { + const randomSeed = near.randomSeed(); + // Returns a Uint8Array of 32 bytes + return randomSeed[0]; // Use first byte for simple randomness +} +``` + +## Key Characteristics + +### Block-Level Consistency +All transactions within the same block receive the **same random seed**. This ensures consensus but means: + +```rust +// Both calls in the same block return identical values +let value1 = env::random_seed()[0]; +let value2 = env::random_seed()[0]; +assert_eq!(value1, value2); // Always true within same block +``` + +### Quality and Distribution +NEAR's VRF produces high-quality randomness with: + +- **Even Distribution**: Each byte value (0-255) appears with equal probability +- **No Patterns**: Sequential calls across blocks show no predictable patterns +- **Cryptographic Security**: Resistant to prediction and manipulation + +## Practical Implications + +### ✅ Good Use Cases +- **Gaming**: Dice rolls, card shuffles, outcome determination +- **Lotteries**: Fair winner selection +- **NFT Minting**: Random trait assignment +- **DeFi**: Random reward distributions + +### ❌ Limitations +- **Key Generation**: Not suitable for cryptographic private keys +- **Cross-Block Uniqueness**: Need additional logic for multiple random values +- **Validator Trust**: Theoretical manipulation if consensus is compromised + +## Randomness Quality Example + +Here's how you can verify NEAR's randomness quality: + +```rust +use near_sdk::collections::UnorderedMap; + +#[near_bindgen] +#[derive(BorshDeserialize, BorshSerialize)] +pub struct RandomnessMonitor { + distribution: UnorderedMap, + total_samples: u32, +} + +#[near_bindgen] +impl RandomnessMonitor { + #[init] + pub fn new() -> Self { + Self { + distribution: UnorderedMap::new(b"d"), + total_samples: 0, + } + } + + pub fn sample_randomness(&mut self) -> u8 { + let random_value = env::random_seed()[0]; + + // Track distribution + let current_count = self.distribution.get(&random_value).unwrap_or(0); + self.distribution.insert(&random_value, &(current_count + 1)); + self.total_samples += 1; + + random_value + } + + pub fn get_distribution_stats(&self) -> (u32, f64) { + let expected_per_value = self.total_samples as f64 / 256.0; + let mut variance = 0.0; + + for i in 0..=255u8 { + let actual = self.distribution.get(&i).unwrap_or(0) as f64; + let diff = actual - expected_per_value; + variance += diff * diff; + } + + (self.total_samples, variance / 256.0) + } +} +``` + +## Next Steps + +Now that you understand how NEAR's randomness works, let's set up your development environment and start building the coin flip game. The next section will guide you through the necessary tools and project structure. + +:::tip Pro Tip +While NEAR's randomness is secure for most applications, always consider your specific use case requirements. For high-stakes financial applications, additional safeguards like commit-reveal schemes might be necessary. +::: \ No newline at end of file diff --git a/docs/tutorials/coin-flip/2-setup.md b/docs/tutorials/coin-flip/2-setup.md new file mode 100644 index 00000000000..8c92efb0058 --- /dev/null +++ b/docs/tutorials/coin-flip/2-setup.md @@ -0,0 +1,314 @@ +--- +id: setup +title: Setting Up Your Development Environment +sidebar_label: Development Setup +description: "Set up your development environment for building randomness-powered NEAR smart contracts." +--- + +In this section, we'll set up everything you need to develop, test, and deploy your coin flip smart contract with secure randomness. + +## Prerequisites + +Before we begin, ensure you have: + +- **Node.js** (version 18 or higher) +- **Rust** (latest stable version) +- **Git** for version control +- A **NEAR testnet account** + +## Installing NEAR CLI + +NEAR CLI is essential for contract deployment and interaction: + +```bash +npm install -g near-cli +``` + +Verify the installation: + +```bash +near --version +``` + +## Creating a NEAR Testnet Account + +If you don't have a NEAR testnet account: + +```bash +near account create-account fund-myself coinflip-tutorial.testnet --useFaucet +``` + +## Project Setup + +Let's create our project structure. You can choose either Rust or JavaScript for your smart contract implementation. + +### Option 1: Rust Project Setup + +Create a new Rust project: + +```bash +mkdir coin-flip-near +cd coin-flip-near +cargo init --name coin_flip_contract +``` + +Update your `Cargo.toml`: + + + +```toml +[package] +name = "coin_flip_contract" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +near-sdk = "5.0.0" + +[profile.release] +codegen-units = 1 +opt-level = "z" +lto = true +debug = false +panic = "abort" +overflow-checks = true +``` + + + +### Option 2: JavaScript Project Setup + +Create a new JavaScript project: + +```bash +mkdir coin-flip-near +cd coin-flip-near +npm init -y +``` + +Install dependencies: + +```bash +npm install near-sdk-js +npm install --save-dev @babel/core @babel/preset-env babel-jest jest +``` + +Create a basic `package.json`: + + + +```json +{ + "name": "coin-flip-contract", + "version": "1.0.0", + "description": "A NEAR smart contract demonstrating secure randomness", + "main": "index.js", + "scripts": { + "build": "near-sdk-js build src/contract.js build/contract.wasm", + "test": "jest", + "deploy": "near contract deploy coinflip.testnet use-file build/contract.wasm without-init-call network-config testnet sign-with-keychain send" + }, + "dependencies": { + "near-sdk-js": "^2.0.0" + }, + "devDependencies": { + "@babel/core": "^7.22.0", + "@babel/preset-env": "^7.22.0", + "babel-jest": "^29.5.0", + "jest": "^29.5.0", + "near-workspaces": "^3.4.0" + } +} +``` + + + +## Project Structure + +Create the following directory structure: + +``` +coin-flip-near/ +├── src/ # Smart contract source code +│ ├── lib.rs # Rust main file +│ └── contract.js # JavaScript main file +├── tests/ # Integration tests +│ ├── test.rs # Rust tests +│ └── test.js # JavaScript tests +├── build/ # Compiled contract artifacts +├── Cargo.toml # Rust dependencies (Rust only) +├── package.json # Node.js dependencies (JS only) +└── README.md +``` + +## Development Tools Setup + +### For Rust Development + +Install additional Rust tools: + +```bash +# WASM target for compilation +rustup target add wasm32-unknown-unknown + +# Cargo-near for optimized builds +cargo install cargo-near +``` + +### For JavaScript Development + +Create a `.babelrc` file for Jest testing: + + + +```json +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "18" + } + } + ] + ] +} +``` + + + +## Environment Configuration + +Create a `.env` file for environment variables: + +```bash +# Network configuration +NEAR_ENV=testnet +CONTRACT_ACCOUNT_ID=coinflip.testnet + +# Test configuration +TEST_ACCOUNT_ID=test-account.testnet +``` + +## Testing Setup + +We'll use NEAR Workspaces for integration testing. Install it: + +### For Rust: + +Add to your `Cargo.toml`: + +```toml +[dev-dependencies] +near-workspaces = "0.10.0" +tokio = "1.0" +serde_json = "1.0" +``` + +### For JavaScript: + +```bash +npm install --save-dev near-workspaces ava +``` + +## IDE Configuration + +### VS Code Setup + +Create `.vscode/settings.json` for optimal development experience: + + + +```json +{ + "rust-analyzer.cargo.target": "wasm32-unknown-unknown", + "rust-analyzer.check.command": "clippy", + "editor.formatOnSave": true, + "files.associations": { + "*.rs": "rust" + } +} +``` + + + +Recommended VS Code extensions: +- **rust-analyzer**: Rust language support +- **NEAR Protocol**: NEAR-specific tooling +- **ES6 String HTML**: Better JavaScript template literal support + +## Build Scripts + +### Rust Build Script + +Create `build.sh`: + + + +```bash +#!/bin/bash +set -e + +# Build the contract +cargo near build + +# Copy the built contract to the correct location +cp target/wasm32-unknown-unknown/release/coin_flip_contract.wasm ./contract.wasm + +echo "Contract built successfully!" +``` + + + +### JavaScript Build Script + +The build is handled by the `near-sdk-js build` command in your package.json. + +## Verification + +Let's verify everything is set up correctly: + +### Test Rust Setup: + +```bash +# Compile the contract (will create a minimal hello world) +cargo check +cargo near build +``` + +### Test JavaScript Setup: + +```bash +# Install dependencies and test build +npm install +npm run build +``` + +### Test NEAR CLI: + +```bash +near account view-account-summary coinflip-tutorial.testnet network-config testnet +``` + +You should see your account details if everything is configured correctly. + +## Next Steps + +With your environment set up, you're ready to start building the smart contract! In the next section, we'll create the basic structure of our coin flip contract and implement the core game logic. + +:::tip Development Tips +- Use `cargo check` frequently during Rust development for faster compilation +- Enable format-on-save in your IDE to maintain consistent code style +- Keep your NEAR CLI updated: `npm update -g near-cli` +::: + +:::info Troubleshooting +If you encounter issues: +- Ensure Node.js version is 18+ +- Check that Rust is properly installed with `rustc --version` +- Verify NEAR CLI can connect: `near validators current` +::: \ No newline at end of file diff --git a/docs/tutorials/coin-flip/3-contract.md b/docs/tutorials/coin-flip/3-contract.md new file mode 100644 index 00000000000..16541e3f653 --- /dev/null +++ b/docs/tutorials/coin-flip/3-contract.md @@ -0,0 +1,540 @@ +--- +id: contract +title: Building the Coin Flip Smart Contract +sidebar_label: Smart Contract Implementation +description: "Implement the core coin flip game logic and contract structure using NEAR's smart contract SDK." +--- + +Now that we have our development environment ready, let's build the core smart contract for our coin flip game. We'll start with the basic structure and gradually add the randomness functionality. + +## Contract Architecture + +Our coin flip contract will have: +- **Game Logic**: Handle player guesses and determine outcomes +- **Point System**: Track player scores across games +- **State Management**: Persist player data between transactions +- **Input Validation**: Ensure secure and valid user inputs + +## Basic Contract Structure + +Let's start with the fundamental contract structure: + +### Rust Implementation + +Create `src/lib.rs`: + + + +```rust +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::collections::UnorderedMap; +use near_sdk::{env, near_bindgen, AccountId, PanicOnDefault}; + +// Define the possible coin flip outcomes +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq)] +pub enum CoinSide { + Heads, + Tails, +} + +// Main contract structure +#[near_bindgen] +#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +pub struct CoinFlipContract { + // Store player points + player_points: UnorderedMap, + // Track total games played + total_games: u64, + // Track total players + total_players: u32, +} + +// Contract implementation +#[near_bindgen] +impl CoinFlipContract { + /// Initialize the contract + #[init] + pub fn new() -> Self { + Self { + player_points: UnorderedMap::new(b"p"), + total_games: 0, + total_players: 0, + } + } + + /// Get current points for a player + pub fn get_points(&self, player: AccountId) -> u32 { + self.player_points.get(&player).unwrap_or(0) + } + + /// Get total games played + pub fn get_total_games(&self) -> u64 { + self.total_games + } + + /// Get total number of players + pub fn get_total_players(&self) -> u32 { + self.total_players + } +} +``` + + + +### JavaScript Implementation + +Create `src/contract.js`: + + + +```javascript +import { NearBindgen, near, call, view, UnorderedMap } from 'near-sdk-js'; + +// Define valid coin sides +const VALID_SIDES = ['heads', 'tails']; + +@NearBindgen({}) +export class CoinFlipContract { + constructor() { + // Initialize player points storage + this.playerPoints = new UnorderedMap('points'); + // Track game statistics + this.totalGames = 0; + this.totalPlayers = 0; + } + + @view({}) + getPoints({ player }) { + return this.playerPoints.get(player, { defaultValue: 0 }); + } + + @view({}) + getTotalGames() { + return this.totalGames; + } + + @view({}) + getTotalPlayers() { + return this.totalPlayers; + } +} +``` + + + +## Adding Game Logic Structure + +Now let's add the core game logic without randomness first: + +### Rust Game Logic + +Add these methods to your Rust contract: + + + +```rust +impl CoinFlipContract { + /// Main game function - flip coin and update points + pub fn flip_coin(&mut self, guess: CoinSide) -> CoinSide { + let player = env::predecessor_account_id(); + + // We'll add randomness logic here in the next step + let outcome = CoinSide::Heads; // Placeholder + + // Update player statistics + self.update_player_stats(&player, &guess, &outcome); + + // Log the result + env::log_str(&format!("Player {} guessed {:?}, outcome was {:?}", + player, guess, outcome)); + + outcome + } + + /// Internal method to update player statistics + fn update_player_stats(&mut self, player: &AccountId, guess: &CoinSide, outcome: &CoinSide) { + let current_points = self.player_points.get(player).unwrap_or(0); + + // Check if this is a new player + if current_points == 0 && !self.player_points.contains_key(player) { + self.total_players += 1; + } + + // Calculate new points + let new_points = if guess == outcome { + current_points + 1 // Correct guess: +1 point + } else { + current_points.saturating_sub(1) // Wrong guess: -1 point (min 0) + }; + + // Update storage + self.player_points.insert(player, &new_points); + self.total_games += 1; + + // Log point change + let change = if guess == outcome { "+1" } else { "-1" }; + env::log_str(&format!("Points: {} → {} ({})", current_points, new_points, change)); + } + + /// Get player's game history summary + pub fn get_player_stats(&self, player: AccountId) -> (u32, bool) { + let points = self.player_points.get(&player).unwrap_or(0); + let has_played = self.player_points.contains_key(&player); + (points, has_played) + } +} +``` + + + +### JavaScript Game Logic + +Add these methods to your JavaScript contract: + + + +```javascript +export class CoinFlipContract { + @call({}) + flipCoin({ guess }) { + // Validate input + if (!VALID_SIDES.includes(guess)) { + throw new Error(`Invalid guess: must be ${VALID_SIDES.join(' or ')}`); + } + + const player = near.predecessorAccountId(); + + // We'll add randomness logic here in the next step + const outcome = 'heads'; // Placeholder + + // Update player statistics + this.updatePlayerStats(player, guess, outcome); + + // Log the result + near.log(`Player ${player} guessed ${guess}, outcome was ${outcome}`); + + return outcome; + } + + updatePlayerStats(player, guess, outcome) { + const currentPoints = this.playerPoints.get(player, { defaultValue: 0 }); + + // Check if this is a new player + if (currentPoints === 0 && !this.playerPoints.containsKey(player)) { + this.totalPlayers += 1; + } + + // Calculate new points + const newPoints = guess === outcome + ? currentPoints + 1 // Correct guess: +1 point + : Math.max(0, currentPoints - 1); // Wrong guess: -1 point (min 0) + + // Update storage + this.playerPoints.set(player, newPoints); + this.totalGames += 1; + + // Log point change + const change = guess === outcome ? '+1' : '-1'; + near.log(`Points: ${currentPoints} → ${newPoints} (${change})`); + } + + @view({}) + getPlayerStats({ player }) { + const points = this.playerPoints.get(player, { defaultValue: 0 }); + const hasPlayed = this.playerPoints.containsKey(player); + return { points, hasPlayed }; + } +} +``` + + + +## Input Validation and Security + +Let's add robust input validation to prevent common issues: + +### Rust Validation + + + +```rust +impl CoinFlipContract { + /// Validate that the caller is not a contract (optional security measure) + fn validate_caller(&self) { + let caller = env::predecessor_account_id(); + + // Simple check: ensure caller ID doesn't contain dots after the first one + // This helps prevent some contract-to-contract calls + let parts: Vec<&str> = caller.as_str().split('.').collect(); + if parts.len() > 2 { + env::panic_str("Direct user accounts only"); + } + } + + /// Enhanced flip coin with validation + pub fn flip_coin_secure(&mut self, guess: CoinSide) -> CoinSide { + // Validate caller (optional) + // self.validate_caller(); + + // Additional validation could go here + // For example: minimum account age, maximum games per block, etc. + + self.flip_coin(guess) + } +} +``` + + + +## Error Handling + +Add comprehensive error handling: + +### Rust Error Handling + + + +```rust +// Custom error types +#[derive(BorshSerialize, BorshDeserialize)] +pub enum GameError { + InvalidGuess, + ContractCallNotAllowed, + RateLimitExceeded, +} + +impl std::fmt::Display for GameError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + GameError::InvalidGuess => write!(f, "Invalid guess: must be Heads or Tails"), + GameError::ContractCallNotAllowed => write!(f, "Contract-to-contract calls not allowed"), + GameError::RateLimitExceeded => write!(f, "Too many games played in this block"), + } + } +} +``` + + + +### JavaScript Error Handling + + + +```javascript +export class CoinFlipContract { + validateInput(guess) { + if (typeof guess !== 'string') { + throw new Error('Guess must be a string'); + } + + if (!VALID_SIDES.includes(guess.toLowerCase())) { + throw new Error(`Invalid guess: must be ${VALID_SIDES.join(' or ')}`); + } + + return guess.toLowerCase(); + } + + @call({}) + flipCoinSecure({ guess }) { + try { + const validatedGuess = this.validateInput(guess); + return this.flipCoin({ guess: validatedGuess }); + } catch (error) { + near.log(`Error: ${error.message}`); + throw error; + } + } +} +``` + + + +## Contract State Management + +Add methods to manage contract state: + +### Rust State Management + + + +```rust +impl CoinFlipContract { + /// Reset a player's points (admin function - could add access control) + pub fn reset_player_points(&mut self, player: AccountId) { + let current_points = self.player_points.get(&player).unwrap_or(0); + if current_points > 0 { + self.player_points.remove(&player); + env::log_str(&format!("Reset points for player: {}", player)); + } + } + + /// Get contract statistics + pub fn get_contract_stats(&self) -> (u64, u32, u32) { + let active_players = self.player_points.len(); + (self.total_games, self.total_players, active_players) + } +} +``` + + + +## Building and Testing the Contract + +Let's build our contract to ensure everything compiles correctly: + +### Rust Build + +```bash +cargo near build +``` + +### JavaScript Build + +```bash +npm run build +``` + +## Testing Basic Functionality + +Create a simple test to verify our contract structure: + +### Rust Test + +Create `tests/test_basic.rs`: + + + +```rust +use coin_flip_contract::{CoinFlipContract, CoinSide}; + +#[tokio::test] +async fn test_contract_initialization() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + // Test contract initialization + let stats: (u64, u32, u32) = contract + .call("get_contract_stats") + .transact() + .await? + .json()?; + + assert_eq!(stats, (0, 0, 0)); // No games, no players initially + println!("✅ Contract initialized correctly"); + + Ok(()) +} +``` + + + +### JavaScript Test + +Create `tests/test_basic.js`: + + + +```javascript +import { Worker } from 'near-workspaces'; +import test from 'ava'; + +test.beforeEach(async (t) => { + const worker = t.context.worker = await Worker.init(); + const root = worker.rootAccount; + const contract = await root.createSubAccount('contract'); + + // Deploy the contract + await contract.deploy('./build/contract.wasm'); + + t.context.accounts = { root, contract }; +}); + +test.afterEach.always(async (t) => { + await t.context.worker.tearDown(); +}); + +test('contract initializes with zero stats', async (t) => { + const { contract } = t.context.accounts; + + const totalGames = await contract.view('getTotalGames'); + const totalPlayers = await contract.view('getTotalPlayers'); + + t.is(totalGames, 0); + t.is(totalPlayers, 0); +}); + +test('player starts with zero points', async (t) => { + const { root, contract } = t.context.accounts; + + const points = await contract.view('getPoints', { player: root.accountId }); + t.is(points, 0); +}); +``` + + + +Run the tests: + +```bash +# Rust +cargo test + +# JavaScript +npm test +``` + +## Contract Deployment + +Let's deploy our basic contract to testnet: + +### Deploy Command + +```bash +# Build first +cargo near build # or npm run build + +# Deploy +near contract deploy coinflip.testnet use-file ./target/wasm32-unknown-unknown/release/coin_flip_contract.wasm with-init-call new json-args {} prepaid-gas 100.0 Tgas attached-deposit 0 NEAR network-config testnet sign-with-keychain send +``` + +### Verify Deployment + +```bash +# Check contract stats +near contract call-function as-read-only coinflip.testnet get_contract_stats json-args {} network-config testnet now + +# Test getting points for an account +near contract call-function as-read-only coinflip.testnet get_points json-args '{"player": "your-account.testnet"}' network-config testnet now +``` + +## What We've Built So Far + +At this point, we have: + +✅ **Contract Structure**: Basic coin flip game framework +✅ **State Management**: Player points storage and statistics +✅ **Input Validation**: Secure input handling and error management +✅ **Game Logic**: Point system for wins/losses +✅ **Testing Framework**: Basic tests to verify functionality +✅ **Deployment**: Contract deployed to testnet + +❌ **Missing**: Actual randomness implementation (coming next!) + +## Next Steps + +Our contract structure is solid, but we're still using a placeholder for the coin flip outcome. In the next section, we'll implement the core randomness logic using NEAR's VRF system to make our coin flips truly random and fair. + +The current contract always returns "Heads" - not very exciting for a coin flip game! Let's fix that by diving into the randomness implementation. + +:::tip Development Note +Notice how we built the contract structure first before adding randomness. This approach helps you: +- Test the core game logic independently +- Ensure proper state management +- Validate input handling +- Verify the contract compiles and deploys correctly +::: + +:::info Current Contract State +Your contract is now deployed and functional, but deterministic. Players will always get the same result, making it predictable. The randomness implementation in the next section will make each flip truly unpredictable! +::: \ No newline at end of file diff --git a/docs/tutorials/coin-flip/4-randomness-implementation.md b/docs/tutorials/coin-flip/4-randomness-implementation.md new file mode 100644 index 00000000000..e105f30d125 --- /dev/null +++ b/docs/tutorials/coin-flip/4-randomness-implementation.md @@ -0,0 +1,516 @@ +--- +id: randomness-implementation +title: Implementing Secure Randomness +sidebar_label: Adding Randomness Logic +description: "Implement NEAR's VRF-based randomness system in your coin flip contract for fair and unpredictable outcomes." +--- + +Now comes the exciting part - implementing true randomness in our coin flip game! We'll replace our placeholder logic with NEAR's secure Verifiable Random Function (VRF) to create fair, unpredictable coin flips. + +## Understanding the Implementation + +NEAR's `env::random_seed()` and `near.randomSeed()` provide a 32-byte array of cryptographically secure random data. For our coin flip, we need to convert this into a simple binary choice: heads or tails. + +## Basic Randomness Implementation + +Let's start with the simplest approach - using one byte from the random seed: + +### Rust Implementation + +Replace the placeholder in your `flip_coin` method: + + + +```rust +impl CoinFlipContract { + /// Generate a random coin flip outcome + fn generate_coin_flip(&self) -> CoinSide { + let random_seed = env::random_seed(); + + // Use the first byte of the random seed + // Even numbers = Heads, Odd numbers = Tails + if random_seed[0] % 2 == 0 { + CoinSide::Heads + } else { + CoinSide::Tails + } + } + + /// Main game function with real randomness + pub fn flip_coin(&mut self, guess: CoinSide) -> CoinSide { + let player = env::predecessor_account_id(); + + // Generate random outcome + let outcome = self.generate_coin_flip(); + + // Update player statistics + self.update_player_stats(&player, &guess, &outcome); + + // Log the result for transparency + env::log_str(&format!( + "🎲 Player {} guessed {:?}, coin landed on {:?}", + player, guess, outcome + )); + + outcome + } +} +``` + + + +### JavaScript Implementation + +Update your `flipCoin` method: + + + +```javascript +export class CoinFlipContract { + generateCoinFlip() { + const randomSeed = near.randomSeed(); + + // Use the first byte of the random seed + // Even numbers = heads, Odd numbers = tails + return randomSeed[0] % 2 === 0 ? 'heads' : 'tails'; + } + + @call({}) + flipCoin({ guess }) { + // Validate input + if (!VALID_SIDES.includes(guess)) { + throw new Error(`Invalid guess: must be ${VALID_SIDES.join(' or ')}`); + } + + const player = near.predecessorAccountId(); + + // Generate random outcome + const outcome = this.generateCoinFlip(); + + // Update player statistics + this.updatePlayerStats(player, guess, outcome); + + // Log the result for transparency + near.log(`🎲 Player ${player} guessed ${guess}, coin landed on ${outcome}`); + + return outcome; + } +} +``` + + + +## Enhanced Randomness for Better Distribution + +Using a single byte gives us good randomness, but we can improve distribution by using multiple bytes: + +### Rust Enhanced Implementation + + + +```rust +impl CoinFlipContract { + /// Enhanced random generation using multiple bytes + fn generate_coin_flip_enhanced(&self) -> CoinSide { + let random_seed = env::random_seed(); + + // Combine multiple bytes for better distribution + let combined_randomness = (random_seed[0] as u32) + .wrapping_add((random_seed[1] as u32) << 8) + .wrapping_add((random_seed[2] as u32) << 16) + .wrapping_add((random_seed[3] as u32) << 24); + + // Use modulo to determine outcome + if combined_randomness % 2 == 0 { + CoinSide::Heads + } else { + CoinSide::Tails + } + } + + /// Alternative: Use hash-based randomness for even better distribution + fn generate_coin_flip_hash(&self) -> CoinSide { + use near_sdk::env; + + let random_seed = env::random_seed(); + let block_height = env::block_height(); + let timestamp = env::block_timestamp(); + + // Create additional entropy by combining with block data + // Note: This is still deterministic within the same block + let entropy_source = format!("{:?}-{}-{}", random_seed, block_height, timestamp); + let hash = env::sha256(entropy_source.as_bytes()); + + // Use the hash for randomness + hash[0] % 2 == 0 ? CoinSide::Heads : CoinSide::Tails + } +} +``` + + + +### JavaScript Enhanced Implementation + + + +```javascript +export class CoinFlipContract { + generateCoinFlipEnhanced() { + const randomSeed = near.randomSeed(); + + // Combine multiple bytes for better distribution + const combinedRandomness = randomSeed[0] + + (randomSeed[1] << 8) + + (randomSeed[2] << 16) + + (randomSeed[3] << 24); + + return combinedRandomness % 2 === 0 ? 'heads' : 'tails'; + } + + generateCoinFlipHash() { + const randomSeed = near.randomSeed(); + const blockHeight = near.blockHeight(); + const timestamp = near.blockTimestamp(); + + // Create additional entropy (still deterministic within same block) + const entropySource = `${Array.from(randomSeed).join(',')}-${blockHeight}-${timestamp}`; + const hash = near.sha256(entropySource); + + return hash[0] % 2 === 0 ? 'heads' : 'tails'; + } +} +``` + + + +## Handling Block-Level Consistency + +Remember that all transactions in the same block get the same random seed. For some applications, you might want to introduce additional variance: + +### Player-Specific Randomness + + + +```rust +impl CoinFlipContract { + /// Generate randomness that's unique per player within the same block + fn generate_player_specific_flip(&self, player: &AccountId) -> CoinSide { + let random_seed = env::random_seed(); + + // Combine random seed with player account ID for uniqueness + let player_bytes = player.as_str().as_bytes(); + let mut combined = Vec::with_capacity(32 + player_bytes.len()); + combined.extend_from_slice(&random_seed); + combined.extend_from_slice(player_bytes); + + // Hash the combined data + let hash = env::sha256(&combined); + + if hash[0] % 2 == 0 { + CoinSide::Heads + } else { + CoinSide::Tails + } + } +} +``` + + + +### JavaScript Player-Specific Implementation + + + +```javascript +export class CoinFlipContract { + generatePlayerSpecificFlip(player) { + const randomSeed = near.randomSeed(); + + // Combine random seed with player account for uniqueness + const playerBytes = new TextEncoder().encode(player); + const combined = new Uint8Array(randomSeed.length + playerBytes.length); + combined.set(randomSeed); + combined.set(playerBytes, randomSeed.length); + + // Hash the combined data + const hash = near.sha256(combined); + + return hash[0] % 2 === 0 ? 'heads' : 'tails'; + } +} +``` + + + +## Adding Randomness Transparency + +For trust and debugging, let's add methods to expose randomness information: + +### Rust Transparency Methods + + + +```rust +impl CoinFlipContract { + /// Get the current random seed for transparency (view only) + pub fn get_current_random_seed(&self) -> Vec { + env::random_seed().to_vec() + } + + /// Get randomness info for the current block + pub fn get_randomness_info(&self) -> (u64, u64, String) { + let block_height = env::block_height(); + let timestamp = env::block_timestamp(); + let seed_first_bytes = format!("{:02x}{:02x}{:02x}{:02x}", + env::random_seed()[0], env::random_seed()[1], + env::random_seed()[2], env::random_seed()[3]); + + (block_height, timestamp, seed_first_bytes) + } + + /// Simulate a coin flip without changing state (for testing) + pub fn simulate_flip(&self, player: AccountId) -> CoinSide { + self.generate_player_specific_flip(&player) + } +} +``` + + + +### JavaScript Transparency Methods + + + +```javascript +export class CoinFlipContract { + @view({}) + getCurrentRandomSeed() { + return Array.from(near.randomSeed()); + } + + @view({}) + getRandomnessInfo() { + const blockHeight = near.blockHeight(); + const timestamp = near.blockTimestamp(); + const seed = near.randomSeed(); + const seedPreview = `${seed[0].toString(16).padStart(2, '0')}${seed[1].toString(16).padStart(2, '0')}${seed[2].toString(16).padStart(2, '0')}${seed[3].toString(16).padStart(2, '0')}`; + + return { + blockHeight: blockHeight.toString(), + timestamp: timestamp.toString(), + seedPreview + }; + } + + @view({}) + simulateFlip({ player }) { + return this.generatePlayerSpecificFlip(player); + } +} +``` + + + +## Testing Randomness Quality + +Let's create tests to verify our randomness implementation works correctly: + +### Rust Randomness Tests + +Create `tests/test_randomness.rs`: + + + +```rust +use coin_flip_contract::{CoinFlipContract, CoinSide}; +use near_workspaces::{types::NearToken, Account, Contract}; +use serde_json::json; + +#[tokio::test] +async fn test_randomness_distribution() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + // Create test account + let alice = worker.dev_create_account().await?; + + let mut heads_count = 0; + let mut tails_count = 0; + let total_flips = 20; + + // Perform multiple flips across different blocks + for i in 0..total_flips { + // Call flip_coin + let outcome: CoinSide = alice + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": "Heads"})) + .transact() + .await? + .json()?; + + match outcome { + CoinSide::Heads => heads_count += 1, + CoinSide::Tails => tails_count += 1, + } + + // Add some delay to potentially get different blocks + if i % 5 == 0 { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + } + + // Verify we got both outcomes (basic randomness check) + assert!(heads_count > 0, "Should have at least one heads"); + assert!(tails_count > 0, "Should have at least one tails"); + assert_eq!(heads_count + tails_count, total_flips); + + println!("✅ Randomness test passed: {} heads, {} tails", heads_count, tails_count); + + Ok(()) +} + +#[tokio::test] +async fn test_randomness_transparency() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + // Test randomness info + let info: (u64, u64, String) = contract + .call("get_randomness_info") + .transact() + .await? + .json()?; + + assert!(info.0 > 0); // Block height should be positive + assert!(info.1 > 0); // Timestamp should be positive + assert_eq!(info.2.len(), 8); // Seed preview should be 8 hex chars + + println!("✅ Randomness transparency test passed"); + + Ok(()) +} +``` + + + +### JavaScript Randomness Tests + +Create `tests/test_randomness.js`: + + + +```javascript +import { Worker } from 'near-workspaces'; +import test from 'ava'; + +test.beforeEach(async (t) => { + const worker = t.context.worker = await Worker.init(); + const root = worker.rootAccount; + const contract = await root.createSubAccount('contract'); + + await contract.deploy('./build/contract.wasm'); + + t.context.accounts = { root, contract }; +}); + +test.afterEach.always(async (t) => { + await t.context.worker.tearDown(); +}); + +test('randomness produces both heads and tails', async (t) => { + const { root, contract } = t.context.accounts; + + let headsCount = 0; + let tailsCount = 0; + const totalFlips = 20; + + for (let i = 0; i < totalFlips; i++) { + const outcome = await root.call(contract, 'flipCoin', { guess: 'heads' }); + + if (outcome === 'heads') { + headsCount++; + } else { + tailsCount++; + } + } + + // Basic randomness verification + t.true(headsCount > 0, 'Should have at least one heads'); + t.true(tailsCount > 0, 'Should have at least one tails'); + t.is(headsCount + tailsCount, totalFlips); + + console.log(`✅ Got ${headsCount} heads, ${tailsCount} tails`); +}); + +test('randomness transparency methods work', async (t) => { + const { contract } = t.context.accounts; + + const randomSeed = await contract.view('getCurrentRandomSeed'); + const randomnessInfo = await contract.view('getRandomnessInfo'); + + t.true(Array.isArray(randomSeed)); + t.is(randomSeed.length, 32); + + t.true(typeof randomnessInfo.blockHeight === 'string'); + t.true(typeof randomnessInfo.timestamp === 'string'); + t.is(randomnessInfo.seedPreview.length, 8); +}); +``` + + + +## Building and Testing + +Let's build and test our randomness implementation: + +```bash +# Build the contract +cargo near build # or npm run build + +# Run randomness tests +cargo test test_randomness # or npm test test_randomness +``` + +## Deploying with Randomness + +Deploy your updated contract: + +```bash +# Deploy updated contract +near contract deploy coinflip.testnet use-file ./target/wasm32-unknown-unknown/release/coin_flip_contract.wasm without-init-call network-config testnet sign-with-keychain send +``` + +### Testing on Testnet + +```bash +# Test a real coin flip +near contract call-function as-transaction coinflip.testnet flip_coin json-args '{"guess": "Heads"}' prepaid-gas 100.0 Tgas attached-deposit 0 NEAR sign-as your-account.testnet network-config testnet send + +# Check randomness info +near contract call-function as-read-only coinflip.testnet get_randomness_info json-args {} network-config testnet now +``` + +## Key Takeaways + +🎯 **What We've Implemented:** +- ✅ True cryptographic randomness using NEAR's VRF +- ✅ Multiple randomness strategies (basic, enhanced, player-specific) +- ✅ Transparency methods for trust and debugging +- ✅ Comprehensive tests for randomness quality +- ✅ Fair 50/50 distribution for heads/tails + +🔒 **Security Benefits:** +- **Unpredictable**: Players cannot predict outcomes +- **Fair**: Each flip has exactly 50% chance for each side +- **Transparent**: Randomness source is verifiable +- **Consensus-Safe**: All validators agree on the same result + +## Next Steps + +With randomness implemented, our coin flip game is now truly functional! In the next section, we'll create comprehensive tests to ensure our contract works perfectly under all conditions and explore testing strategies for randomness-based applications. + +:::tip Pro Tip +The `generate_player_specific_flip` method is particularly useful when you need different random outcomes for different users within the same block. This technique can be applied to many randomness use cases beyond coin flips! +::: \ No newline at end of file diff --git a/docs/tutorials/coin-flip/5-testing.md b/docs/tutorials/coin-flip/5-testing.md new file mode 100644 index 00000000000..5ab7deab098 --- /dev/null +++ b/docs/tutorials/coin-flip/5-testing.md @@ -0,0 +1,946 @@ +--- +id: testing +title: Testing Your Randomness-Powered Contract +sidebar_label: Comprehensive Testing +description: "Learn how to thoroughly test smart contracts with randomness, including statistical validation and edge case handling." +--- + +Testing contracts with randomness presents unique challenges. Unlike deterministic functions, we can't predict exact outputs, so we need different testing strategies. This section covers comprehensive testing approaches for randomness-powered applications. + +## Testing Strategy Overview + +When testing randomness, we focus on: + +1. **Statistical Properties**: Distribution, fairness, and quality +2. **Edge Cases**: Invalid inputs, boundary conditions +3. **State Management**: Points, statistics, and persistence +4. **Security**: Input validation and access control +5. **Performance**: Gas usage and efficiency + +## Setting Up the Test Environment + +Let's create a comprehensive test suite that covers all aspects of our contract: + +### Rust Test Setup + +First, update your `Cargo.toml` to include testing dependencies: + + + +```toml +[dev-dependencies] +near-workspaces = "0.10.0" +tokio = { version = "1.0", features = ["full"] } +serde_json = "1.0" +statistical-tests = "0.1" # For statistical analysis +``` + + + +### JavaScript Test Setup + +Update your `package.json` test dependencies: + + + +```json +{ + "devDependencies": { + "near-workspaces": "^3.4.0", + "ava": "^5.3.0", + "simple-statistics": "^7.8.0" + } +} +``` + + + +## Statistical Testing of Randomness + +The most important aspect is ensuring our randomness is actually random and fair: + +### Rust Statistical Tests + +Create `tests/test_statistical.rs`: + + + +```rust +use coin_flip_contract::{CoinFlipContract, CoinSide}; +use near_workspaces::{types::NearToken, Account, Contract}; +use serde_json::json; +use std::collections::HashMap; + +#[tokio::test] +async fn test_fair_distribution() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let alice = worker.dev_create_account().await?; + + let mut results = HashMap::new(); + let total_flips = 100; + + // Perform many coin flips + for i in 0..total_flips { + let outcome: CoinSide = alice + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": "Heads"})) + .transact() + .await? + .json()?; + + *results.entry(format!("{:?}", outcome)).or_insert(0) += 1; + + // Small delay to encourage different blocks + if i % 10 == 0 { + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + } + + let heads_count = results.get("Heads").unwrap_or(&0); + let tails_count = results.get("Tails").unwrap_or(&0); + + // Statistical validation + assert!(*heads_count > 0, "Should have some heads"); + assert!(*tails_count > 0, "Should have some tails"); + + // Chi-square test for fairness (simplified) + let expected = total_flips as f64 / 2.0; + let chi_square = ((*heads_count as f64 - expected).powi(2) / expected) + + ((*tails_count as f64 - expected).powi(2) / expected); + + // For 1 degree of freedom, critical value at p=0.05 is 3.84 + assert!(chi_square < 10.0, "Distribution seems unfair (chi-square: {})", chi_square); + + println!("✅ Distribution test: {} heads, {} tails (χ² = {:.2})", + heads_count, tails_count, chi_square); + + Ok(()) +} + +#[tokio::test] +async fn test_player_specific_randomness() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + // Create multiple players + let alice = worker.dev_create_account().await?; + let bob = worker.dev_create_account().await?; + let charlie = worker.dev_create_account().await?; + + let players = vec![&alice, &bob, &charlie]; + let mut all_results = Vec::new(); + + // Each player flips in the same block (simulate simultaneous games) + for player in &players { + let outcome: CoinSide = player + .call(&contract.id(), "simulate_flip") + .args_json(json!({"player": player.id()})) + .transact() + .await? + .json()?; + + all_results.push(outcome); + } + + // Check that we got some variety (not all same outcome) + let heads_count = all_results.iter().filter(|&x| matches!(x, CoinSide::Heads)).count(); + let tails_count = all_results.len() - heads_count; + + // With 3 players, we expect some variety (though it's possible to get all same by chance) + println!("✅ Player-specific randomness: {} heads, {} tails among {} players", + heads_count, tails_count, players.len()); + + Ok(()) +} + +#[tokio::test] +async fn test_randomness_seed_changes() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let mut previous_seeds = std::collections::HashSet::new(); + + // Check that random seeds change across different calls + for i in 0..10 { + let seed: Vec = contract + .call("get_current_random_seed") + .transact() + .await? + .json()?; + + // Convert to string for easy comparison + let seed_str = format!("{:?}", seed); + previous_seeds.insert(seed_str); + + // Small delay to potentially get different blocks + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + // We should have seen at least a few different seeds + assert!(previous_seeds.len() > 1, + "Random seeds should vary across different blocks. Got {} unique seeds", + previous_seeds.len()); + + println!("✅ Seed variation test: {} unique seeds out of 10 calls", previous_seeds.len()); + + Ok(()) +} +``` + + + +### JavaScript Statistical Tests + +Create `tests/test_statistical.js`: + + + +```javascript +import { Worker } from 'near-workspaces'; +import test from 'ava'; +import ss from 'simple-statistics'; + +test.beforeEach(async (t) => { + const worker = t.context.worker = await Worker.init(); + const root = worker.rootAccount; + const contract = await root.createSubAccount('contract'); + + await contract.deploy('./build/contract.wasm'); + + const alice = await root.createSubAccount('alice'); + const bob = await root.createSubAccount('bob'); + + t.context.accounts = { root, contract, alice, bob }; +}); + +test.afterEach.always(async (t) => { + await t.context.worker.tearDown(); +}); + +test('fair distribution over many flips', async (t) => { + const { alice, contract } = t.context.accounts; + + const results = []; + const totalFlips = 100; + + for (let i = 0; i < totalFlips; i++) { + const outcome = await alice.call(contract, 'flipCoin', { guess: 'heads' }); + results.push(outcome === 'heads' ? 1 : 0); // 1 for heads, 0 for tails + + // Small delay to encourage different blocks + if (i % 10 === 0 && i > 0) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + const headsCount = results.reduce((sum, val) => sum + val, 0); + const tailsCount = totalFlips - headsCount; + + // Basic checks + t.true(headsCount > 0, 'Should have some heads'); + t.true(tailsCount > 0, 'Should have some tails'); + + // Chi-square test for fairness + const expected = totalFlips / 2; + const chiSquare = Math.pow(headsCount - expected, 2) / expected + + Math.pow(tailsCount - expected, 2) / expected; + + // For 1 degree of freedom, critical value at p=0.05 is 3.84 + t.true(chiSquare < 10, `Distribution seems unfair (χ² = ${chiSquare})`); + + console.log(`✅ Distribution: ${headsCount} heads, ${tailsCount} tails (χ² = ${chiSquare.toFixed(2)})`); +}); + +test('different players get different results in same block', async (t) => { + const { alice, bob, contract } = t.context.accounts; + + // Simulate multiple flips without state changes + const aliceResult = await contract.view('simulateFlip', { player: alice.accountId }); + const bobResult = await contract.view('simulateFlip', { player: bob.accountId }); + + // While they might be the same by chance, test that the function works + t.true(['heads', 'tails'].includes(aliceResult)); + t.true(['heads', 'tails'].includes(bobResult)); + + console.log(`✅ Player-specific: Alice=${aliceResult}, Bob=${bobResult}`); +}); + +test('randomness quality metrics', async (t) => { + const { alice, contract } = t.context.accounts; + + const results = []; + const totalFlips = 50; + + // Collect results + for (let i = 0; i < totalFlips; i++) { + const outcome = await alice.call(contract, 'flipCoin', { guess: 'heads' }); + results.push(outcome === 'heads' ? 1 : 0); + } + + // Calculate statistics + const mean = ss.mean(results); + const variance = ss.variance(results); + const standardDeviation = ss.standardDeviation(results); + + // For fair coin: mean should be ~0.5, variance ~0.25 + t.true(Math.abs(mean - 0.5) < 0.2, `Mean too far from 0.5: ${mean}`); + t.true(variance > 0.1, `Variance too low: ${variance}`); // Should have some variance + + console.log(`✅ Quality metrics: mean=${mean.toFixed(3)}, var=${variance.toFixed(3)}, std=${standardDeviation.toFixed(3)}`); +}); +``` + + + +## Testing Game Logic and State Management + +Beyond randomness, we need to test the game mechanics: + +### Rust Game Logic Tests + +Create `tests/test_game_logic.rs`: + + + +```rust +use coin_flip_contract::{CoinFlipContract, CoinSide}; +use near_workspaces::{types::NearToken, Account, Contract}; +use serde_json::json; + +#[tokio::test] +async fn test_point_system() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let alice = worker.dev_create_account().await?; + + // Alice starts with 0 points + let initial_points: u32 = alice + .call(&contract.id(), "get_points") + .args_json(json!({"player": alice.id()})) + .transact() + .await? + .json()?; + assert_eq!(initial_points, 0); + + // Track Alice's expected points + let mut expected_points = 0u32; + + // Play multiple games and track points + for i in 0..20 { + let guess = if i % 2 == 0 { CoinSide::Heads } else { CoinSide::Tails }; + + let outcome: CoinSide = alice + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": guess})) + .transact() + .await? + .json()?; + + // Update expected points + if guess == outcome { + expected_points += 1; + } else { + expected_points = expected_points.saturating_sub(1); + } + + // Verify points match expectation + let actual_points: u32 = alice + .call(&contract.id(), "get_points") + .args_json(json!({"player": alice.id()})) + .transact() + .await? + .json()?; + + assert_eq!(actual_points, expected_points, + "Points mismatch at game {}: expected {}, got {}", + i + 1, expected_points, actual_points); + } + + println!("✅ Point system test passed: {} final points", expected_points); + + Ok(()) +} + +#[tokio::test] +async fn test_multiple_players() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + // Create multiple players + let alice = worker.dev_create_account().await?; + let bob = worker.dev_create_account().await?; + let charlie = worker.dev_create_account().await?; + + let players = vec![&alice, &bob, &charlie]; + + // Each player plays some games + for (i, player) in players.iter().enumerate() { + let games_to_play = 5 + i; // Different number of games per player + + for _ in 0..games_to_play { + let _outcome: CoinSide = player + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": "Heads"})) + .transact() + .await? + .json()?; + } + } + + // Verify contract statistics + let (total_games, total_players, active_players): (u64, u32, u32) = contract + .call("get_contract_stats") + .transact() + .await? + .json()?; + + assert_eq!(total_games, 5 + 6 + 7); // Sum of games played + assert_eq!(total_players, 3); // Three players registered + assert_eq!(active_players, 3); // All players are active + + println!("✅ Multiple players test: {} games, {} players", total_games, total_players); + + Ok(()) +} + +#[tokio::test] +async fn test_player_stats() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let alice = worker.dev_create_account().await?; + + // Check initial stats + let (points, has_played): (u32, bool) = alice + .call(&contract.id(), "get_player_stats") + .args_json(json!({"player": alice.id()})) + .transact() + .await? + .json()?; + + assert_eq!(points, 0); + assert!(!has_played); + + // Play one game + let _outcome: CoinSide = alice + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": "Heads"})) + .transact() + .await? + .json()?; + + // Check updated stats + let (new_points, now_has_played): (u32, bool) = alice + .call(&contract.id(), "get_player_stats") + .args_json(json!({"player": alice.id()})) + .transact() + .await? + .json()?; + + assert!(now_has_played); + // Points should be 0 or 1 depending on the outcome + assert!(new_points <= 1); + + println!("✅ Player stats test passed"); + + Ok(()) +} +``` + + + +### JavaScript Game Logic Tests + +Create `tests/test_game_logic.js`: + + + +```javascript +import { Worker } from 'near-workspaces'; +import test from 'ava'; + +test.beforeEach(async (t) => { + const worker = t.context.worker = await Worker.init(); + const root = worker.rootAccount; + const contract = await root.createSubAccount('contract'); + + await contract.deploy('./build/contract.wasm'); + + const alice = await root.createSubAccount('alice'); + const bob = await root.createSubAccount('bob'); + + t.context.accounts = { root, contract, alice, bob }; +}); + +test.afterEach.always(async (t) => { + await t.context.worker.tearDown(); +}); + +test('point system works correctly', async (t) => { + const { alice, contract } = t.context.accounts; + + // Initial points should be 0 + let points = await contract.view('getPoints', { player: alice.accountId }); + t.is(points, 0); + + let expectedPoints = 0; + + // Play several games and track points + for (let i = 0; i < 15; i++) { + const guess = i % 2 === 0 ? 'heads' : 'tails'; + const outcome = await alice.call(contract, 'flipCoin', { guess }); + + // Update expected points + if (guess === outcome) { + expectedPoints += 1; + } else { + expectedPoints = Math.max(0, expectedPoints - 1); + } + + // Verify actual points match expected + const actualPoints = await contract.view('getPoints', { player: alice.accountId }); + t.is(actualPoints, expectedPoints, `Points mismatch at game ${i + 1}`); + } + + console.log(`✅ Point system verified: ${expectedPoints} final points`); +}); + +test('multiple players have independent scores', async (t) => { + const { alice, bob, contract } = t.context.accounts; + + // Alice plays some games + await alice.call(contract, 'flipCoin', { guess: 'heads' }); + await alice.call(contract, 'flipCoin', { guess: 'heads' }); + + // Bob plays different games + await bob.call(contract, 'flipCoin', { guess: 'tails' }); + + // Check that they have independent scores + const alicePoints = await contract.view('getPoints', { player: alice.accountId }); + const bobPoints = await contract.view('getPoints', { player: bob.accountId }); + + // They should both have some score (0, 1, or 2) + t.true(alicePoints >= 0 && alicePoints <= 2); + t.true(bobPoints >= 0 && bobPoints <= 1); + + // Verify total games + const totalGames = await contract.view('getTotalGames'); + t.is(totalGames, 3); + + console.log(`✅ Independent scores: Alice=${alicePoints}, Bob=${bobPoints}`); +}); + +test('player statistics tracking', async (t) => { + const { alice, contract } = t.context.accounts; + + // Check initial stats + let stats = await contract.view('getPlayerStats', { player: alice.accountId }); + t.is(stats.points, 0); + t.is(stats.hasPlayed, false); + + // Play a game + await alice.call(contract, 'flipCoin', { guess: 'heads' }); + + // Check updated stats + stats = await contract.view('getPlayerStats', { player: alice.accountId }); + t.is(stats.hasPlayed, true); + t.true(stats.points >= 0 && stats.points <= 1); + + console.log(`✅ Player stats: points=${stats.points}, hasPlayed=${stats.hasPlayed}`); +}); +``` + + + +## Testing Input Validation and Security + +Security testing ensures our contract handles invalid inputs gracefully: + +### Rust Security Tests + +Create `tests/test_security.rs`: + + + +```rust +use coin_flip_contract::{CoinFlipContract, CoinSide}; +use near_workspaces::{types::NearToken, Account, Contract}; +use serde_json::json; + +#[tokio::test] +async fn test_valid_inputs() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let alice = worker.dev_create_account().await?; + + // Test both valid inputs + let outcome1: CoinSide = alice + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": "Heads"})) + .transact() + .await? + .json()?; + + let outcome2: CoinSide = alice + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": "Tails"})) + .transact() + .await? + .json()?; + + // Both should work without errors + assert!(matches!(outcome1, CoinSide::Heads | CoinSide::Tails)); + assert!(matches!(outcome2, CoinSide::Heads | CoinSide::Tails)); + + println!("✅ Valid inputs test passed"); + + Ok(()) +} + +#[tokio::test] +async fn test_view_methods_dont_change_state() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let alice = worker.dev_create_account().await?; + + // Get initial stats + let initial_stats: (u64, u32, u32) = contract + .call("get_contract_stats") + .transact() + .await? + .json()?; + + // Call view methods multiple times + for _ in 0..5 { + let _points: u32 = alice + .call(&contract.id(), "get_points") + .args_json(json!({"player": alice.id()})) + .transact() + .await? + .json()?; + + let _seed: Vec = contract + .call("get_current_random_seed") + .transact() + .await? + .json()?; + + let _info: (u64, u64, String) = contract + .call("get_randomness_info") + .transact() + .await? + .json()?; + } + + // Stats should remain unchanged + let final_stats: (u64, u32, u32) = contract + .call("get_contract_stats") + .transact() + .await? + .json()?; + + assert_eq!(initial_stats, final_stats); + + println!("✅ View methods don't change state"); + + Ok(()) +} + +#[tokio::test] +async fn test_gas_usage() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let alice = worker.dev_create_account().await?; + + // Test gas usage for flip_coin + let result = alice + .call(&contract.id(), "flip_coin") + .args_json(json!({"guess": "Heads"})) + .gas(300_000_000_000_000) // 300 TGas + .transact() + .await?; + + let gas_used = result.total_gas_burnt; + + // Should use reasonable amount of gas (less than 10 TGas for simple operation) + assert!(gas_used < 10_000_000_000_000, "Gas usage too high: {}", gas_used); + + println!("✅ Gas usage test: {} gas used", gas_used); + + Ok(()) +} +``` + + + +### JavaScript Security Tests + +Create `tests/test_security.js`: + + + +```javascript +import { Worker } from 'near-workspaces'; +import test from 'ava'; + +test.beforeEach(async (t) => { + const worker = t.context.worker = await Worker.init(); + const root = worker.rootAccount; + const contract = await root.createSubAccount('contract'); + + await contract.deploy('./build/contract.wasm'); + + const alice = await root.createSubAccount('alice'); + + t.context.accounts = { root, contract, alice }; +}); + +test.afterEach.always(async (t) => { + await t.context.worker.tearDown(); +}); + +test('rejects invalid guess inputs', async (t) => { + const { alice, contract } = t.context.accounts; + + const invalidInputs = ['head', 'tail', 'HEADS', 'invalid', '', 123, null]; + + for (const invalidInput of invalidInputs) { + await t.throwsAsync( + async () => { + await alice.call(contract, 'flipCoin', { guess: invalidInput }); + }, + { instanceOf: Error }, + `Should reject invalid input: ${invalidInput}` + ); + } + + console.log('✅ All invalid inputs properly rejected'); +}); + +test('view methods are read-only', async (t) => { + const { alice, contract } = t.context.accounts; + + // Get initial state + const initialGames = await contract.view('getTotalGames'); + const initialPlayers = await contract.view('getTotalPlayers'); + + // Call view methods multiple times + for (let i = 0; i < 5; i++) { + await contract.view('getPoints', { player: alice.accountId }); + await contract.view('getCurrentRandomSeed'); + await contract.view('getRandomnessInfo'); + await contract.view('simulateFlip', { player: alice.accountId }); + } + + // State should be unchanged + const finalGames = await contract.view('getTotalGames'); + const finalPlayers = await contract.view('getTotalPlayers'); + + t.is(initialGames, finalGames); + t.is(initialPlayers, finalPlayers); + + console.log('✅ View methods confirmed read-only'); +}); + +test('consistent randomness within same call', async (t) => { + const { contract } = t.context.accounts; + + // Multiple calls to get seed should return same value within same transaction + const info1 = await contract.view('getRandomnessInfo'); + const info2 = await contract.view('getRandomnessInfo'); + + // Within same block, should get same randomness info + t.is(info1.seedPreview, info2.seedPreview); + t.is(info1.blockHeight, info2.blockHeight); + + console.log('✅ Randomness consistency verified'); +}); +``` + + + +## Performance and Load Testing + +Test how your contract performs under various conditions: + +### Rust Performance Tests + + + +```rust +use coin_flip_contract::{CoinFlipContract, CoinSide}; +use near_workspaces::{types::NearToken, Account, Contract}; +use serde_json::json; +use std::time::Instant; + +#[tokio::test] +async fn test_concurrent_players() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + // Create multiple accounts + let mut players = Vec::new(); + for i in 0..10 { + let player = worker.dev_create_account().await?; + players.push(player); + } + + let start_time = Instant::now(); + + // Have all players play simultaneously + let mut handles = Vec::new(); + for player in players { + let contract_clone = contract.clone(); + let handle = tokio::spawn(async move { + let _outcome: CoinSide = player + .call(&contract_clone.id(), "flip_coin") + .args_json(json!({"guess": "Heads"})) + .transact() + .await + .unwrap() + .json() + .unwrap(); + }); + handles.push(handle); + } + + // Wait for all to complete + for handle in handles { + handle.await?; + } + + let duration = start_time.elapsed(); + + // Verify all games were recorded + let (total_games, total_players, _): (u64, u32, u32) = contract + .call("get_contract_stats") + .transact() + .await? + .json()?; + + assert_eq!(total_games, 10); + assert_eq!(total_players, 10); + + println!("✅ Concurrent test: 10 players completed in {:?}", duration); + + Ok(()) +} +``` + + + +## Running All Tests + +Create comprehensive test scripts: + +### Rust Test Script + +Create `run_tests.sh`: + + + +```bash +#!/bin/bash +set -e + +echo "🧪 Running Coin Flip Contract Tests" +echo "==================================" + +# Build the contract first +echo "📦 Building contract..." +cargo near build + +# Run unit tests +echo "🔬 Running unit tests..." +cargo test --lib + +# Run integration tests +echo "🔗 Running integration tests..." +cargo test --test test_basic +cargo test --test test_game_logic +cargo test --test test_statistical +cargo test --test test_security +cargo test --test test_performance + +echo "✅ All tests passed!" + +# Optional: Run with coverage +if command -v cargo-tarpaulin &> /dev/null; then + echo "📊 Generating coverage report..." + cargo tarpaulin --out Html + echo "Coverage report generated: tarpaulin-report.html" +fi +``` + + + +### JavaScript Test Script + +Update your `package.json`: + + + +```json +{ + "scripts": { + "test": "npm run test:unit && npm run test:integration", + "test:unit": "ava tests/test_basic.js", + "test:integration": "ava tests/test_*.js", + "test:statistical": "ava tests/test_statistical.js", + "test:security": "ava tests/test_security.js", + "coverage": "nyc ava" + } +} +``` + + + +## Test Results Analysis + +When you run these tests, you should see output like: + +```bash +✅ Distribution test: 52 heads, 48 tails (χ² = 0.16) +✅ Player-specific randomness: 2 heads, 1 tails among 3 players +✅ Seed variation test: 7 unique seeds out of 10 calls +✅ Point system test passed: 3 final points +✅ Multiple players test: 18 games, 3 players +✅ Valid inputs test passed +✅ Gas usage test: 2847593000000 gas used +``` + +## Next Steps + +With comprehensive testing in place, our coin flip contract is robust and ready for production. In the final section, we'll explore advanced randomness patterns and techniques that you can apply to more complex applications. + +:::tip Testing Best Practices +- Always test statistical properties of randomness, not just functionality +- Use multiple players to verify independence +- Test edge cases and invalid inputs +- Monitor gas usage to ensure efficiency +- Run tests multiple times to catch flaky behavior +::: + +:::info Test Coverage +Aim for high test coverage but focus on critical paths: +- Randomness quality and fairness +- State management and persistence +- Input validation and security +- Gas efficiency and performance +::: \ No newline at end of file diff --git a/docs/tutorials/coin-flip/6-advanced-patterns.md b/docs/tutorials/coin-flip/6-advanced-patterns.md new file mode 100644 index 00000000000..eb7324c0221 --- /dev/null +++ b/docs/tutorials/coin-flip/6-advanced-patterns.md @@ -0,0 +1,735 @@ +--- +id: advanced-patterns +title: Advanced Randomness Patterns +sidebar_label: Advanced Techniques +description: "Explore advanced randomness patterns and techniques for complex applications beyond simple coin flips." +--- + +Now that you've mastered basic randomness with our coin flip game, let's explore advanced patterns and techniques that enable more sophisticated randomness-powered applications. These patterns will help you build complex games, fair lotteries, and advanced DeFi protocols. + +## Pattern 1: Weighted Random Selection + +Sometimes you need outcomes with different probabilities - like rare items in games or tiered rewards in DeFi. + +### Basic Weighted Selection + + + +```rust +use near_sdk::{env, near_bindgen}; +use std::collections::HashMap; + +#[near_bindgen] +impl CoinFlipContract { + /// Select from weighted options (e.g., loot drops, reward tiers) + pub fn weighted_selection(&self, weights: Vec<(String, u32)>) -> String { + if weights.is_empty() { + env::panic_str("No options provided"); + } + + // Calculate total weight + let total_weight: u32 = weights.iter().map(|(_, weight)| weight).sum(); + if total_weight == 0 { + env::panic_str("Total weight cannot be zero"); + } + + // Generate random value in range [0, total_weight) + let random_seed = env::random_seed(); + let random_value = self.random_u32_range(0, total_weight); + + // Find the selected item + let mut cumulative_weight = 0u32; + for (item, weight) in weights { + cumulative_weight += weight; + if random_value < cumulative_weight { + return item; + } + } + + // Fallback (should never reach here) + weights.last().unwrap().0.clone() + } + + /// Helper: Generate random u32 in range [min, max) + fn random_u32_range(&self, min: u32, max: u32) -> u32 { + if min >= max { + return min; + } + + let random_seed = env::random_seed(); + let random_bytes = [random_seed[0], random_seed[1], random_seed[2], random_seed[3]]; + let random_u32 = u32::from_le_bytes(random_bytes); + + min + (random_u32 % (max - min)) + } +} +``` + + + +### JavaScript Weighted Selection + + + +```javascript +export class AdvancedRandomness extends CoinFlipContract { + @call({}) + weightedSelection({ weights }) { + if (!Array.isArray(weights) || weights.length === 0) { + throw new Error('No options provided'); + } + + // Calculate total weight + const totalWeight = weights.reduce((sum, [item, weight]) => sum + weight, 0); + if (totalWeight === 0) { + throw new Error('Total weight cannot be zero'); + } + + // Generate random value + const randomValue = this.randomU32Range(0, totalWeight); + + // Find selected item + let cumulativeWeight = 0; + for (const [item, weight] of weights) { + cumulativeWeight += weight; + if (randomValue < cumulativeWeight) { + return item; + } + } + + // Fallback + return weights[weights.length - 1][0]; + } + + randomU32Range(min, max) { + if (min >= max) return min; + + const randomSeed = near.randomSeed(); + const randomU32 = randomSeed[0] + (randomSeed[1] << 8) + + (randomSeed[2] << 16) + (randomSeed[3] << 24); + + return min + (randomU32 % (max - min)); + } +} +``` + + + +### Example Usage: Loot Box System + + + +```rust +#[derive(BorshSerialize, BorshDeserialize)] +pub struct LootBox { + pub common_items: Vec, + pub rare_items: Vec, + pub legendary_items: Vec, +} + +#[near_bindgen] +impl CoinFlipContract { + /// Open a loot box with tiered rewards + pub fn open_loot_box(&self) -> (String, String) { + // Define rarity weights: 70% common, 25% rare, 5% legendary + let rarity_weights = vec![ + ("common".to_string(), 70), + ("rare".to_string(), 25), + ("legendary".to_string(), 5), + ]; + + let rarity = self.weighted_selection(rarity_weights); + + // Select specific item based on rarity + let item = match rarity.as_str() { + "common" => self.select_from_pool(&["Sword", "Shield", "Potion"]), + "rare" => self.select_from_pool(&["Magic Sword", "Enchanted Shield", "Elixir"]), + "legendary" => self.select_from_pool(&["Dragon Sword", "Divine Shield", "Phoenix Feather"]), + _ => "Unknown Item".to_string(), + }; + + (rarity, item) + } + + fn select_from_pool(&self, items: &[&str]) -> String { + if items.is_empty() { + return "Empty".to_string(); + } + + let index = self.random_u32_range(0, items.len() as u32) as usize; + items[index].to_string() + } +} +``` + + + +## Pattern 2: Bias-Free Range Selection + +For critical applications like lotteries, you need perfectly unbiased random selection: + +### Rejection Sampling for Unbiased Results + + + +```rust +#[near_bindgen] +impl CoinFlipContract { + /// Generate unbiased random number in range [min, max) using rejection sampling + pub fn unbiased_range(&self, min: u32, max: u32) -> u32 { + if min >= max { + return min; + } + + let range = max - min; + let random_seed = env::random_seed(); + + // Calculate the maximum valid value to avoid bias + let max_valid = u32::MAX - (u32::MAX % range); + + // Try up to 8 times with different bytes from the seed + for i in (0..29).step_by(4) { + let candidate = u32::from_le_bytes([ + random_seed[i], + random_seed[i + 1], + random_seed[i + 2], + random_seed[i + 3] + ]); + + // Accept only if within valid range + if candidate < max_valid { + return min + (candidate % range); + } + } + + // Fallback: use simple modulo (minimal bias for 32-byte seed) + let fallback = u32::from_le_bytes([ + random_seed[0], random_seed[1], random_seed[2], random_seed[3] + ]); + min + (fallback % range) + } + + /// Fair lottery: select N winners from M participants + pub fn lottery_winners(&self, participants: Vec, winner_count: u32) -> Vec { + if participants.is_empty() || winner_count == 0 { + return vec![]; + } + + let winner_count = std::cmp::min(winner_count, participants.len() as u32); + let mut winners = Vec::new(); + let mut remaining = participants; + + // Fisher-Yates shuffle to select winners + for _ in 0..winner_count { + if remaining.is_empty() { + break; + } + + let index = self.unbiased_range(0, remaining.len() as u32) as usize; + winners.push(remaining.remove(index)); + } + + winners + } +} +``` + + + +### JavaScript Unbiased Implementation + + + +```javascript +export class UnbiasedRandom extends CoinFlipContract { + @call({}) + unbiasedRange({ min, max }) { + if (min >= max) return min; + + const range = max - min; + const randomSeed = near.randomSeed(); + + // Calculate maximum valid value + const maxValid = Math.floor(0xFFFFFFFF / range) * range; + + // Try multiple candidates + for (let i = 0; i < 7; i += 4) { + const candidate = randomSeed[i] + (randomSeed[i + 1] << 8) + + (randomSeed[i + 2] << 16) + (randomSeed[i + 3] << 24); + + if (candidate < maxValid) { + return min + (candidate % range); + } + } + + // Fallback + const fallback = randomSeed[0] + (randomSeed[1] << 8) + + (randomSeed[2] << 16) + (randomSeed[3] << 24); + return min + (fallback % range); + } + + @call({}) + lotteryWinners({ participants, winnerCount }) { + if (!participants || participants.length === 0 || winnerCount <= 0) { + return []; + } + + const actualWinnerCount = Math.min(winnerCount, participants.length); + const winners = []; + const remaining = [...participants]; // Copy array + + for (let i = 0; i < actualWinnerCount; i++) { + if (remaining.length === 0) break; + + const index = this.unbiasedRange({ min: 0, max: remaining.length }); + winners.push(remaining.splice(index, 1)[0]); + } + + return winners; + } +} +``` + + + +## Pattern 3: Multi-Round Randomness + +For complex games requiring multiple random events: + +### Sequential Randomness with Entropy Mixing + + + +```rust +use near_sdk::collections::UnorderedMap; + +#[derive(BorshSerialize, BorshDeserialize)] +pub struct GameRound { + pub round_id: u64, + pub player: AccountId, + pub dice_rolls: Vec, + pub cards_drawn: Vec, + pub bonus_multiplier: f64, +} + +#[near_bindgen] +impl CoinFlipContract { + /// Complex game with multiple random elements + pub fn play_complex_game(&mut self, player: AccountId) -> GameRound { + let round_id = self.total_games + 1; + + // Generate base entropy + let base_seed = env::random_seed(); + + // Roll 3 dice with different entropy sources + let dice_rolls = vec![ + self.entropy_dice(&base_seed, &player, 0), + self.entropy_dice(&base_seed, &player, 1), + self.entropy_dice(&base_seed, &player, 2), + ]; + + // Draw 5 cards (1-52) + let cards_drawn = (0..5) + .map(|i| self.entropy_card(&base_seed, &player, i)) + .collect(); + + // Calculate bonus multiplier based on results + let dice_sum: u32 = dice_rolls.iter().map(|&x| x as u32).sum(); + let bonus_multiplier = match dice_sum { + 3..=6 => 0.5, // Low roll + 7..=14 => 1.0, // Normal roll + 15..=18 => 2.0, // High roll + _ => 1.0, + }; + + GameRound { + round_id, + player: player.clone(), + dice_rolls, + cards_drawn, + bonus_multiplier, + } + } + + /// Generate dice roll (1-6) with custom entropy + fn entropy_dice(&self, base_seed: &[u8; 32], player: &AccountId, variant: u8) -> u8 { + let mut entropy_data = Vec::new(); + entropy_data.extend_from_slice(base_seed); + entropy_data.extend_from_slice(player.as_str().as_bytes()); + entropy_data.push(variant); + entropy_data.extend_from_slice(&env::block_height().to_le_bytes()); + + let hash = env::sha256(&entropy_data); + (hash[0] % 6) + 1 + } + + /// Generate card (1-52) with custom entropy + fn entropy_card(&self, base_seed: &[u8; 32], player: &AccountId, variant: u8) -> u8 { + let mut entropy_data = Vec::new(); + entropy_data.extend_from_slice(base_seed); + entropy_data.extend_from_slice(player.as_str().as_bytes()); + entropy_data.push(100 + variant); // Different salt than dice + entropy_data.extend_from_slice(&env::block_timestamp().to_le_bytes()); + + let hash = env::sha256(&entropy_data); + (hash[0] % 52) + 1 + } +} +``` + + + +## Pattern 4: Commit-Reveal for High-Stakes Applications + +For maximum security in high-value scenarios: + +### Commit-Reveal Scheme + + + +```rust +use near_sdk::collections::LookupMap; + +#[derive(BorshSerialize, BorshDeserialize)] +pub struct Commitment { + pub player: AccountId, + pub commitment_hash: Vec, + pub timestamp: u64, + pub stake: u128, +} + +#[derive(BorshSerialize, BorshDeserialize)] +pub struct Reveal { + pub nonce: String, + pub guess: CoinSide, +} + +#[near_bindgen] +impl CoinFlipContract { + /// Phase 1: Player commits to their guess without revealing it + #[payable] + pub fn commit_guess(&mut self, commitment_hash: Vec) { + let player = env::predecessor_account_id(); + let stake = env::attached_deposit(); + + assert!(stake >= 1_000_000_000_000_000_000_000_000, "Minimum stake: 1 NEAR"); + assert!(commitment_hash.len() == 32, "Invalid commitment hash length"); + + let commitment = Commitment { + player: player.clone(), + commitment_hash, + timestamp: env::block_timestamp(), + stake, + }; + + self.commitments.insert(&player, &commitment); + + env::log_str(&format!("Commitment recorded for {}", player)); + } + + /// Phase 2: Player reveals their guess and nonce + pub fn reveal_and_play(&mut self, nonce: String, guess: CoinSide) -> CoinSide { + let player = env::predecessor_account_id(); + + // Get and remove commitment + let commitment = self.commitments.get(&player) + .expect("No commitment found"); + self.commitments.remove(&player); + + // Verify commitment + let reveal_data = format!("{}{:?}", nonce, guess); + let reveal_hash = env::sha256(reveal_data.as_bytes()); + + assert_eq!(reveal_hash, commitment.commitment_hash, "Invalid reveal"); + + // Check timing (e.g., reveal must be within 10 minutes) + let elapsed = env::block_timestamp() - commitment.timestamp; + assert!(elapsed <= 600_000_000_000, "Reveal window expired"); // 10 minutes in nanoseconds + + // Use both NEAR's randomness AND commitment timing for extra entropy + let game_outcome = self.commit_reveal_flip(&commitment, &nonce); + + // Handle payout + if guess == game_outcome { + // Player wins: return stake + bonus + Promise::new(player).transfer(commitment.stake * 2); + } + // If player loses, stake stays in contract + + game_outcome + } + + /// Generate outcome using commitment entropy + NEAR randomness + fn commit_reveal_flip(&self, commitment: &Commitment, nonce: &str) -> CoinSide { + let mut entropy_sources = Vec::new(); + + // Mix multiple entropy sources + entropy_sources.extend_from_slice(&env::random_seed()); + entropy_sources.extend_from_slice(&commitment.commitment_hash); + entropy_sources.extend_from_slice(nonce.as_bytes()); + entropy_sources.extend_from_slice(&commitment.timestamp.to_le_bytes()); + entropy_sources.extend_from_slice(&env::block_height().to_le_bytes()); + + let final_hash = env::sha256(&entropy_sources); + + if final_hash[0] % 2 == 0 { + CoinSide::Heads + } else { + CoinSide::Tails + } + } +} + +// Add to contract struct +#[near_bindgen] +#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +pub struct CoinFlipContract { + // ... existing fields + commitments: LookupMap, +} +``` + + + +## Pattern 5: Randomness Quality Monitoring + +For production applications, monitor randomness quality: + +### Statistical Health Monitoring + + + +```rust +#[derive(BorshSerialize, BorshDeserialize, Default)] +pub struct RandomnessHealth { + pub total_samples: u64, + pub byte_distribution: [u32; 256], + pub last_update: u64, + pub health_score: f64, +} + +#[near_bindgen] +impl CoinFlipContract { + /// Record randomness usage for quality monitoring + pub fn record_randomness(&mut self, sample: u8) { + self.randomness_health.total_samples += 1; + self.randomness_health.byte_distribution[sample as usize] += 1; + self.randomness_health.last_update = env::block_timestamp(); + + // Update health score every 1000 samples + if self.randomness_health.total_samples % 1000 == 0 { + self.randomness_health.health_score = self.calculate_health_score(); + } + } + + /// Calculate statistical health score (0.0 = poor, 1.0 = perfect) + fn calculate_health_score(&self) -> f64 { + if self.randomness_health.total_samples < 1000 { + return 1.0; // Insufficient data + } + + let expected_per_bucket = self.randomness_health.total_samples as f64 / 256.0; + let mut chi_square = 0.0; + + for &observed in &self.randomness_health.byte_distribution { + let diff = observed as f64 - expected_per_bucket; + chi_square += (diff * diff) / expected_per_bucket; + } + + // Convert chi-square to health score (simplified) + let normalized_chi_square = chi_square / self.randomness_health.total_samples as f64; + 1.0 / (1.0 + normalized_chi_square) + } + + /// Get randomness quality report + pub fn get_randomness_health(&self) -> RandomnessHealth { + self.randomness_health.clone() + } + + /// Alert if randomness quality is poor + pub fn check_randomness_alert(&self) -> Option { + if self.randomness_health.total_samples < 1000 { + return None; + } + + if self.randomness_health.health_score < 0.8 { + Some(format!( + "⚠️ Randomness quality degraded: {:.2} (samples: {})", + self.randomness_health.health_score, + self.randomness_health.total_samples + )) + } else { + None + } + } +} +``` + + + +## Real-World Application Examples + +### NFT Mystery Box + + + +```rust +#[derive(BorshSerialize, BorshDeserialize)] +pub struct NFTMetadata { + pub rarity: String, + pub attribute1: String, + pub attribute2: String, + pub power_level: u32, +} + +#[near_bindgen] +impl CoinFlipContract { + /// Mint NFT with random attributes + #[payable] + pub fn mint_random_nft(&mut self) -> NFTMetadata { + let player = env::predecessor_account_id(); + let payment = env::attached_deposit(); + + assert!(payment >= 1_000_000_000_000_000_000_000_000, "Insufficient payment"); // 1 NEAR + + // Determine rarity (5% legendary, 15% rare, 80% common) + let rarity_weights = vec![ + ("legendary".to_string(), 5), + ("rare".to_string(), 15), + ("common".to_string(), 80), + ]; + let rarity = self.weighted_selection(rarity_weights); + + // Generate random attributes based on rarity + let (attribute1, attribute2) = match rarity.as_str() { + "legendary" => ( + self.select_from_pool(&["Dragon", "Phoenix", "Cosmic"]), + self.select_from_pool(&["Fire", "Lightning", "Void"]), + ), + "rare" => ( + self.select_from_pool(&["Knight", "Mage", "Archer"]), + self.select_from_pool(&["Ice", "Earth", "Wind"]), + ), + _ => ( + self.select_from_pool(&["Warrior", "Scout", "Peasant"]), + self.select_from_pool(&["Normal", "Basic", "Simple"]), + ), + }; + + // Random power level based on rarity + let power_level = match rarity.as_str() { + "legendary" => self.random_u32_range(80, 100), + "rare" => self.random_u32_range(50, 80), + _ => self.random_u32_range(10, 50), + }; + + NFTMetadata { + rarity, + attribute1, + attribute2, + power_level, + } + } +} +``` + + + +## Testing Advanced Patterns + +Create comprehensive tests for advanced patterns: + + + +```rust +#[tokio::test] +async fn test_weighted_selection() -> Result<(), Box> { + let worker = near_workspaces::sandbox().await?; + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = worker.dev_deploy(&contract_wasm).await?; + + let weights = vec![ + ("common".to_string(), 70), + ("rare".to_string(), 25), + ("legendary".to_string(), 5), + ]; + + let mut results = std::collections::HashMap::new(); + + // Run many selections + for _ in 0..1000 { + let result: String = contract + .call("weighted_selection") + .args_json(json!({"weights": weights})) + .transact() + .await? + .json()?; + + *results.entry(result).or_insert(0) += 1; + } + + // Verify distribution roughly matches weights + let common_count = results.get("common").unwrap_or(&0); + let rare_count = results.get("rare").unwrap_or(&0); + let legendary_count = results.get("legendary").unwrap_or(&0); + + // Should be roughly 70%, 25%, 5% (with some variance) + assert!(*common_count > 600 && *common_count < 800); + assert!(*rare_count > 200 && *rare_count < 350); + assert!(*legendary_count > 20 && *legendary_count < 80); + + println!("✅ Weighted selection: Common={}, Rare={}, Legendary={}", + common_count, rare_count, legendary_count); + + Ok(()) +} +``` + + + +## Key Takeaways + +🎯 **Advanced Patterns Enable:** +- **Weighted Selection**: Fair probability distributions for games and rewards +- **Unbiased Ranges**: Perfect fairness for critical applications +- **Multi-Round Games**: Complex interactions with multiple random elements +- **Commit-Reveal**: Maximum security for high-stakes scenarios +- **Quality Monitoring**: Production-ready randomness validation + +🔒 **Security Considerations:** +- Always validate inputs and handle edge cases +- Use multiple entropy sources for high-security applications +- Monitor randomness quality in production +- Consider economic incentives and potential attacks + +🚀 **Real-World Applications:** +- **Gaming**: Complex RPGs, card games, battle systems +- **DeFi**: Fair lotteries, random rewards, yield farming bonuses +- **NFTs**: Procedural generation, mystery boxes, trait assignment +- **DAOs**: Random sampling, fair governance mechanisms + +## Next Steps + +You now have a comprehensive toolkit for implementing randomness in NEAR applications! Whether you're building simple games or complex DeFi protocols, these patterns provide the foundation for fair, secure, and engaging randomness-powered features. + +Consider exploring: +- Cross-contract randomness sharing +- Randomness oracles for external data +- Advanced cryptographic techniques like VDFs +- Integration with other NEAR Protocol features + +:::tip Production Checklist +Before deploying randomness-critical contracts: +- ✅ Implement comprehensive testing +- ✅ Add quality monitoring +- ✅ Plan for edge cases and failures +- ✅ Consider economic implications +- ✅ Audit for security vulnerabilities +::: + +:::info Community Resources +Join the NEAR developer community to share your randomness-powered applications: +- [NEAR Discord](https://near.chat) +- [Developer Telegram](https://t.me/neardev) +- [GitHub Discussions](https://github.com/near/nearcore/discussions) +::: \ No newline at end of file diff --git a/docs/tutorials/donation/0-introduction.md b/docs/tutorials/donation/0-introduction.md new file mode 100644 index 00000000000..be8f2f7dc2a --- /dev/null +++ b/docs/tutorials/donation/0-introduction.md @@ -0,0 +1,54 @@ +--- +id: introduction +title: Building a Donation Smart Contract +sidebar_label: Introduction +description: "This tutorial will guide you through building a donation smart contract that handles NEAR tokens, tracks donations, and manages beneficiaries." +--- + +import {Github} from '@site/src/components/codetabs'; + +Learn how to build a complete donation system on NEAR that accepts, tracks, and manages token transfers. This tutorial demonstrates the fundamental concepts of handling tokens in NEAR smart contracts while building a practical application that users can interact with. + +![Donation App Interface](/docs/assets/examples/donation.png) +_The donation app interface showing recent donations and donation form_ + +## How It Works + +The donation smart contract acts as an intermediary that: + +1. **Accepts NEAR tokens** from users through payable function calls +2. **Tracks donation history** by storing donor information and amounts +3. **Forwards tokens** to a designated beneficiary account +4. **Provides query methods** to retrieve donation statistics and history + +The key advantage of this approach is transparent donation tracking while ensuring funds reach their intended destination immediately. + +:::info + +The complete source code for this tutorial is available in the [donation examples repository](https://github.com/near-examples/donation-examples). The contract is deployed on testnet at `donation.near-examples.testnet` for testing purposes. + +::: + +## What You Will Learn + +In this tutorial, you will learn how to: + +- [Set up your development environment](1-setup.md) for NEAR smart contract development +- [Build the donation contract](2-contract.md) with token handling capabilities +- [Implement donation tracking](3-tracking.md) and storage management +- [Create query methods](4-queries.md) to retrieve donation data +- [Deploy and test the contract](5-deploy.md) on NEAR testnet +- [Build a frontend interface](6-frontend.md) to interact with your contract + +## Key Concepts Covered + +Throughout this tutorial, you'll master these essential NEAR development concepts: + +- **Token Transfers**: How to accept and forward NEAR tokens in smart contracts +- **Payable Functions**: Using `#[payable]` decorator to receive token deposits +- **Storage Management**: Efficiently storing and retrieving donation data +- **Error Handling**: Implementing robust error handling for financial operations +- **Testing**: Writing comprehensive tests for token-handling contracts +- **Frontend Integration**: Connecting a web interface to your smart contract + +Let's start by setting up your development environment! \ No newline at end of file diff --git a/docs/tutorials/donation/1-setup.md b/docs/tutorials/donation/1-setup.md new file mode 100644 index 00000000000..60019b745a3 --- /dev/null +++ b/docs/tutorials/donation/1-setup.md @@ -0,0 +1,222 @@ +--- +id: setup +title: Setting up Your Development Environment +sidebar_label: Development Setup +description: "Learn how to set up your development environment for building NEAR smart contracts with token handling capabilities." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Before building our donation smart contract, we need to set up a proper development environment. This includes installing the necessary tools, creating accounts, and initializing our project structure. + +## Prerequisites + +Ensure you have the following tools installed on your system: + +- **Node.js** (v18 or later) and **npm** or **yarn** +- **Rust** (latest stable version) with `wasm32-unknown-unknown` target +- **NEAR CLI** for account management and deployment + + + + +```bash +# Install Node.js via Homebrew +brew install node + +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +rustup target add wasm32-unknown-unknown + +# Install NEAR CLI +npm install -g near-cli +``` + + + + +```bash +# Install Node.js via package manager +sudo apt update +sudo apt install nodejs npm + +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +rustup target add wasm32-unknown-unknown + +# Install NEAR CLI +npm install -g near-cli +``` + + + + +```bash +# Install Node.js from https://nodejs.org +# Download and install Rust from https://rustup.rs +rustup target add wasm32-unknown-unknown + +# Install NEAR CLI +npm install -g near-cli +``` + + + + +## Creating NEAR Accounts + +For this tutorial, we'll need two accounts: one for development/deployment and one to act as the donation beneficiary. + +### Create Developer Account + + + + +```bash +# Create a new developer account with testnet funding +near create-account donation-dev.testnet --useFaucet +``` + + + + +```bash +# Create a new account pre-funded by faucet service +near account create-account sponsor-by-faucet-service donation-dev.testnet autogenerate-new-keypair save-to-keychain network-config testnet create +``` + + + + +### Create Beneficiary Account + +```bash +# Create an account that will receive donations +near create-account donation-beneficiary.testnet --useFaucet +``` + +:::tip +Write down your account names as we'll use them throughout the tutorial. Replace `donation-dev.testnet` and `donation-beneficiary.testnet` with your actual account names. +::: + +## Project Initialization + +Now let's create our project structure. We'll build both Rust and JavaScript versions of the contract. + +### Initialize Rust Contract + +```bash +# Create project directory +mkdir near-donation-tutorial +cd near-donation-tutorial + +# Initialize Rust contract +cargo init --name donation-contract +``` + +Add the following to your `Cargo.toml`: + +```toml +[package] +name = "donation-contract" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +near-sdk = "5.0" +serde_json = "1.0" + +[profile.release] +codegen-units = 1 +opt-level = "z" +lto = true +debug = false +panic = "abort" +overflow-checks = true +``` + +### Initialize TypeScript Contract (Alternative) + +If you prefer TypeScript, create a separate directory: + +```bash +# In the same project root +mkdir contract-ts +cd contract-ts +npm init -y + +# Install NEAR SDK for JavaScript +npm install near-sdk-js +npm install -D typescript @types/node +``` + +## Project Structure + +Your project should now look like this: + +```bash +near-donation-tutorial/ +├── src/ # Rust contract source +│ ├── lib.rs +│ └── donation.rs +├── contract-ts/ # TypeScript contract (optional) +│ ├── src/ +│ └── package.json +├── tests/ # Integration tests +├── frontend/ # Web interface (we'll add this later) +├── Cargo.toml +└── README.md +``` + +## Environment Configuration + +Set up your environment variables for easier development: + +```bash +# Create .env file +echo "CONTRACT_NAME=donation-dev.testnet" >> .env +echo "BENEFICIARY=donation-beneficiary.testnet" >> .env +echo "NETWORK=testnet" >> .env +``` + +## Verification + +Let's verify everything is set up correctly: + +```bash +# Check Rust installation +rustc --version +cargo --version + +# Check NEAR CLI +near --version + +# Check Node.js (for frontend later) +node --version +npm --version + +# Test account access +near state donation-dev.testnet --networkId testnet +``` + +You should see account information including balance and storage usage. + +:::info Next Steps +With your development environment ready, we can now start building the donation smart contract. In the next section, we'll implement the core contract structure and token handling logic. +::: + +## Troubleshooting + +### Common Issues + +**Rust target not found**: Run `rustup target add wasm32-unknown-unknown` + +**NEAR CLI authentication**: Run `near login` and follow the browser authentication process + +**Account creation fails**: Ensure you have sufficient funds or use the faucet option + +**Permission errors**: On Unix systems, you might need to use `sudo` for global npm installs \ No newline at end of file diff --git a/docs/tutorials/donation/2-contract.md b/docs/tutorials/donation/2-contract.md new file mode 100644 index 00000000000..11cbe31fdf2 --- /dev/null +++ b/docs/tutorials/donation/2-contract.md @@ -0,0 +1,377 @@ +--- +id: contract +title: Building the Core Donation Contract +sidebar_label: Core Contract +description: "Learn how to create the fundamental structure of a donation smart contract that can receive and handle NEAR tokens." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from '@site/src/components/codetabs'; + +Now that our development environment is ready, let's build the core donation smart contract. This contract will handle NEAR token transfers, track donations, and manage beneficiaries. + +## Contract Structure Overview + +Our donation contract needs to: + +1. **Accept donations** through payable functions +2. **Store donation records** for transparency +3. **Forward tokens** to the beneficiary immediately +4. **Track donation statistics** like total amount and donor count + +## Basic Contract Setup + +Let's start by creating the fundamental contract structure. + + + + +First, update `src/lib.rs`: + +```rust +use near_sdk::{ + env, near_bindgen, require, AccountId, Balance, BorshDeserialize, + BorshSerialize, PanicOnDefault, Promise +}; +use std::collections::HashMap; + +mod donation; +pub use donation::*; + +#[near_bindgen] +impl DonationContract { + #[init] + pub fn new(beneficiary: AccountId) -> Self { + require!(!env::state_exists(), "Already initialized"); + Self { + beneficiary, + donations: HashMap::new(), + total_donations: 0, + } + } +} +``` + +Now create `src/donation.rs`: + +```rust +use near_sdk::{ + env, near_bindgen, require, AccountId, Balance, BorshDeserialize, + BorshSerialize, PanicOnDefault, Promise, json_types::U128 +}; +use std::collections::HashMap; + +/// Represents a single donation record +#[derive(BorshDeserialize, BorshSerialize)] +pub struct Donation { + pub account_id: AccountId, + pub total_amount: Balance, + pub timestamp: u64, +} + +/// The main donation contract +#[near_bindgen] +#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +pub struct DonationContract { + /// Account that receives the donations + pub beneficiary: AccountId, + /// Map of donor account to their donation info + pub donations: HashMap, + /// Total amount donated to this contract + pub total_donations: Balance, +} +``` + + + + +Create `contract-ts/src/contract.ts`: + +```typescript +import { NearBindgen, near, call, view, initialize, LookupMap } from 'near-sdk-js'; +import { Donation } from './model'; + +@NearBindgen({}) +class DonationContract { + beneficiary: string = ''; + donations: LookupMap = new LookupMap('d'); + totalDonations: string = '0'; + + @initialize({}) + init({ beneficiary }: { beneficiary: string }): void { + this.beneficiary = beneficiary; + this.totalDonations = '0'; + } +} +``` + +And create `contract-ts/src/model.ts`: + +```typescript +export class Donation { + account_id: string; + total_amount: string; + timestamp: string; + + constructor(account_id: string, total_amount: string, timestamp: string) { + this.account_id = account_id; + this.total_amount = total_amount; + this.timestamp = timestamp; + } +} +``` + + + + +## Implementing the Donation Function + +The core functionality is the `donate` method that accepts NEAR tokens and forwards them to the beneficiary. + + + + +Add this method to `src/donation.rs`: + +```rust +#[near_bindgen] +impl DonationContract { + /// Accepts a donation and forwards it to the beneficiary + #[payable] + pub fn donate(&mut self) { + let donor: AccountId = env::predecessor_account_id(); + let donation_amount: Balance = env::attached_deposit(); + + // Ensure a donation was actually sent + require!(donation_amount > 0, "Donation amount must be greater than 0"); + + // Update donation records + self.total_donations += donation_amount; + + match self.donations.get(&donor) { + Some(mut existing_donation) => { + existing_donation.total_amount += donation_amount; + existing_donation.timestamp = env::block_timestamp(); + self.donations.insert(donor.clone(), existing_donation); + } + None => { + let new_donation = Donation { + account_id: donor.clone(), + total_amount: donation_amount, + timestamp: env::block_timestamp(), + }; + self.donations.insert(donor.clone(), new_donation); + } + } + + // Transfer the donation to the beneficiary + Promise::new(self.beneficiary.clone()).transfer(donation_amount); + + // Log the donation event + env::log_str(&format!( + "Thank you @{} for donating {}! Total raised: {}", + donor, + donation_amount, + self.total_donations + )); + } + + /// Get the current beneficiary + pub fn get_beneficiary(&self) -> AccountId { + self.beneficiary.clone() + } + + /// Get total donations received + pub fn get_total_donations(&self) -> U128 { + U128(self.total_donations) + } +} +``` + + + + +Add this method to `contract-ts/src/contract.ts`: + +```typescript +import { NearBindgen, near, call, view, initialize, LookupMap, assert } from 'near-sdk-js'; +import { Donation } from './model'; + +@NearBindgen({}) +export class DonationContract { + beneficiary: string = ''; + donations: LookupMap = new LookupMap('d'); + totalDonations: string = '0'; + + @initialize({}) + init({ beneficiary }: { beneficiary: string }): void { + this.beneficiary = beneficiary; + this.totalDonations = '0'; + } + + @call({ payableFunction: true }) + donate(): void { + const donor = near.predecessorAccountId(); + const donationAmount = near.attachedDeposit(); + + assert(donationAmount > BigInt(0), 'Donation amount must be greater than 0'); + + // Update total donations + this.totalDonations = (BigInt(this.totalDonations) + donationAmount).toString(); + + // Update donation records + const existingDonation = this.donations.get(donor); + if (existingDonation) { + existingDonation.total_amount = (BigInt(existingDonation.total_amount) + donationAmount).toString(); + existingDonation.timestamp = near.blockTimestamp().toString(); + this.donations.set(donor, existingDonation); + } else { + const newDonation = new Donation( + donor, + donationAmount.toString(), + near.blockTimestamp().toString() + ); + this.donations.set(donor, newDonation); + } + + // Transfer to beneficiary + const promise = near.promiseBatchCreate(this.beneficiary); + near.promiseBatchActionTransfer(promise, donationAmount); + + near.log(`Thank you @${donor} for donating ${donationAmount}! Total raised: ${this.totalDonations}`); + } + + @view({}) + get_beneficiary(): string { + return this.beneficiary; + } + + @view({}) + get_total_donations(): string { + return this.totalDonations; + } +} +``` + + + + +## Key Concepts Explained + +### Payable Functions + +The `#[payable]` decorator (Rust) or `payableFunction: true` (JS) allows the function to receive NEAR tokens. Without this, the function would panic if tokens are attached. + +### Token Transfer + +- **Rust**: `Promise::new(account).transfer(amount)` creates a promise to transfer tokens +- **JavaScript**: `near.promiseBatchCreate()` and `near.promiseBatchActionTransfer()` achieve the same + +### Storage Considerations + +We use efficient storage patterns: +- **HashMap** (Rust) stores donation records in contract state +- **LookupMap** (JavaScript) provides similar functionality with optimized storage access + +### Error Handling + +Both implementations include proper error handling: +- Checking for zero donations +- Validating attached deposits +- Ensuring proper initialization + +## Building the Contract + +Let's test our contract compiles correctly: + + + + +```bash +# Build the contract +cargo build --target wasm32-unknown-unknown --release + +# Check for any compilation errors +cargo check +``` + + + + +```bash +cd contract-ts + +# Install dependencies if not already done +npm install + +# Build the contract +npm run build +``` + +Add this build script to your `package.json`: + +```json +{ + "scripts": { + "build": "near-sdk-js build src/contract.ts build/contract.wasm" + } +} +``` + + + + +If the build succeeds, you're ready to move on to implementing donation tracking and query methods. + +:::tip Gas Considerations +Token transfers consume gas. Always ensure your contract functions have sufficient gas allowance, especially when making cross-contract calls or promises. +::: + +:::info Next Steps +Our basic donation contract can now accept and forward tokens! In the next section, we'll add comprehensive donation tracking and query capabilities to make the contract more useful and transparent. +::: + +## Testing the Core Functionality + +Before moving forward, let's create a simple test to verify our donation function works: + + + + +Create `src/tests.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use near_sdk::testing_env; + use near_sdk::test_utils::{accounts, VMContextBuilder}; + + #[test] + fn test_donation() { + let mut context = VMContextBuilder::new(); + testing_env!(context + .predecessor_account_id(accounts(0)) + .attached_deposit(1_000_000_000_000_000_000_000_000) // 1 NEAR + .build()); + + let mut contract = DonationContract::new(accounts(1)); + contract.donate(); + + assert_eq!(contract.get_total_donations().0, 1_000_000_000_000_000_000_000_000); + } +} +``` + +Run the test with `cargo test`. + + + + +We'll cover comprehensive testing in the deployment section, including sandbox tests that simulate the full blockchain environment. + + + + +Your donation contract core is now ready! The next step is implementing comprehensive tracking and query methods. \ No newline at end of file diff --git a/docs/tutorials/donation/3-tracking.md b/docs/tutorials/donation/3-tracking.md new file mode 100644 index 00000000000..824088a2307 --- /dev/null +++ b/docs/tutorials/donation/3-tracking.md @@ -0,0 +1,499 @@ +--- +id: tracking +title: Implementing Donation Tracking and History +sidebar_label: Donation Tracking +description: "Learn how to implement comprehensive donation tracking, pagination, and efficient storage management in your NEAR smart contract." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from '@site/src/components/codetabs'; + +Now that our contract can accept donations, let's implement comprehensive tracking features that make the donation process transparent and user-friendly. We'll add pagination, donor statistics, and efficient data retrieval methods. + +## Enhanced Data Structures + +First, let's improve our data structures to support better tracking and querying. + + + + +Update `src/donation.rs` with enhanced structures: + +```rust +use near_sdk::{ + env, near_bindgen, require, AccountId, Balance, BorshDeserialize, + BorshSerialize, PanicOnDefault, Promise, json_types::U128, serde::{Deserialize, Serialize} +}; +use std::collections::{HashMap, VecDeque}; + +/// Represents a single donation record with detailed information +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct Donation { + pub account_id: AccountId, + pub total_amount: Balance, + pub timestamp: u64, + pub donation_count: u32, +} + +/// A single donation event for history tracking +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct DonationEvent { + pub donor: AccountId, + pub amount: Balance, + pub timestamp: u64, + pub message: Option, +} + +/// Statistics about donations +#[derive(Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct DonationStats { + pub total_donations: U128, + pub total_donors: u32, + pub largest_donation: U128, + pub latest_donation: Option, +} + +/// Enhanced donation contract with comprehensive tracking +#[near_bindgen] +#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +pub struct DonationContract { + pub beneficiary: AccountId, + pub donations: HashMap, + pub donation_history: VecDeque, + pub total_donations: Balance, + pub largest_donation: Balance, +} +``` + + + + +Update `contract-ts/src/model.ts`: + +```typescript +export class Donation { + account_id: string; + total_amount: string; + timestamp: string; + donation_count: number; + + constructor(account_id: string, total_amount: string, timestamp: string, donation_count: number = 1) { + this.account_id = account_id; + this.total_amount = total_amount; + this.timestamp = timestamp; + this.donation_count = donation_count; + } +} + +export class DonationEvent { + donor: string; + amount: string; + timestamp: string; + message?: string; + + constructor(donor: string, amount: string, timestamp: string, message?: string) { + this.donor = donor; + this.amount = amount; + this.timestamp = timestamp; + this.message = message; + } +} + +export class DonationStats { + total_donations: string; + total_donors: number; + largest_donation: string; + latest_donation?: DonationEvent; + + constructor( + total_donations: string, + total_donors: number, + largest_donation: string, + latest_donation?: DonationEvent + ) { + this.total_donations = total_donations; + this.total_donors = total_donors; + this.largest_donation = largest_donation; + this.latest_donation = latest_donation; + } +} +``` + +Update `contract-ts/src/contract.ts`: + +```typescript +import { NearBindgen, near, call, view, initialize, LookupMap, Vector, assert } from 'near-sdk-js'; +import { Donation, DonationEvent, DonationStats } from './model'; + +@NearBindgen({}) +export class DonationContract { + beneficiary: string = ''; + donations: LookupMap = new LookupMap('d'); + donationHistory: Vector = new Vector('h'); + totalDonations: string = '0'; + largestDonation: string = '0'; + + @initialize({}) + init({ beneficiary }: { beneficiary: string }): void { + this.beneficiary = beneficiary; + this.totalDonations = '0'; + this.largestDonation = '0'; + } +} +``` + +## Storage Management + +Efficient storage management is crucial for keeping your contract economical and performant. Here are key strategies we've implemented: + +### History Limits + +```rust +// Keep only last 100 donations in history to manage storage +if self.donation_history.len() > 100 { + self.donation_history.pop_front(); +} +``` + +### Pagination + +We implement pagination to avoid gas limit issues when returning large datasets: + +```rust +pub fn get_donations(&self, from_index: Option, limit: Option) -> Vec { + let limit = limit.unwrap_or(10).min(50) as usize; // Cap at 50 + // ... pagination logic +} +``` + +### Gas Optimization + +- **View methods** don't consume gas for the caller +- **Query limits** prevent excessive gas consumption +- **Efficient data structures** reduce storage costs + +## Advanced Features + +### Donation Messages + +Users can now include optional messages with their donations: + + + + +```rust +// Call with message +near call $CONTRACT_ID donate '{"message": "Keep up the great work!"}' --deposit 1 --accountId $YOUR_ACCOUNT +``` + + + + +```bash +# Call with message +near call $CONTRACT_ID donate '{"message": "Keep up the great work!"}' --deposit 1 --accountId $YOUR_ACCOUNT +``` + + + + +### Top Donor Rankings + +The contract now tracks and can return top donors: + +```rust +// Get top 5 donors +let top_donors = contract.get_top_donors(Some(5)); +``` + +### Real-time Statistics + +Get comprehensive donation statistics: + +```rust +let stats = contract.get_donation_stats(); +println!("Total raised: {} NEAR", stats.total_donations.0 as f64 / 1e24); +println!("Number of donors: {}", stats.total_donors); +``` + +## Testing Enhanced Features + +Let's create tests for our new tracking features: + + + + +Add to `src/tests.rs`: + +```rust +#[test] +fn test_donation_tracking() { + let mut context = VMContextBuilder::new(); + testing_env!(context + .predecessor_account_id(accounts(0)) + .attached_deposit(1_000_000_000_000_000_000_000_000) // 1 NEAR + .build()); + + let mut contract = DonationContract::new(accounts(1)); + + // First donation + contract.donate(Some("First donation!".to_string())); + + // Second donation from same user + testing_env!(context + .attached_deposit(2_000_000_000_000_000_000_000_000) // 2 NEAR + .build()); + contract.donate(None); + + // Check stats + let stats = contract.get_donation_stats(); + assert_eq!(stats.total_donations.0, 3_000_000_000_000_000_000_000_000); + assert_eq!(stats.total_donors, 1); + assert_eq!(stats.largest_donation.0, 2_000_000_000_000_000_000_000_000); + + // Check donation history + let history = contract.get_donations(None, None); + assert_eq!(history.len(), 2); + assert_eq!(history[0].amount, 2_000_000_000_000_000_000_000_000); // Most recent first +} + +#[test] +fn test_pagination() { + let mut context = VMContextBuilder::new(); + let mut contract = DonationContract::new(accounts(1)); + + // Make multiple donations + for i in 0..15 { + testing_env!(context + .predecessor_account_id(accounts(0)) + .attached_deposit(1_000_000_000_000_000_000_000_000) + .build()); + contract.donate(Some(format!("Donation {}", i))); + } + + // Test pagination + let first_page = contract.get_donations(Some(0), Some(5)); + assert_eq!(first_page.len(), 5); + + let second_page = contract.get_donations(Some(5), Some(5)); + assert_eq!(second_page.len(), 5); + + // Verify they're different donations + assert_ne!(first_page[0].timestamp, second_page[0].timestamp); +} +``` + + + + +Testing for JavaScript contracts will be covered in the deployment section with comprehensive sandbox tests. + + + + +## Building and Verifying + +Build your enhanced contract: + +```bash +# Rust +cargo build --target wasm32-unknown-unknown --release + +# JavaScript +cd contract-ts && npm run build +``` + +Run tests to ensure everything works: + +```bash +# Rust +cargo test + +# JavaScript tests will be covered in deployment section +``` + +:::tip Storage Costs +Each piece of data stored in your contract costs NEAR tokens for storage. The current cost is approximately 1 NEAR per 100kb of data. Our history limiting ensures storage costs remain reasonable. +::: + +:::info Performance Considerations +- **Pagination** prevents gas limit issues with large datasets +- **View methods** are free to call and don't modify state +- **Efficient sorting** is implemented for top donor queries +::: + +Your donation contract now has comprehensive tracking capabilities! In the next section, we'll implement advanced query methods and add administrative features. + +## Enhanced Donation Function + +Let's update our donation function to include comprehensive tracking with optional messages. + + + + +```rust +#[near_bindgen] +impl DonationContract { + /// Enhanced donate function with message support and comprehensive tracking + #[payable] + pub fn donate(&mut self, message: Option) { + let donor: AccountId = env::predecessor_account_id(); + let donation_amount: Balance = env::attached_deposit(); + + require!(donation_amount > 0, "Donation amount must be greater than 0"); + + // Update total donations + self.total_donations += donation_amount; + + // Track largest donation + if donation_amount > self.largest_donation { + self.largest_donation = donation_amount; + } + + // Update donor records + match self.donations.get(&donor) { + Some(mut existing_donation) => { + existing_donation.total_amount += donation_amount; + existing_donation.timestamp = env::block_timestamp(); + existing_donation.donation_count += 1; + self.donations.insert(donor.clone(), existing_donation); + } + None => { + let new_donation = Donation { + account_id: donor.clone(), + total_amount: donation_amount, + timestamp: env::block_timestamp(), + donation_count: 1, + }; + self.donations.insert(donor.clone(), new_donation); + } + } + + // Add to donation history + let donation_event = DonationEvent { + donor: donor.clone(), + amount: donation_amount, + timestamp: env::block_timestamp(), + message: message.clone(), + }; + + self.donation_history.push_back(donation_event); + + // Keep only last 100 donations in history to manage storage + if self.donation_history.len() > 100 { + self.donation_history.pop_front(); + } + + // Transfer to beneficiary + Promise::new(self.beneficiary.clone()).transfer(donation_amount); + + // Enhanced logging + let log_message = match message { + Some(msg) => format!( + "Thank you @{} for donating {} NEAR with message: '{}'. Total raised: {} NEAR", + donor, + donation_amount as f64 / 1e24, + msg, + self.total_donations as f64 / 1e24 + ), + None => format!( + "Thank you @{} for donating {} NEAR! Total raised: {} NEAR", + donor, + donation_amount as f64 / 1e24, + self.total_donations as f64 / 1e24 + ), + }; + + env::log_str(&log_message); + } +} +``` + + + + +```typescript +@call({ payableFunction: true }) +donate({ message }: { message?: string }): void { + const donor = near.predecessorAccountId(); + const donationAmount = near.attachedDeposit(); + + assert(donationAmount > BigInt(0), 'Donation amount must be greater than 0'); + + // Update totals + this.totalDonations = (BigInt(this.totalDonations) + donationAmount).toString(); + + // Track largest donation + if (donationAmount > BigInt(this.largestDonation)) { + this.largestDonation = donationAmount.toString(); + } + + // Update donor records + const existingDonation = this.donations.get(donor); + if (existingDonation) { + existingDonation.total_amount = (BigInt(existingDonation.total_amount) + donationAmount).toString(); + existingDonation.timestamp = near.blockTimestamp().toString(); + existingDonation.donation_count += 1; + this.donations.set(donor, existingDonation); + } else { + const newDonation = new Donation( + donor, + donationAmount.toString(), + near.blockTimestamp().toString(), + 1 + ); + this.donations.set(donor, newDonation); + } + + // Add to history + const donationEvent = new DonationEvent( + donor, + donationAmount.toString(), + near.blockTimestamp().toString(), + message + ); + + this.donationHistory.push(donationEvent); + + // Keep only last 100 donations + if (this.donationHistory.length > 100) { + this.donationHistory.swapRemove(0); + } + + // Transfer to beneficiary + const promise = near.promiseBatchCreate(this.beneficiary); + near.promiseBatchActionTransfer(promise, donationAmount); + + const logMessage = message + ? `Thank you @${donor} for donating ${donationAmount} with message: '${message}'. Total: ${this.totalDonations}` + : `Thank you @${donor} for donating ${donationAmount}! Total: ${this.totalDonations}`; + + near.log(logMessage); +} +``` + + + + +## Comprehensive Query Methods + +Now let's implement various methods to query donation data with pagination and statistics. + + + + +```rust +#[near_bindgen] +impl DonationContract { + /// Get donation statistics + pub fn get_donation_stats(&self) -> DonationStats { + let latest_donation = self.donation_history.back().cloned(); + + DonationStats { + total_donations: U128(self.total_donations), + total_donors: self.donations.len() as u32, + largest_donation: U128 \ No newline at end of file diff --git a/docs/tutorials/donation/4-queries.md b/docs/tutorials/donation/4-queries.md new file mode 100644 index 00000000000..6720bc8f789 --- /dev/null +++ b/docs/tutorials/donation/4-queries.md @@ -0,0 +1,820 @@ +--- +id: queries +title: Advanced Query Methods and Analytics +sidebar_label: Advanced Queries +description: "Implement sophisticated query methods, analytics, and administrative features for your donation smart contract." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from '@site/src/components/codetabs'; + +Now let's add advanced query capabilities and analytics to make our donation contract more useful for both users and administrators. We'll implement search functionality, time-based queries, and administrative controls. + +## Time-Based Analytics + +Let's add methods to query donations within specific time periods and generate analytics. + + + + +Add these structures and methods to `src/donation.rs`: + +```rust +/// Time-based donation analytics +#[derive(Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct DonationAnalytics { + pub period_donations: U128, + pub period_donors: u32, + pub average_donation: U128, + pub donation_frequency: f64, // donations per day +} + +/// Date range for queries +#[derive(Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct DateRange { + pub start_timestamp: u64, + pub end_timestamp: u64, +} + +#[near_bindgen] +impl DonationContract { + /// Get donations within a time range + pub fn get_donations_by_date_range(&self, date_range: DateRange) -> Vec { + self.donation_history + .iter() + .filter(|donation| { + donation.timestamp >= date_range.start_timestamp + && donation.timestamp <= date_range.end_timestamp + }) + .cloned() + .collect() + } + + /// Get donation analytics for a specific time period + pub fn get_donation_analytics(&self, date_range: DateRange) -> DonationAnalytics { + let period_donations: Vec<&DonationEvent> = self.donation_history + .iter() + .filter(|donation| { + donation.timestamp >= date_range.start_timestamp + && donation.timestamp <= date_range.end_timestamp + }) + .collect(); + + let total_amount: Balance = period_donations.iter() + .map(|d| d.amount) + .sum(); + + let unique_donors: std::collections::HashSet<&AccountId> = period_donations.iter() + .map(|d| &d.donor) + .collect(); + + let days_in_period = ((date_range.end_timestamp - date_range.start_timestamp) / 86_400_000_000_000) as f64; + let donation_frequency = if days_in_period > 0.0 { + period_donations.len() as f64 / days_in_period + } else { + 0.0 + }; + + let average_donation = if !period_donations.is_empty() { + total_amount / period_donations.len() as Balance + } else { + 0 + }; + + DonationAnalytics { + period_donations: U128(total_amount), + period_donors: unique_donors.len() as u32, + average_donation: U128(average_donation), + donation_frequency, + } + } + + /// Get donations from a specific donor with pagination + pub fn get_donations_by_donor(&self, donor: AccountId, from_index: Option, limit: Option) -> Vec { + let start_index = from_index.unwrap_or(0) as usize; + let limit = limit.unwrap_or(10).min(50) as usize; + + let donor_donations: Vec = self.donation_history + .iter() + .filter(|donation| donation.donor == donor) + .cloned() + .collect(); + + let end_index = (start_index + limit).min(donor_donations.len()); + + if start_index >= donor_donations.len() { + return vec![]; + } + + // Return most recent first + let mut result = donor_donations; + result.reverse(); + result[start_index..end_index].to_vec() + } + + /// Search donations by message content + pub fn search_donations_by_message(&self, search_term: String, limit: Option) -> Vec { + let limit = limit.unwrap_or(10).min(50) as usize; + let search_term = search_term.to_lowercase(); + + self.donation_history + .iter() + .filter(|donation| { + if let Some(ref message) = donation.message { + message.to_lowercase().contains(&search_term) + } else { + false + } + }) + .rev() // Most recent first + .take(limit) + .cloned() + .collect() + } + + /// Get donation milestones (every 1000 NEAR raised) + pub fn get_donation_milestones(&self) -> Vec { + let mut milestones = Vec::new(); + let mut running_total: Balance = 0; + let mut next_milestone: Balance = 1_000_000_000_000_000_000_000_000_000; // 1000 NEAR + + for donation in &self.donation_history { + running_total += donation.amount; + + if running_total >= next_milestone { + milestones.push(donation.clone()); + next_milestone += 1_000_000_000_000_000_000_000_000_000; // Next 1000 NEAR + } + } + + milestones + } +} +``` + + + + +Add these classes to `contract-ts/src/model.ts`: + +```typescript +export class DonationAnalytics { + period_donations: string; + period_donors: number; + average_donation: string; + donation_frequency: number; + + constructor( + period_donations: string, + period_donors: number, + average_donation: string, + donation_frequency: number + ) { + this.period_donations = period_donations; + this.period_donors = period_donors; + this.average_donation = average_donation; + this.donation_frequency = donation_frequency; + } +} + +export class DateRange { + start_timestamp: string; + end_timestamp: string; + + constructor(start_timestamp: string, end_timestamp: string) { + this.start_timestamp = start_timestamp; + this.end_timestamp = end_timestamp; + } +} +``` + +Add these methods to `contract-ts/src/contract.ts`: + +```typescript +@view({}) +get_donations_by_date_range({ date_range }: { date_range: DateRange }): DonationEvent[] { + const result: DonationEvent[] = []; + const startTime = BigInt(date_range.start_timestamp); + const endTime = BigInt(date_range.end_timestamp); + + for (let i = 0; i < this.donationHistory.length; i++) { + const donation = this.donationHistory.get(i); + const donationTime = BigInt(donation.timestamp); + + if (donationTime >= startTime && donationTime <= endTime) { + result.push(donation); + } + } + + return result.reverse(); // Most recent first +} + +@view({}) +get_donation_analytics({ date_range }: { date_range: DateRange }): DonationAnalytics { + const periodDonations: DonationEvent[] = []; + const startTime = BigInt(date_range.start_timestamp); + const endTime = BigInt(date_range.end_timestamp); + + for (let i = 0; i < this.donationHistory.length; i++) { + const donation = this.donationHistory.get(i); + const donationTime = BigInt(donation.timestamp); + + if (donationTime >= startTime && donationTime <= endTime) { + periodDonations.push(donation); + } + } + + let totalAmount = BigInt(0); + const uniqueDonors = new Set(); + + periodDonations.forEach(donation => { + totalAmount += BigInt(donation.amount); + uniqueDonors.add(donation.donor); + }); + + const daysInPeriod = Number(endTime - startTime) / (86_400 * 1e9); // nanoseconds to days + const donationFrequency = daysInPeriod > 0 ? periodDonations.length / daysInPeriod : 0; + const averageDonation = periodDonations.length > 0 ? totalAmount / BigInt(periodDonations.length) : BigInt(0); + + return new DonationAnalytics( + totalAmount.toString(), + uniqueDonors.size, + averageDonation.toString(), + donationFrequency + ); +} + +@view({}) +get_donations_by_donor({ donor, from_index, limit }: { + donor: string; + from_index?: number; + limit?: number; +}): DonationEvent[] { + const startIndex = from_index || 0; + const maxLimit = Math.min(limit || 10, 50); + + const donorDonations: DonationEvent[] = []; + + for (let i = 0; i < this.donationHistory.length; i++) { + const donation = this.donationHistory.get(i); + if (donation.donor === donor) { + donorDonations.push(donation); + } + } + + // Reverse to get most recent first, then apply pagination + donorDonations.reverse(); + const endIndex = Math.min(startIndex + maxLimit, donorDonations.length); + + return donorDonations.slice(startIndex, endIndex); +} + +@view({}) +search_donations_by_message({ search_term, limit }: { + search_term: string; + limit?: number; +}): DonationEvent[] { + const maxLimit = Math.min(limit || 10, 50); + const searchTermLower = search_term.toLowerCase(); + const results: DonationEvent[] = []; + + // Search from most recent backwards + for (let i = this.donationHistory.length - 1; i >= 0 && results.length < maxLimit; i--) { + const donation = this.donationHistory.get(i); + if (donation.message && donation.message.toLowerCase().includes(searchTermLower)) { + results.push(donation); + } + } + + return results; +} + +@view({}) +get_donation_milestones(): DonationEvent[] { + const milestones: DonationEvent[] = []; + let runningTotal = BigInt(0); + let nextMilestone = BigInt('1000000000000000000000000000'); // 1000 NEAR + + for (let i = 0; i < this.donationHistory.length; i++) { + const donation = this.donationHistory.get(i); + runningTotal += BigInt(donation.amount); + + if (runningTotal >= nextMilestone) { + milestones.push(donation); + nextMilestone += BigInt('1000000000000000000000000000'); // Next 1000 NEAR + } + } + + return milestones; +} +``` + + + + +## Administrative Functions + +Let's add administrative functions for contract management. + + + + +Add these administrative methods: + +```rust +#[near_bindgen] +impl DonationContract { + /// Change the beneficiary (only callable by current beneficiary) + pub fn change_beneficiary(&mut self, new_beneficiary: AccountId) { + require!( + env::predecessor_account_id() == self.beneficiary, + "Only the current beneficiary can change the beneficiary" + ); + + let old_beneficiary = self.beneficiary.clone(); + self.beneficiary = new_beneficiary.clone(); + + env::log_str(&format!( + "Beneficiary changed from {} to {}", + old_beneficiary, + new_beneficiary + )); + } + + /// Emergency pause functionality (only callable by beneficiary) + pub fn set_pause_status(&mut self, paused: bool) { + require!( + env::predecessor_account_id() == self.beneficiary, + "Only the beneficiary can pause/unpause the contract" + ); + + // Note: In a full implementation, you'd store this in contract state + // and check it in the donate function + env::log_str(&format!("Contract pause status set to: {}", paused)); + } + + /// Get contract metadata + pub fn get_contract_metadata(&self) -> serde_json::Value { + serde_json::json!({ + "version": "1.0.0", + "beneficiary": self.beneficiary, + "total_donations": self.total_donations.to_string(), + "total_donors": self.donations.len(), + "contract_balance": env::account_balance().to_string(), + "storage_used": env::storage_usage(), + }) + } + + /// Get detailed donor information + pub fn get_donor_details(&self, donor: AccountId) -> Option { + if let Some(donation_info) = self.donations.get(&donor) { + let donor_history: Vec<&DonationEvent> = self.donation_history + .iter() + .filter(|d| d.donor == donor) + .collect(); + + Some(serde_json::json!({ + "donor": donor, + "total_donated": donation_info.total_amount.to_string(), + "donation_count": donation_info.donation_count, + "first_donation": donor_history.first().map(|d| d.timestamp), + "latest_donation": donation_info.timestamp, + "largest_single_donation": donor_history.iter() + .map(|d| d.amount) + .max() + .unwrap_or(0) + .to_string(), + })) + } else { + None + } + } +} +``` + + + + +Add these administrative methods: + +```typescript +@call({}) +change_beneficiary({ new_beneficiary }: { new_beneficiary: string }): void { + assert( + near.predecessorAccountId() === this.beneficiary, + 'Only the current beneficiary can change the beneficiary' + ); + + const oldBeneficiary = this.beneficiary; + this.beneficiary = new_beneficiary; + + near.log(`Beneficiary changed from ${oldBeneficiary} to ${new_beneficiary}`); +} + +@call({}) +set_pause_status({ paused }: { paused: boolean }): void { + assert( + near.predecessorAccountId() === this.beneficiary, + 'Only the beneficiary can pause/unpause the contract' + ); + + // Note: In a full implementation, you'd store this in contract state + // and check it in the donate function + near.log(`Contract pause status set to: ${paused}`); +} + +@view({}) +get_contract_metadata(): any { + return { + version: '1.0.0', + beneficiary: this.beneficiary, + total_donations: this.totalDonations, + total_donors: this.donations.length, + contract_balance: near.accountBalance(), + storage_used: near.storageUsage().toString(), + }; +} + +@view({}) +get_donor_details({ donor }: { donor: string }): any | null { + const donationInfo = this.donations.get(donor); + if (!donationInfo) { + return null; + } + + const donorHistory: DonationEvent[] = []; + let largestDonation = BigInt(0); + let firstDonation: DonationEvent | null = null; + + for (let i = 0; i < this.donationHistory.length; i++) { + const donation = this.donationHistory.get(i); + if (donation.donor === donor) { + donorHistory.push(donation); + if (!firstDonation) { + firstDonation = donation; + } + const amount = BigInt(donation.amount); + if (amount > largestDonation) { + largestDonation = amount; + } + } + } + + return { + donor, + total_donated: donationInfo.total_amount, + donation_count: donationInfo.donation_count, + first_donation: firstDonation?.timestamp || null, + latest_donation: donationInfo.timestamp, + largest_single_donation: largestDonation.toString(), + }; +} +``` + + + + +## Query Usage Examples + +Here are practical examples of how to use these advanced query methods: + +### Time-Based Analytics + +```bash +# Get donations from the last 7 days +WEEK_AGO=$(date -d '7 days ago' +%s)000000000 # Convert to nanoseconds +NOW=$(date +%s)000000000 + +near view $CONTRACT_ID get_donation_analytics \ + '{"date_range": {"start_timestamp": "'$WEEK_AGO'", "end_timestamp": "'$NOW'"}}' +``` + +### Donor-Specific Queries + +```bash +# Get all donations from a specific donor +near view $CONTRACT_ID get_donations_by_donor \ + '{"donor": "alice.testnet", "from_index": 0, "limit": 10}' + +# Get detailed donor information +near view $CONTRACT_ID get_donor_details \ + '{"donor": "alice.testnet"}' +``` + +### Message Search + +```bash +# Search for donations with specific messages +near view $CONTRACT_ID search_donations_by_message \ + '{"search_term": "birthday", "limit": 5}' +``` + +### Milestone Tracking + +```bash +# Get all milestone donations +near view $CONTRACT_ID get_donation_milestones +``` + +## Performance Considerations + +### Gas Optimization + +- **Pagination limits**: All query methods limit results to prevent gas issues +- **Efficient filtering**: Use iterators and early termination where possible +- **View methods only**: Query methods are read-only and don't consume caller's gas + +### Storage Efficiency + +- **Bounded collections**: History is limited to prevent unbounded growth +- **Efficient data structures**: Use appropriate collection types for different access patterns +- **Lazy loading**: Don't load unnecessary data in queries + +## Testing Advanced Queries + + + + +Add comprehensive tests in `src/tests.rs`: + +```rust +#[test] +fn test_date_range_queries() { + let mut context = VMContextBuilder::new(); + let mut contract = DonationContract::new(accounts(1)); + + let start_time = 1_000_000_000; + let mid_time = 2_000_000_000; + let end_time = 3_000_000_000; + + // Make donations at different times + testing_env!(context + .predecessor_account_id(accounts(0)) + .attached_deposit(1_000_000_000_000_000_000_000_000) + .block_timestamp(start_time) + .build()); + contract.donate(Some("Early donation".to_string())); + + testing_env!(context + .block_timestamp(mid_time) + .build()); + contract.donate(Some("Mid donation".to_string())); + + testing_env!(context + .block_timestamp(end_time) + .build()); + contract.donate(Some("Late donation".to_string())); + + // Query first half + let date_range = DateRange { + start_timestamp: start_time, + end_timestamp: mid_time, + }; + let first_half = contract.get_donations_by_date_range(date_range); + assert_eq!(first_half.len(), 2); + + // Query analytics for full period + let full_range = DateRange { + start_timestamp: start_time, + end_timestamp: end_time, + }; + let analytics = contract.get_donation_analytics(full_range); + assert_eq!(analytics.period_donations.0, 3_000_000_000_000_000_000_000_000); + assert_eq!(analytics.period_donors, 1); +} + +#[test] +fn test_donor_queries() { + let mut context = VMContextBuilder::new(); + let mut contract = DonationContract::new(accounts(2)); + + // Donations from different accounts + testing_env!(context + .predecessor_account_id(accounts(0)) + .attached_deposit(1_000_000_000_000_000_000_000_000) + .build()); + contract.donate(Some("From Alice".to_string())); + + testing_env!(context + .predecessor_account_id(accounts(1)) + .attached_deposit(2_000_000_000_000_000_000_000_000) + .build()); + contract.donate(Some("From Bob".to_string())); + + testing_env!(context + .predecessor_account_id(accounts(0)) + .attached_deposit(500_000_000_000_000_000_000_000) + .build()); + contract.donate(Some("Alice again".to_string())); + + // Test donor-specific queries + let alice_donations = contract.get_donations_by_donor(accounts(0), None, None); + assert_eq!(alice_donations.len(), 2); + + let bob_donations = contract.get_donations_by_donor(accounts(1), None, None); + assert_eq!(bob_donations.len(), 1); + + // Test donor details + let alice_details = contract.get_donor_details(accounts(0)); + assert!(alice_details.is_some()); + + if let Some(details) = alice_details { + let total = details["total_donated"].as_str().unwrap(); + assert_eq!(total, "1500000000000000000000000"); // 1.5 NEAR + } +} + +#[test] +fn test_message_search() { + let mut context = VMContextBuilder::new(); + let mut contract = DonationContract::new(accounts(1)); + + // Add donations with various messages + testing_env!(context + .predecessor_account_id(accounts(0)) + .attached_deposit(1_000_000_000_000_000_000_000_000) + .build()); + contract.donate(Some("Happy birthday!".to_string())); + + testing_env!(context.build()); + contract.donate(Some("Great work on the project".to_string())); + + testing_env!(context.build()); + contract.donate(Some("Another birthday wish".to_string())); + + // Search for birthday messages + let birthday_donations = contract.search_donations_by_message("birthday".to_string(), None); + assert_eq!(birthday_donations.len(), 2); + + // Search for non-existent term + let empty_result = contract.search_donations_by_message("vacation".to_string(), None); + assert_eq!(empty_result.len(), 0); +} + +#[test] +fn test_administrative_functions() { + let mut context = VMContextBuilder::new(); + testing_env!(context + .predecessor_account_id(accounts(1)) // beneficiary + .build()); + + let mut contract = DonationContract::new(accounts(1)); + + // Test changing beneficiary (should succeed) + contract.change_beneficiary(accounts(2)); + assert_eq!(contract.get_beneficiary(), accounts(2)); + + // Test metadata + let metadata = contract.get_contract_metadata(); + assert_eq!(metadata["beneficiary"], accounts(2).to_string()); +} + +#[test] +#[should_panic(expected = "Only the current beneficiary can change the beneficiary")] +fn test_unauthorized_beneficiary_change() { + let mut context = VMContextBuilder::new(); + testing_env!(context + .predecessor_account_id(accounts(0)) // not beneficiary + .build()); + + let mut contract = DonationContract::new(accounts(1)); + contract.change_beneficiary(accounts(2)); // Should panic +} +``` + + + + +## CLI Usage Examples + +Here are practical command-line examples for interacting with your enhanced donation contract: + +### Basic Queries + +```bash +# Set your contract ID +export CONTRACT_ID="your-donation-contract.testnet" + +# Get donation statistics +near view $CONTRACT_ID get_donation_stats + +# Get recent donations with pagination +near view $CONTRACT_ID get_donations '{"from_index": 0, "limit": 5}' + +# Get top donors +near view $CONTRACT_ID get_top_donors '{"limit": 3}' +``` + +### Advanced Analytics + +```bash +# Get donations from last 30 days +THIRTY_DAYS_AGO=$(date -d '30 days ago' +%s)000000000 +NOW=$(date +%s)000000000 + +near view $CONTRACT_ID get_donation_analytics \ + "{\"date_range\": {\"start_timestamp\": \"$THIRTY_DAYS_AGO\", \"end_timestamp\": \"$NOW\"}}" + +# Search donations by message +near view $CONTRACT_ID search_donations_by_message \ + '{"search_term": "charity", "limit": 10}' + +# Get milestone donations +near view $CONTRACT_ID get_donation_milestones +``` + +### Administrative Commands + +```bash +# Change beneficiary (only current beneficiary can do this) +near call $CONTRACT_ID change_beneficiary \ + '{"new_beneficiary": "new-beneficiary.testnet"}' \ + --accountId current-beneficiary.testnet + +# Get contract metadata +near view $CONTRACT_ID get_contract_metadata + +# Get detailed donor information +near view $CONTRACT_ID get_donor_details \ + '{"donor": "generous-donor.testnet"}' +``` + +## Integration with Frontend + +These query methods are designed to integrate seamlessly with frontend applications: + +### JavaScript Integration Example + +```javascript +// Get recent donations for display +const recentDonations = await contract.get_donations({ + from_index: 0, + limit: 10 +}); + +// Get donation statistics for dashboard +const stats = await contract.get_donation_stats(); + +// Search functionality +const searchResults = await contract.search_donations_by_message({ + search_term: userSearchTerm, + limit: 20 +}); + +// User profile page +const userDetails = await contract.get_donor_details({ + donor: currentUser.accountId +}); +``` + +## Error Handling and Edge Cases + +Our query methods handle various edge cases: + +- **Empty results**: Return empty arrays/null when no data matches +- **Invalid pagination**: Clamp indices to valid ranges +- **Gas limits**: Limit result sizes to prevent gas issues +- **Invalid timestamps**: Handle malformed date ranges gracefully + +## Performance Monitoring + +Monitor your contract's performance with these built-in metrics: + +```bash +# Check storage usage +near view $CONTRACT_ID get_contract_metadata + +# Monitor gas consumption for different query sizes +near view $CONTRACT_ID get_donations '{"limit": 50}' --gas 300000000000000 +``` + +:::tip Optimization Tips + +1. **Use pagination** for large result sets +2. **Limit search terms** to prevent excessive computation +3. **Cache frequently accessed data** in your frontend +4. **Monitor storage costs** and implement cleanup if needed + +::: + +:::info Next Steps + +Your donation contract now has comprehensive query and analytics capabilities! In the next section, we'll deploy the contract to NEAR testnet and create comprehensive tests to ensure everything works correctly. + +::: + +## Summary + +We've successfully implemented: + +- **Time-based analytics** for donation tracking over periods +- **Advanced search capabilities** including message search and donor filtering +- **Administrative functions** for contract management +- **Comprehensive error handling** and edge case management +- **Performance optimizations** with pagination and gas limits +- **Detailed testing** to ensure reliability + +Your donation contract is now feature-complete and ready for deployment! \ No newline at end of file diff --git a/docs/tutorials/donation/5-deploy.md b/docs/tutorials/donation/5-deploy.md new file mode 100644 index 00000000000..88538f5cfa8 --- /dev/null +++ b/docs/tutorials/donation/5-deploy.md @@ -0,0 +1,743 @@ +--- +id: deploy +title: Deploying and Testing Your Donation Contract +sidebar_label: Deploy & Test +description: "Learn how to deploy your donation smart contract to NEAR testnet and create comprehensive integration tests." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from '@site/src/components/codetabs'; + +Now that our donation contract is feature-complete, let's deploy it to NEAR testnet and create comprehensive tests to ensure everything works correctly in a real blockchain environment. + +## Pre-Deployment Checklist + +Before deploying, let's ensure our contract is ready for production: + +### Build and Optimize + + + + +```bash +# Clean and build with optimizations +cargo clean +cargo build --target wasm32-unknown-unknown --release + +# Verify the build succeeded and check file size +ls -la target/wasm32-unknown-unknown/release/donation_contract.wasm + +# Optional: Use wasm-opt for further optimization +wasm-opt -Oz target/wasm32-unknown-unknown/release/donation_contract.wasm \ + -o target/wasm32-unknown-unknown/release/donation_contract_optimized.wasm +``` + + + + +```bash +cd contract-ts + +# Install dependencies and build +npm install +npm run build + +# Verify build output +ls -la build/contract.wasm +``` + + + + +### Run Local Tests + +Ensure all tests pass before deployment: + + + + +```bash +# Run all unit tests +cargo test + +# Run tests with detailed output +cargo test -- --nocapture +``` + + + + +```bash +# Run JavaScript/TypeScript tests (if configured) +npm test +``` + + + + +## Contract Deployment + +### Deploy to Testnet + + + + +```bash +# Deploy the contract +near deploy donation-dev.testnet target/wasm32-unknown-unknown/release/donation_contract.wasm + +# Initialize the contract +near call donation-dev.testnet new \ + '{"beneficiary": "donation-beneficiary.testnet"}' \ + --accountId donation-dev.testnet +``` + + + + +```bash +# Deploy the contract +near contract deploy donation-dev.testnet \ + use-file target/wasm32-unknown-unknown/release/donation_contract.wasm \ + without-init-call network-config testnet \ + sign-with-keychain send + +# Initialize the contract +near contract call-function as-transaction donation-dev.testnet new \ + json-args '{"beneficiary": "donation-beneficiary.testnet"}' \ + prepaid-gas '30.0 Tgas' attached-deposit '0 NEAR' \ + sign-as donation-dev.testnet network-config testnet \ + sign-with-keychain send +``` + + + + +### Verify Deployment + +```bash +# Check contract state +near view donation-dev.testnet get_beneficiary + +# Check contract metadata +near view donation-dev.testnet get_contract_metadata + +# Verify initialization +near view donation-dev.testnet get_donation_stats +``` + +## Integration Testing + +Let's create comprehensive integration tests that interact with the deployed contract. + +### Create Test Scripts + + + + +Create `tests/integration.rs`: + +```rust +use near_workspaces::{Account, Contract, Worker}; +use serde_json::json; + +#[tokio::test] +async fn test_donation_workflow() -> anyhow::Result<()> { + let worker = near_workspaces::sandbox().await?; + + // Create accounts + let beneficiary = worker.dev_create_account().await?; + let donor1 = worker.dev_create_account().await?; + let donor2 = worker.dev_create_account().await?; + + // Deploy and initialize contract + let contract = worker + .dev_deploy(&std::fs::read("target/wasm32-unknown-unknown/release/donation_contract.wasm")?) + .await?; + + contract + .call("new") + .args_json(json!({ + "beneficiary": beneficiary.id() + })) + .transact() + .await? + .into_result()?; + + // Test initial state + let beneficiary_result: String = contract + .view("get_beneficiary") + .await? + .json()?; + assert_eq!(beneficiary_result, beneficiary.id().to_string()); + + // Test donation without message + let initial_beneficiary_balance = beneficiary.view_account().await?.balance; + + let donation_result = donor1 + .call(contract.id(), "donate") + .args_json(json!({})) + .deposit(near_workspaces::types::NearToken::from_near(1)) + .transact() + .await?; + + assert!(donation_result.is_success()); + + // Verify beneficiary received funds + let final_beneficiary_balance = beneficiary.view_account().await?.balance; + assert!(final_beneficiary_balance > initial_beneficiary_balance); + + // Test donation with message + donor2 + .call(contract.id(), "donate") + .args_json(json!({ + "message": "Keep up the great work!" + })) + .deposit(near_workspaces::types::NearToken::from_near(2)) + .transact() + .await? + .into_result()?; + + // Verify donation tracking + let stats: serde_json::Value = contract + .view("get_donation_stats") + .await? + .json()?; + + assert_eq!(stats["total_donors"], 2); + assert_eq!(stats["total_donations"], "3000000000000000000000000"); // 3 NEAR + + // Test donor-specific queries + let donor1_donations: Vec = contract + .view("get_donations_by_donor") + .args_json(json!({ + "donor": donor1.id(), + "from_index": 0, + "limit": 10 + })) + .await? + .json()?; + + assert_eq!(donor1_donations.len(), 1); + + // Test message search + let message_results: Vec = contract + .view("search_donations_by_message") + .args_json(json!({ + "search_term": "great work", + "limit": 10 + })) + .await? + .json()?; + + assert_eq!(message_results.len(), 1); + assert_eq!(message_results[0]["donor"], donor2.id().to_string()); + + Ok(()) +} + +#[tokio::test] +async fn test_administrative_functions() -> anyhow::Result<()> { + let worker = near_workspaces::sandbox().await?; + + let beneficiary = worker.dev_create_account().await?; + let new_beneficiary = worker.dev_create_account().await?; + let unauthorized_user = worker.dev_create_account().await?; + + let contract = worker + .dev_deploy(&std::fs::read("target/wasm32-unknown-unknown/release/donation_contract.wasm")?) + .await?; + + contract + .call("new") + .args_json(json!({ + "beneficiary": beneficiary.id() + })) + .transact() + .await? + .into_result()?; + + // Test successful beneficiary change + let change_result = beneficiary + .call(contract.id(), "change_beneficiary") + .args_json(json!({ + "new_beneficiary": new_beneficiary.id() + })) + .transact() + .await?; + + assert!(change_result.is_success()); + + // Verify beneficiary changed + let current_beneficiary: String = contract + .view("get_beneficiary") + .await? + .json()?; + assert_eq!(current_beneficiary, new_beneficiary.id().to_string()); + + // Test unauthorized beneficiary change (should fail) + let unauthorized_result = unauthorized_user + .call(contract.id(), "change_beneficiary") + .args_json(json!({ + "new_beneficiary": unauthorized_user.id() + })) + .transact() + .await?; + + assert!(unauthorized_result.is_failure()); + + Ok(()) +} + +#[tokio::test] +async fn test_edge_cases() -> anyhow::Result<()> { + let worker = near_workspaces::sandbox().await?; + + let beneficiary = worker.dev_create_account().await?; + let donor = worker.dev_create_account().await?; + + let contract = worker + .dev_deploy(&std::fs::read("target/wasm32-unknown-unknown/release/donation_contract.wasm")?) + .await?; + + contract + .call("new") + .args_json(json!({ + "beneficiary": beneficiary.id() + })) + .transact() + .await? + .into_result()?; + + // Test zero donation (should fail) + let zero_donation_result = donor + .call(contract.id(), "donate") + .args_json(json!({})) + .deposit(near_workspaces::types::NearToken::from_yoctonear(0)) + .transact() + .await?; + + assert!(zero_donation_result.is_failure()); + + // Test queries with no data + let empty_donations: Vec = contract + .view("get_donations") + .args_json(json!({ + "from_index": 0, + "limit": 10 + })) + .await? + .json()?; + + assert_eq!(empty_donations.len(), 0); + + // Test non-existent donor details + let non_existent: Option = contract + .view("get_donor_details") + .args_json(json!({ + "donor": "non-existent.testnet" + })) + .await? + .json()?; + + assert!(non_existent.is_none()); + + Ok(()) +} +``` + +Add to `Cargo.toml`: + +```toml +[dev-dependencies] +near-workspaces = "0.9" +tokio = { version = "1.0", features = ["full"] } +anyhow = "1.0" +serde_json = "1.0" +``` + + + + +Create `contract-ts/sandbox-test/main.ava.ts`: + +```typescript +import { Worker, NearAccount } from 'near-workspaces'; +import anyTest, { TestFn } from 'ava'; + +const test = anyTest as TestFn<{ + worker: Worker; + accounts: Record; +}>; + +test.beforeEach(async (t) => { + const worker = await Worker.init(); + const root = worker.rootAccount; + + const contract = await root.devDeploy('./build/contract.wasm'); + const beneficiary = await root.createSubAccount('beneficiary'); + const donor1 = await root.createSubAccount('donor1'); + const donor2 = await root.createSubAccount('donor2'); + + // Initialize contract + await contract.call(contract, 'init', { + beneficiary: beneficiary.accountId, + }); + + t.context.worker = worker; + t.context.accounts = { contract, beneficiary, donor1, donor2 }; +}); + +test.afterEach.always(async (t) => { + await t.context.worker.tearDown(); +}); + +test('donation workflow', async (t) => { + const { contract, beneficiary, donor1, donor2 } = t.context.accounts; + + // Test initial state + const initialBeneficiary = await contract.view('get_beneficiary'); + t.is(initialBeneficiary, beneficiary.accountId); + + // Make a donation + await donor1.call( + contract, + 'donate', + {}, + { attachedDeposit: '1000000000000000000000000' } // 1 NEAR + ); + + // Verify donation tracking + const stats = await contract.view('get_donation_stats'); + t.is(stats.total_donors, 1); + t.is(stats.total_donations, '1000000000000000000000000'); + + // Make donation with message + await donor2.call( + contract, + 'donate', + { message: 'Keep up the great work!' }, + { attachedDeposit: '2000000000000000000000000' } // 2 NEAR + ); + + // Verify updated stats + const updatedStats = await contract.view('get_donation_stats'); + t.is(updatedStats.total_donors, 2); + t.is(updatedStats.total_donations, '3000000000000000000000000'); + + // Test search functionality + const searchResults = await contract.view('search_donations_by_message', { + search_term: 'great work', + limit: 10, + }); + t.is(searchResults.length, 1); + t.is(searchResults[0].donor, donor2.accountId); +}); + +test('administrative functions', async (t) => { + const { contract, beneficiary } = t.context.accounts; + const newBeneficiary = await t.context.worker.rootAccount.createSubAccount('newbeneficiary'); + + // Change beneficiary (should succeed) + await beneficiary.call(contract, 'change_beneficiary', { + new_beneficiary: newBeneficiary.accountId, + }); + + // Verify change + const currentBeneficiary = await contract.view('get_beneficiary'); + t.is(currentBeneficiary, newBeneficiary.accountId); + + // Test contract metadata + const metadata = await contract.view('get_contract_metadata'); + t.is(metadata.beneficiary, newBeneficiary.accountId); + t.is(metadata.version, '1.0.0'); +}); + +test('edge cases and error handling', async (t) => { + const { contract, donor1 } = t.context.accounts; + + // Test zero donation (should fail) + const zeroResult = await t.throwsAsync(async () => { + await donor1.call( + contract, + 'donate', + {}, + { attachedDeposit: '0' } + ); + }); + + // Test queries with no data + const emptyDonations = await contract.view('get_donations', { + from_index: 0, + limit: 10, + }); + t.is(emptyDonations.length, 0); + + // Test non-existent donor + const nonExistentDonor = await contract.view('get_donor_details', { + donor: 'non-existent.testnet', + }); + t.is(nonExistentDonor, null); +}); +``` + + + + +### Run Integration Tests + + + + +```bash +# Run integration tests +cargo test --test integration + +# Run specific test +cargo test --test integration test_donation_workflow + +# Run with output +cargo test --test integration -- --nocapture +``` + + + + +```bash +cd contract-ts + +# Install test dependencies +npm install --save-dev ava near-workspaces + +# Add test script to package.json +npm run test + +# Or run directly +npx ava sandbox-test/main.ava.ts +``` + + + + +## Manual Testing on Testnet + +Let's manually test our deployed contract to ensure it works correctly: + +### Basic Functionality Tests + +```bash +export CONTRACT_ID="donation-dev.testnet" +export BENEFICIARY="donation-beneficiary.testnet" + +# Test 1: Make a donation +near call $CONTRACT_ID donate '{}' \ + --deposit 0.5 \ + --accountId your-test-account.testnet + +# Test 2: Make a donation with message +near call $CONTRACT_ID donate '{"message": "Great project!"}' \ + --deposit 1.0 \ + --accountId your-test-account.testnet + +# Test 3: Check donation stats +near view $CONTRACT_ID get_donation_stats + +# Test 4: Get recent donations +near view $CONTRACT_ID get_donations '{"from_index": 0, "limit": 5}' + +# Test 5: Get donor details +near view $CONTRACT_ID get_donor_details \ + '{"donor": "your-test-account.testnet"}' +``` + +### Advanced Feature Tests + +```bash +# Test search functionality +near view $CONTRACT_ID search_donations_by_message \ + '{"search_term": "project", "limit": 10}' + +# Test top donors +near view $CONTRACT_ID get_top_donors '{"limit": 3}' + +# Test date range queries (last 24 hours) +YESTERDAY=$(date -d 'yesterday' +%s)000000000 +NOW=$(date +%s)000000000 + +near view $CONTRACT_ID get_donation_analytics \ + "{\"date_range\": {\"start_timestamp\": \"$YESTERDAY\", \"end_timestamp\": \"$NOW\"}}" +``` + +### Administrative Tests + +```bash +# Test beneficiary change (only beneficiary can do this) +near call $CONTRACT_ID change_beneficiary \ + '{"new_beneficiary": "new-beneficiary.testnet"}' \ + --accountId $BENEFICIARY + +# Test unauthorized access (should fail) +near call $CONTRACT_ID change_beneficiary \ + '{"new_beneficiary": "hacker.testnet"}' \ + --accountId your-test-account.testnet +``` + +## Performance Testing + +Test your contract's performance under various conditions: + +### Load Testing + +```bash +# Script to make multiple donations +for i in {1..10}; do + near call $CONTRACT_ID donate \ + "{\"message\": \"Donation #$i\"}" \ + --deposit 0.1 \ + --accountId donor-$i.testnet & +done +wait + +# Check if all donations were recorded +near view $CONTRACT_ID get_donation_stats +``` + +### Gas Usage Analysis + +```bash +# Test gas consumption for different operations +near call $CONTRACT_ID donate '{}' \ + --deposit 1.0 \ + --gas 300000000000000 \ + --accountId test-account.testnet + +# Check gas usage for large queries +near view $CONTRACT_ID get_donations \ + '{"from_index": 0, "limit": 50}' \ + --gas 300000000000000 +``` + +## Monitoring and Maintenance + +### Contract Health Checks + +Create a monitoring script: + +```bash +#!/bin/bash +# monitor-contract.sh + +CONTRACT_ID="your-donation-contract.testnet" + +echo "=== Contract Health Check ===" +echo "Timestamp: $(date)" + +# Check contract metadata +echo "Contract Metadata:" +near view $CONTRACT_ID get_contract_metadata + +# Check donation stats +echo -e "\nDonation Statistics:" +near view $CONTRACT_ID get_donation_stats + +# Check recent activity +echo -e "\nRecent Donations:" +near view $CONTRACT_ID get_donations '{"from_index": 0, "limit": 3}' + +echo "=== Health Check Complete ===" +``` + +### Storage Monitoring + +```bash +# Monitor storage usage +METADATA=$(near view $CONTRACT_ID get_contract_metadata) +echo "Storage used: $(echo $METADATA | jq -r '.storage_used')" +echo "Contract balance: $(echo $METADATA | jq -r '.contract_balance')" +``` + +## Troubleshooting Common Issues + +### Deployment Issues + +**Problem**: Contract deployment fails +```bash +# Check account balance +near state donation-dev.testnet + +# Ensure sufficient storage deposit +near call donation-dev.testnet storage_deposit \ + '{}' --deposit 0.1 --accountId donation-dev.testnet +``` + +**Problem**: Contract initialization fails +```bash +# Check if contract is already initialized +near view donation-dev.testnet get_beneficiary + +# If needed, redeploy without initialization +near deploy donation-dev.testnet contract.wasm +``` + +### Runtime Issues + +**Problem**: Donations fail with gas errors +```bash +# Increase gas limit +near call $CONTRACT_ID donate '{}' \ + --deposit 1.0 \ + --gas 300000000000000 \ + --accountId donor.testnet +``` + +**Problem**: Query methods return empty results +```bash +# Check if contract has data +near view $CONTRACT_ID number_of_donors +near view $CONTRACT_ID total_donation_events +``` + +## Production Checklist + +Before deploying to mainnet: + +- [ ] All unit tests pass +- [ ] Integration tests pass on testnet +- [ ] Manual testing completed successfully +- [ ] Gas usage analyzed and optimized +- [ ] Administrative functions tested +- [ ] Edge cases handled properly +- [ ] Storage costs calculated +- [ ] Monitoring setup configured +- [ ] Backup and recovery plan established +- [ ] Security audit completed (recommended) + +:::tip Production Deployment + +For mainnet deployment, follow the same process but use: +- `--networkId mainnet` +- Real NEAR tokens for testing (start small!) +- A mainnet account with sufficient balance +- Consider using a more descriptive contract account name + +Example mainnet deployment: +```bash +near deploy my-charity-donation.near contract.wasm --networkId mainnet +``` + +::: + +## Deployment Summary + +Congratulations! You've successfully: + +- **Built and optimized** your donation smart contract +- **Deployed to testnet** with proper initialization +- **Created comprehensive tests** including unit, integration, and manual testing +- **Implemented monitoring** and health checks +- **Tested administrative functions** and security measures +- **Analyzed performance** and gas usage +- **Prepared for production** deployment + +Your donation contract is now ready for real-world use! The next and final step is building a user-friendly frontend interface. \ No newline at end of file diff --git a/docs/tutorials/donation/6-frontend.md b/docs/tutorials/donation/6-frontend.md new file mode 100644 index 00000000000..5dec29562e0 --- /dev/null +++ b/docs/tutorials/donation/6-frontend.md @@ -0,0 +1,1230 @@ +--- +id: frontend +title: Building a Frontend Interface +sidebar_label: Frontend Interface +description: "Create a modern, responsive web interface for your donation smart contract using React and NEAR Wallet integration." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import {Github} from '@site/src/components/codetabs'; + +Now let's create a beautiful, functional frontend that allows users to interact with our donation smart contract. We'll build a modern React application with NEAR Wallet integration, real-time updates, and comprehensive donation analytics. + +## Project Setup + +Let's create a Next.js frontend with NEAR integration: + +```bash +# Navigate to your project root +cd near-donation-tutorial + +# Create frontend using create-near-app +npx create-near-app@latest frontend --frontend=next --contract=rust + +# Or create manually with Next.js +npx create-next-app@latest frontend --typescript --tailwind --eslint + +cd frontend +npm install near-api-js @near-wallet-selector/core @near-wallet-selector/my-near-wallet @near-wallet-selector/modal-ui +``` + +## Project Structure + +```bash +frontend/ +├── components/ +│ ├── DonationForm.tsx +│ ├── DonationStats.tsx +│ ├── DonationTable.tsx +│ ├── TopDonors.tsx +│ └── WalletConnection.tsx +├── hooks/ +│ ├── useContract.ts +│ └── useWallet.ts +├── pages/ +│ ├── index.tsx +│ └── _app.tsx +├── utils/ +│ ├── near.ts +│ └── contract.ts +└── styles/ + └── globals.css +``` + +## NEAR Wallet Integration + +First, let's set up NEAR Wallet connection and contract interaction: + + + +```typescript +// utils/near.ts +import { connect, Contract, keyStores, WalletConnection } from 'near-api-js'; +import { setupWalletSelector } from '@near-wallet-selector/core'; +import { setupMyNearWallet } from '@near-wallet-selector/my-near-wallet'; +import { setupModal } from '@near-wallet-selector/modal-ui'; + +const CONTRACT_ID = process.env.NEXT_PUBLIC_CONTRACT_NAME || 'donation-dev.testnet'; +const NETWORK_ID = process.env.NEXT_PUBLIC_NETWORK_ID || 'testnet'; + +export const nearConfig = { + networkId: NETWORK_ID, + keyStore: new keyStores.BrowserLocalStorageKeyStore(), + nodeUrl: NETWORK_ID === 'testnet' + ? 'https://rpc.testnet.near.org' + : 'https://rpc.mainnet.near.org', + walletUrl: NETWORK_ID === 'testnet' + ? 'https://testnet.mynearwallet.com/' + : 'https://app.mynearwallet.com/', + helperUrl: NETWORK_ID === 'testnet' + ? 'https://helper.testnet.near.org' + : 'https://helper.mainnet.near.org', + explorerUrl: NETWORK_ID === 'testnet' + ? 'https://testnet.nearblocks.io' + : 'https://nearblocks.io', +}; + +export class Near { + walletConnection: WalletConnection; + wallet: any; + contract: any; + + constructor() { + this.walletConnection = new WalletConnection(connect(nearConfig) as any, null); + } + + async initWallet() { + const selector = await setupWalletSelector({ + network: NETWORK_ID, + modules: [setupMyNearWallet()], + }); + + this.wallet = await setupModal(selector, { + contractId: CONTRACT_ID, + }); + + return this.wallet; + } + + isSignedIn() { + return this.walletConnection.isSignedIn(); + } + + getAccountId() { + return this.walletConnection.getAccountId(); + } + + async signIn() { + const modal = await this.initWallet(); + modal.show(); + } + + async signOut() { + if (this.wallet) { + const wallet = await this.wallet.wallet(); + wallet.signOut(); + } + this.walletConnection.signOut(); + window.location.reload(); + } + + async initContract() { + const near = await connect(nearConfig); + const account = await near.account(this.getAccountId()); + + this.contract = new Contract(account, CONTRACT_ID, { + viewMethods: [ + 'get_beneficiary', + 'get_donation_stats', + 'get_donations', + 'get_top_donors', + 'get_donation_for_account', + 'search_donations_by_message', + 'get_contract_metadata' + ], + changeMethods: ['donate', 'change_beneficiary'], + }); + + return this.contract; + } +} + +export const near = new Near(); +``` + +## Contract Interface Hook + +Create a custom hook for contract interactions: + +```typescript +// hooks/useContract.ts +import { useEffect, useState } from 'react'; +import { near } from '../utils/near'; + +export interface DonationStats { + total_donations: string; + total_donors: number; + largest_donation: string; + latest_donation?: any; +} + +export interface Donation { + donor: string; + amount: string; + timestamp: string; + message?: string; +} + +export function useContract() { + const [contract, setContract] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function initContract() { + if (near.isSignedIn()) { + try { + const contractInstance = await near.initContract(); + setContract(contractInstance); + } catch (error) { + console.error('Failed to initialize contract:', error); + } + } + setLoading(false); + } + + initContract(); + }, []); + + const donate = async (amount: string, message?: string) => { + if (!contract) throw new Error('Contract not initialized'); + + const args = message ? { message } : {}; + + return await contract.donate( + args, + '300000000000000', // gas + amount // attached deposit in yoctoNEAR + ); + }; + + const getDonationStats = async (): Promise => { + if (!contract) throw new Error('Contract not initialized'); + return await contract.get_donation_stats(); + }; + + const getRecentDonations = async (limit = 10): Promise => { + if (!contract) throw new Error('Contract not initialized'); + return await contract.get_donations({ + from_index: 0, + limit + }); + }; + + const getTopDonors = async (limit = 5) => { + if (!contract) throw new Error('Contract not initialized'); + return await contract.get_top_donors({ limit }); + }; + + const searchDonations = async (searchTerm: string, limit = 20) => { + if (!contract) throw new Error('Contract not initialized'); + return await contract.search_donations_by_message({ + search_term: searchTerm, + limit + }); + }; + + const getDonorDetails = async (donor: string) => { + if (!contract) throw new Error('Contract not initialized'); + return await contract.get_donation_for_account({ account_id: donor }); + }; + + return { + contract, + loading, + donate, + getDonationStats, + getRecentDonations, + getTopDonors, + searchDonations, + getDonorDetails, + }; +} +``` + +## Donation Form Component + +Create an intuitive donation form with amount presets and message support: + + + +```typescript +// components/DonationForm.tsx +import { useState } from 'react'; +import { utils } from 'near-api-js'; +import { useContract } from '../hooks/useContract'; + +interface DonationFormProps { + onDonationSuccess: () => void; +} + +export default function DonationForm({ onDonationSuccess }: DonationFormProps) { + const [amount, setAmount] = useState(''); + const [message, setMessage] = useState(''); + const [loading, setLoading] = useState(false); + const { donate } = useContract(); + + const presetAmounts = ['0.1', '0.5', '1', '5', '10']; + + const handleDonate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!amount || parseFloat(amount) <= 0) { + alert('Please enter a valid donation amount'); + return; + } + + setLoading(true); + try { + const amountInYocto = utils.format.parseNearAmount(amount); + if (!amountInYocto) throw new Error('Invalid amount'); + + await donate(amountInYocto, message || undefined); + + // Success - reset form + setAmount(''); + setMessage(''); + onDonationSuccess(); + + } catch (error: any) { + console.error('Donation failed:', error); + if (error.message?.includes('User rejected')) { + alert('Transaction was cancelled'); + } else { + alert(`Donation failed: ${error.message}`); + } + } finally { + setLoading(false); + } + }; + + return ( +
+

Make a Donation

+ +
+ {/* Amount Selection */} +
+ + + {/* Preset Amounts */} +
+ {presetAmounts.map((preset) => ( + + ))} +
+ + {/* Custom Amount Input */} + setAmount(e.target.value)} + placeholder="Enter custom amount" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
+ + {/* Optional Message */} +
+ +