From 627552b611764feadfefe22be68bea0c996d2bc8 Mon Sep 17 00:00:00 2001 From: savagechucks Date: Wed, 29 Apr 2026 23:14:20 +0100 Subject: [PATCH 1/4] feat: Submission of assignment --- cairo_program/src/integer.cairo | 14 ++++++++++---- cairo_program/src/lib.cairo | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cairo_program/src/integer.cairo b/cairo_program/src/integer.cairo index 9501eca..3fa56a4 100644 --- a/cairo_program/src/integer.cairo +++ b/cairo_program/src/integer.cairo @@ -1,8 +1,8 @@ #[executable] fn main() { - let result: u8 = add_num(5, 6); - println!("the sum of x & y is: {}", result); - assert(result == 11, 'invalid sum logic'); + let add_result: u8 = add_num(5, 6); + println!("the sum of x & y is: {}", add_result); + assert(add_result == 11, 'invalid sum logic'); let sub_result: u8 = sub_num(10, 5); println!("sub result is: {}", sub_result); @@ -11,10 +11,16 @@ fn main() { // addition logic fn add_num(x: u8, y: u8) -> u8 { - x + y + return x + y; } // subtraction logic fn sub_num(x: u8, y: u8) -> u8 { + // logic for returning negatgive value + if x > y{ + return + } return x - y; } + + diff --git a/cairo_program/src/lib.cairo b/cairo_program/src/lib.cairo index acc7644..b1c3620 100644 --- a/cairo_program/src/lib.cairo +++ b/cairo_program/src/lib.cairo @@ -1,5 +1,5 @@ // mod hello_world; // mod short_string; -// mod integer; +mod integer; // mod bool; -mod bytearray; \ No newline at end of file +// mod bytearray; \ No newline at end of file From bdbe090db87b0242cdbaa5ab1df6f0bda799dfc6 Mon Sep 17 00:00:00 2001 From: savagechucks Date: Mon, 18 May 2026 10:52:29 +0100 Subject: [PATCH 2/4] feat: Isaac Submission of Assignment --- cairo_program/src/bool.cairo | 1 - cairo_program/src/integer.cairo | 37 +++++++++++++++++++++---- starknet_contracts/Scarb.toml | 5 ++++ starknet_contracts/src/arithmetic.cairo | 28 +++++++++++++++++++ starknet_contracts/src/lib.cairo | 33 ++++++++++++++++++++-- 5 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 starknet_contracts/src/arithmetic.cairo diff --git a/cairo_program/src/bool.cairo b/cairo_program/src/bool.cairo index cafad1e..e8d4f47 100644 --- a/cairo_program/src/bool.cairo +++ b/cairo_program/src/bool.cairo @@ -18,7 +18,6 @@ fn is_adult(x: u8) -> bool { return true; } - // determine even numbers fn is_even(x: u8) -> bool { if x % 2 == 0 { diff --git a/cairo_program/src/integer.cairo b/cairo_program/src/integer.cairo index 3fa56a4..36555f2 100644 --- a/cairo_program/src/integer.cairo +++ b/cairo_program/src/integer.cairo @@ -1,3 +1,5 @@ +use core::num::traits::OverflowingMul; + #[executable] fn main() { let add_result: u8 = add_num(5, 6); @@ -7,6 +9,20 @@ fn main() { let sub_result: u8 = sub_num(10, 5); println!("sub result is: {}", sub_result); assert(sub_result == 5, 'invalid sub logic'); + + // Test subtraction that should panic (10 - 15 = negative) + // sub_num(10, 15); // This would panic + + let mul_result: u8 = mul_num(255, 1); + println!("mul result is: {}", mul_result); + assert(mul_result == 255, 'invalid mul logic'); + + let div_result: u8 = div_num(20, 0); + println!("div result is: {}", div_result); + assert(div_result == 5, 'invalid div logic'); + + // Test division by zero - should panic + // div_num(10, 0); // This would panic } // addition logic @@ -16,11 +32,22 @@ fn add_num(x: u8, y: u8) -> u8 { // subtraction logic fn sub_num(x: u8, y: u8) -> u8 { - // logic for returning negatgive value - if x > y{ - return - } - return x - y; + assert!(!(y > x), "negative result not allowed"); + x - y } +// multiplication logic +fn mul_num(x: u8, y: u8) -> u8 { + let (result_, overflowed_) = x.overflowing_mul(y); + + assert!(!(overflowed_), "multiplication overflowed"); + + result_ +} + +// division logic with division by zero check +fn div_num(x: u8, y: u8) -> u8 { + assert!(!(y == 0), "Cannot be Zero"); + x / y +} diff --git a/starknet_contracts/Scarb.toml b/starknet_contracts/Scarb.toml index 29cf20f..b24c100 100644 --- a/starknet_contracts/Scarb.toml +++ b/starknet_contracts/Scarb.toml @@ -5,8 +5,13 @@ edition = "2024_07" # See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + [dependencies] starknet = "2.18.0" +cairo_execute = "2.18.0" + +[cairo] +enable-gas = true [dev-dependencies] snforge_std = "0.56.0" diff --git a/starknet_contracts/src/arithmetic.cairo b/starknet_contracts/src/arithmetic.cairo new file mode 100644 index 0000000..939782b --- /dev/null +++ b/starknet_contracts/src/arithmetic.cairo @@ -0,0 +1,28 @@ +use core::num::traits::OverflowingMul; + +// addition logic +pub fn add_num(x: u32, y: u32) -> u32 { + return x + y; +} + +// subtraction logic +fn sub_num(x: u8, y: u8) -> u8 { + assert!(!(y > x), "negative result not allowed"); + x - y +} + +// multiplication logic +fn mul_num(x: u8, y: u8) -> u8 { + let (result_, overflowed_) = x.overflowing_mul(y); + + assert!(!(overflowed_), "multiplication overflowed"); + + result_ +} + +// division logic with division by zero check +fn div_num(x: u8, y: u8) -> u8 { + assert!(!(y == 0), "Cannot be Zero"); + x / y +} + diff --git a/starknet_contracts/src/lib.cairo b/starknet_contracts/src/lib.cairo index f1b82bb..02257ad 100644 --- a/starknet_contracts/src/lib.cairo +++ b/starknet_contracts/src/lib.cairo @@ -1,5 +1,7 @@ /// Interface representing `HelloContract`. /// This interface allows modification and retrieval of the contract's storage count. + +mod arithmetic; #[starknet::interface] pub trait ICounter { /// Increase count. @@ -10,21 +12,48 @@ pub trait ICounter { /// Simple contract for managing count. #[starknet::contract] -mod Counter { +pub mod Counter { use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use starknet::{ContractAddress, get_caller_address}; + use super::arithmetic::{add_num}; #[storage] struct Storage { count: u32, + owner: ContractAddress, + } + + #[constructor] + fn constructor(ref self: ContractState, owner: ContractAddress) { + self.owner.write(owner); // no .into() needed } + } + + #[generate_trait] + impl PrivateImpl of PrivateTrait { + fn assert_only_owner(self: @ContractState ) { + assert(get_caller_address() == self.owner.read(), 'Caller not the Owner'); + } } + + #[abi(embed_v0)] impl CounterImpl of super::ICounter { fn increase_count(ref self: ContractState, amount: u32) { + + // Only owner + self.assert_only_owner(); assert(amount != 0, 'Amount cannot be 0'); - self.count.write(self.count.read() + amount); + + // Read current count and add the amount + let current_count = self.count.read(); + let new_count = add_num(current_count, amount); + self.count.write(new_count); + + assert(new_count == current_count + amount, 'Count increase failed'); } + fn get_count(self: @ContractState) -> u32 { self.count.read() } From 33b624e930a12dfe2637e5cee3b23245cdc7815c Mon Sep 17 00:00:00 2001 From: savagechucks Date: Thu, 21 May 2026 12:02:19 +0100 Subject: [PATCH 3/4] feat: submitting ERC20 contract and Autonomus agent --- .gitignore | 1 - .vscode/settings.json | 3 - README.md | 22 - cairo_program/.vscode/settings.json | 3 - .../examples/transfer-agent/.env.example | 27 + .../examples/transfer-agent/.gitignore | 3 + .../examples/transfer-agent/README.md | 142 +++ .../examples/transfer-agent/cli.ts | 193 ++++ .../examples/transfer-agent/package.json | 25 + .../examples/transfer-agent/run.ts | 181 ++++ .../examples/transfer-agent/src/config.ts | 92 ++ .../examples/transfer-agent/src/mcp.ts | 101 +++ .../examples/transfer-agent/src/policy.ts | 87 ++ .../examples/transfer-agent/src/pretty.ts | 199 ++++ .../examples/transfer-agent/src/types.ts | 48 + .../examples/transfer-agent/src/workflow.ts | 150 +++ .../transfer-agent/test/policy.test.ts | 64 ++ .../examples/transfer-agent/tsconfig.json | 12 + starknet_contracts/src/lib.cairo | 4 +- target/CACHEDIR.TAG | 3 - .../2.18.0_proc_macro.cache | Bin 15413 -> 0 bytes writing_test/.gitignore | 5 + writing_test/README.md | 851 ++++++++++++++++++ writing_test/Scarb.lock | 24 + writing_test/Scarb.toml | 52 ++ writing_test/TASK_EXPLANATION.md | 458 ++++++++++ writing_test/scripts/README.md | 48 + writing_test/scripts/admin_burn.sh | 13 + writing_test/scripts/admin_revoke.sh | 13 + writing_test/scripts/admin_unrevoke.sh | 13 + writing_test/scripts/admin_update_limit.sh | 13 + writing_test/scripts/approve.sh | 13 + writing_test/scripts/common.sh | 60 ++ writing_test/scripts/read_token.sh | 67 ++ writing_test/scripts/token.env.example | 13 + writing_test/scripts/transfer.sh | 13 + writing_test/scripts/transfer_from.sh | 13 + writing_test/snfoundry.toml | 11 + writing_test/src/lib.cairo | 241 +++++ writing_test/tests/test_contract.cairo | 47 + 40 files changed, 3294 insertions(+), 34 deletions(-) delete mode 100644 .gitignore delete mode 100644 .vscode/settings.json delete mode 100644 README.md delete mode 100644 cairo_program/.vscode/settings.json create mode 100644 starknet-agentic/examples/transfer-agent/.env.example create mode 100644 starknet-agentic/examples/transfer-agent/.gitignore create mode 100644 starknet-agentic/examples/transfer-agent/README.md create mode 100644 starknet-agentic/examples/transfer-agent/cli.ts create mode 100644 starknet-agentic/examples/transfer-agent/package.json create mode 100644 starknet-agentic/examples/transfer-agent/run.ts create mode 100644 starknet-agentic/examples/transfer-agent/src/config.ts create mode 100644 starknet-agentic/examples/transfer-agent/src/mcp.ts create mode 100644 starknet-agentic/examples/transfer-agent/src/policy.ts create mode 100644 starknet-agentic/examples/transfer-agent/src/pretty.ts create mode 100644 starknet-agentic/examples/transfer-agent/src/types.ts create mode 100644 starknet-agentic/examples/transfer-agent/src/workflow.ts create mode 100644 starknet-agentic/examples/transfer-agent/test/policy.test.ts create mode 100644 starknet-agentic/examples/transfer-agent/tsconfig.json delete mode 100644 target/CACHEDIR.TAG delete mode 100644 target/cairo-language-server/2.18.0_proc_macro.cache create mode 100644 writing_test/.gitignore create mode 100644 writing_test/README.md create mode 100644 writing_test/Scarb.lock create mode 100644 writing_test/Scarb.toml create mode 100644 writing_test/TASK_EXPLANATION.md create mode 100644 writing_test/scripts/README.md create mode 100755 writing_test/scripts/admin_burn.sh create mode 100755 writing_test/scripts/admin_revoke.sh create mode 100755 writing_test/scripts/admin_unrevoke.sh create mode 100755 writing_test/scripts/admin_update_limit.sh create mode 100755 writing_test/scripts/approve.sh create mode 100755 writing_test/scripts/common.sh create mode 100755 writing_test/scripts/read_token.sh create mode 100644 writing_test/scripts/token.env.example create mode 100755 writing_test/scripts/transfer.sh create mode 100755 writing_test/scripts/transfer_from.sh create mode 100644 writing_test/snfoundry.toml create mode 100644 writing_test/src/lib.cairo create mode 100644 writing_test/tests/test_contract.cairo diff --git a/.gitignore b/.gitignore deleted file mode 100644 index eb5a316..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index edc55ce..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "snyk.advanced.autoSelectOrganization": true -} \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index ece307b..0000000 --- a/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Cairo Bootcamp 6.0 - -Welcome to **Cairo Bootcamp 6** — a hands-on, developer-focused program designed to help you learn Cairo from first principles and build real-world applications. This repository contains learning materials, examples, and exercises used throughout the bootcamp. - ---- - -## About the Bootcamp - -This Cairo Bootcamp is designed to: -- Introduce developers to Cairo -- Teach provable computation fundamentals** -- Explore Starknet and smart contract development -- Encourage hands-on building and experimentation - - ---- - -## 📁 Repository Structure -. -├── cairo_programs/ # Pure Cairo programs (stateless logic) -├── starknet_contracts/ # Smart contracts (stateful logic) -└── README.md \ No newline at end of file diff --git a/cairo_program/.vscode/settings.json b/cairo_program/.vscode/settings.json deleted file mode 100644 index edc55ce..0000000 --- a/cairo_program/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "snyk.advanced.autoSelectOrganization": true -} \ No newline at end of file diff --git a/starknet-agentic/examples/transfer-agent/.env.example b/starknet-agentic/examples/transfer-agent/.env.example new file mode 100644 index 0000000..6b79197 --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/.env.example @@ -0,0 +1,27 @@ +# Starknet Sepolia credentials (required) +STARKNET_RPC_URL=https://starknet-sepolia.g.alchemy.com/v2/YOUR_KEY +STARKNET_ACCOUNT_ADDRESS=0x... +STARKNET_PRIVATE_KEY=0x... + +# Optional AVNU paymaster (gasless transfers on Sepolia) +# AVNU_BASE_URL=https://sepolia.api.avnu.fi +# AVNU_PAYMASTER_URL=https://sepolia.paymaster.avnu.fi +# AVNU_PAYMASTER_API_KEY= + +# Transfer policy +TRANSFER_TOKEN=STRK +MIN_BALANCE_TO_TRANSFER=50 +TRANSFER_AMOUNT=1 +RESERVE_AFTER_TRANSFER=10 +LOW_BALANCE_ALERT_THRESHOLD=20 +TRANSFER_RECIPIENT=0x... + +# Execution +RUN_MODE=dry-run +TRANSFER_GASFREE=0 +TRANSFER_AGENT_MCP_ENTRY=../../packages/starknet-mcp-server/dist/index.js +TRANSFER_AGENT_MCP_LABEL=transfer-agent +TRANSFER_AGENT_OUTPUT_DIR=./artifacts + +# Optional webhook stub for low-balance alerts +# ALERT_WEBHOOK_URL= diff --git a/starknet-agentic/examples/transfer-agent/.gitignore b/starknet-agentic/examples/transfer-agent/.gitignore new file mode 100644 index 0000000..45126cb --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/.gitignore @@ -0,0 +1,3 @@ +.env +artifacts/ +node_modules/ diff --git a/starknet-agentic/examples/transfer-agent/README.md b/starknet-agentic/examples/transfer-agent/README.md new file mode 100644 index 0000000..dd59e1e --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/README.md @@ -0,0 +1,142 @@ +# Transfer Agent (Test 2 — Sepolia) + +Composable autonomous transfer agent that orchestrates Starknet MCP tools in a multi-step pipeline. + +## Test 2 requirements mapping + +| Requirement | How this example demonstrates it | +|-------------|--------------------------------| +| Fetch wallet balance | `starknet_get_balance` in `fetchBalance()` | +| Validate transfer condition | `evaluateTransferPolicy()` — balance must be **strictly greater than** `MIN_BALANCE_TO_TRANSFER` and respect `RESERVE_AFTER_TRANSFER` | +| Transfer if balance > X | `starknet_transfer` with `dryRun: true` then on-chain when `RUN_MODE=execute` | +| Call another function | Post-step `starknet_call_contract` — `balance_of` on recipient | +| Log execution result | Structured JSON logs + `artifacts/transfer-agent-.json` | +| Low-balance alerts | `checkLowBalanceAlert()` — `WARN` log; optional `ALERT_WEBHOOK_URL` POST | + +## Prerequisites + +- Node.js 20+ +- Built MCP server at `packages/starknet-mcp-server/dist/index.js` +- Sepolia-funded account with STRK (USDC mainnet address fails on Sepolia — use STRK or ETH) + +```bash +# From repo root +pnpm install && pnpm build +``` + +## Setup + +```bash +cd examples/transfer-agent +cp .env.example .env +# Edit .env: STARKNET_RPC_URL (Sepolia), account, private key, TRANSFER_RECIPIENT +``` + +Default policy (with 100 STRK): + +- Transfer only if balance **> 50 STRK** +- Send **1 STRK** to recipient +- Keep **10 STRK** reserve after transfer +- Alert if balance **< 20 STRK** + +## Run + +```bash +# Unit tests (policy logic) +pnpm test + +# Dry-run: simulate transfer, no on-chain tx +pnpm run run + +# Human-readable terminal output (recommended for demos) +pnpm run:pretty +pnpm run:execute:pretty + +# Execute real Sepolia transfer (small amount!) +pnpm run:execute +``` + +### `--pretty` output + +Add `--pretty` (or `TRANSFER_AGENT_PRETTY=1`) for readable lines instead of JSON: + +```text +[INFO] Starting transfer-agent + Token: STRK | Mode: dry-run | Recipient: 0x... +[INFO] Balance: 200 STRK + Account: 0x... +[ALERT] LOW BALANCE ALERT # only when below threshold +[INFO] Policy: transfer allowed +[INFO] Transfer simulated (dry-run) +[INFO] Run completed + Artifact: ./artifacts/transfer-agent-....json +``` + +## CLI commands (interactive) + +One-shot commands via MCP (same `.env` as the agent): + +```bash +pnpm cli balance STRK --pretty +pnpm cli balances ETH STRK --pretty +pnpm cli transfer 1 STRK 0xRecipient... --pretty # simulate only +pnpm cli transfer 1 STRK 0xRecipient... --execute --pretty # real tx +pnpm cli call 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d balance_of 0xYourAccount... --pretty +pnpm cli workflow --pretty # full pipeline +pnpm cli help +``` + +## Execute mode failed with "Contract not found"? + +If `pnpm run:execute` or `pnpm cli transfer ... --execute` fails on `starknet_getClassAt` for your **account** address, your smart-contract wallet is likely **not deployed** on that network yet. Reads (balance) can work while writes cannot. + +**Fix:** deploy the account first, then retry: + +1. Use a wallet that is already deployed on Sepolia (ArgentX / Braavos with a funded account), and put that address + private key in `.env`, **or** +2. Deploy a new agent account: [examples/onboard-agent/README.md](../onboard-agent/README.md) — then update `.env` with the new `STARKNET_ACCOUNT_ADDRESS` and `STARKNET_PRIVATE_KEY` from `onboarding_secrets.json`. + +Verify deployment: + +```bash +pnpm cli call 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d balance_of $STARKNET_ACCOUNT_ADDRESS +``` + +## Demo low-balance alert + +Temporarily raise the alert threshold above your balance: + +```bash +LOW_BALANCE_ALERT_THRESHOLD=200 pnpm run +``` + +Expect a `WARN` log with `reasonCode: "ALERT_LOW_BALANCE"`. + +## Demo policy block (skip transfer) + +Lower the minimum so balance is not above threshold, or raise `MIN_BALANCE_TO_TRANSFER` above your balance: + +```bash +MIN_BALANCE_TO_TRANSFER=200 pnpm run +``` + +Artifact will show `transfer.skipped: true` and `reasonCode: "BLOCK_BALANCE_NOT_ABOVE_THRESHOLD"`. + +## Artifact output + +Each run writes `artifacts/transfer-agent-.json` containing: + +- `balanceBefore` — MCP balance response +- `lowBalanceAlert` — alert evaluation +- `transferPolicy` — allow/block decision +- `transfer` — simulation or execution result +- `followUp` — recipient `balance_of` read + +## Network note + +Use a **Sepolia** RPC URL. MCP token symbols resolve to canonical addresses shared by mainnet/Sepolia for ETH and STRK. USDC at the mainnet address does not exist on Sepolia. + +## Security + +- Never commit `.env` or private keys +- Start with `RUN_MODE=dry-run` and small `TRANSFER_AMOUNT` (e.g. `0.001`) +- Use a recipient address you control diff --git a/starknet-agentic/examples/transfer-agent/cli.ts b/starknet-agentic/examples/transfer-agent/cli.ts new file mode 100644 index 0000000..4a0e1f6 --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/cli.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env -S npx tsx +/** + * Interactive CLI for Starknet MCP tools (balance, transfer, call, workflow). + * + * Usage: + * pnpm cli balance STRK + * pnpm cli balance STRK --pretty + * pnpm cli transfer 1 STRK 0xRecipient --execute --pretty + * pnpm cli workflow --pretty + */ + +import dotenv from "dotenv"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseConfig } from "./src/config.js"; +import { McpSidecar } from "./src/mcp.js"; +import { + formatBalancePretty, + formatBalancesPretty, + formatCallPretty, + formatTransferPretty, + hasPrettyFlag, + stripPrettyFlag, +} from "./src/pretty.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, ".env") }); + +function buildMcpEnv(): Record { + const keys = [ + "STARKNET_RPC_URL", + "STARKNET_ACCOUNT_ADDRESS", + "STARKNET_PRIVATE_KEY", + "AVNU_BASE_URL", + "AVNU_PAYMASTER_URL", + "AVNU_PAYMASTER_API_KEY", + "AVNU_PAYMASTER_FEE_MODE", + ]; + const env: Record = {}; + for (const key of keys) { + const value = process.env[key]; + if (typeof value === "string" && value.length > 0) { + env[key] = value; + } + } + return env; +} + +function printResult(data: unknown, prettyFormatter?: (d: Record) => string): void { + const pretty = hasPrettyFlag(); + if (pretty && prettyFormatter && data && typeof data === "object") { + console.log(prettyFormatter(data as Record)); + return; + } + console.log(JSON.stringify(data, null, 2)); +} + +function usage(): void { + console.log(`Starknet transfer-agent CLI + +Global flags: + --pretty Human-readable output (or set TRANSFER_AGENT_PRETTY=1) + +Commands: + balance Check your balance (ETH, STRK, USDC, USDT) + balances Check multiple balances + transfer Simulate transfer (dry-run) + transfer --execute + Send real on-chain transfer + call [FELT...] + Read contract (e.g. balance_of ) + workflow Run full autonomous pipeline (see run.ts) + +Examples: + pnpm cli balance STRK --pretty + pnpm cli balances ETH STRK --pretty + pnpm cli transfer 0.5 STRK 0x034b... --execute --pretty + pnpm cli workflow --pretty +`); +} + +async function withMcp(fn: (sidecar: McpSidecar) => Promise): Promise { + const cfg = parseConfig(); + const mcpEntry = path.isAbsolute(cfg.TRANSFER_AGENT_MCP_ENTRY) + ? cfg.TRANSFER_AGENT_MCP_ENTRY + : path.resolve(__dirname, cfg.TRANSFER_AGENT_MCP_ENTRY); + + const sidecar = new McpSidecar(mcpEntry, buildMcpEnv()); + await sidecar.connect(cfg.TRANSFER_AGENT_MCP_LABEL); + try { + return await fn(sidecar); + } finally { + await sidecar.close(); + } +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const pretty = hasPrettyFlag(argv); + const args = stripPrettyFlag(argv); + const [command, ...rest] = args; + + if (!command || command === "help" || command === "--help" || command === "-h") { + usage(); + return; + } + + if (command === "workflow") { + const { spawn } = await import("node:child_process"); + const runArgs = ["tsx", "run.ts"]; + if (pretty) runArgs.push("--pretty"); + const child = spawn("npx", runArgs, { + cwd: __dirname, + stdio: "inherit", + env: process.env, + }); + const code = await new Promise((resolve) => { + child.on("exit", (c) => resolve(c ?? 1)); + }); + process.exitCode = code; + return; + } + + if (command === "balance") { + const token = rest.filter((a) => !a.startsWith("--"))[0] ?? "STRK"; + const result = await withMcp((s) => s.callTool("starknet_get_balance", { token })); + printResult(result, formatBalancePretty); + return; + } + + if (command === "balances") { + const tokens = rest.filter((a) => !a.startsWith("--")); + const tokenList = tokens.length > 0 ? tokens : ["ETH", "STRK"]; + const result = await withMcp((s) => s.callTool("starknet_get_balances", { tokens: tokenList })); + printResult(result, formatBalancesPretty); + return; + } + + if (command === "transfer") { + const execute = rest.includes("--execute"); + const cmdArgs = rest.filter((a) => a !== "--execute" && !a.startsWith("--")); + const [amount, token, recipient] = cmdArgs; + if (!amount || !token || !recipient) { + console.error("Usage: pnpm cli transfer [--execute] [--pretty]"); + process.exitCode = 1; + return; + } + const result = await withMcp((s) => + s.callTool("starknet_transfer", { + recipient, + token, + amount, + dryRun: !execute, + gasfree: process.env.TRANSFER_GASFREE === "1", + }), + ); + printResult(result, formatTransferPretty); + return; + } + + if (command === "call") { + const cmdArgs = rest.filter((a) => !a.startsWith("--")); + const [contractAddress, entrypoint, ...calldata] = cmdArgs; + if (!contractAddress || !entrypoint) { + console.error("Usage: pnpm cli call [CALLDATA...] [--pretty]"); + process.exitCode = 1; + return; + } + const result = await withMcp((s) => + s.callTool("starknet_call_contract", { + contractAddress, + entrypoint, + calldata, + }), + ); + printResult(result, formatCallPretty); + return; + } + + console.error(`Unknown command: ${command}\n`); + usage(); + process.exitCode = 1; +} + +main().catch((error) => { + if (hasPrettyFlag()) { + console.error(`[ERROR] ${error instanceof Error ? error.message : String(error)}`); + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + process.exitCode = 1; +}); diff --git a/starknet-agentic/examples/transfer-agent/package.json b/starknet-agentic/examples/transfer-agent/package.json new file mode 100644 index 0000000..32f3f0e --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/package.json @@ -0,0 +1,25 @@ +{ + "name": "@starknetfoundation/starknet-agentic-transfer-agent-demo", + "version": "0.1.0", + "private": true, + "description": "Composable autonomous transfer agent on Starknet Sepolia using MCP tools.", + "type": "module", + "scripts": { + "run": "npx tsx run.ts", + "run:pretty": "npx tsx run.ts --pretty", + "run:execute": "RUN_MODE=execute npx tsx run.ts", + "run:execute:pretty": "RUN_MODE=execute npx tsx run.ts --pretty", + "cli": "npx tsx cli.ts", + "test": "node --test --import tsx test/*.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "dotenv": "^17.4.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "tsx": "^4.22.3", + "typescript": "^6.0.3" + } +} diff --git a/starknet-agentic/examples/transfer-agent/run.ts b/starknet-agentic/examples/transfer-agent/run.ts new file mode 100644 index 0000000..003267e --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/run.ts @@ -0,0 +1,181 @@ +#!/usr/bin/env -S npx tsx +/** + * Composable Transfer Agent (Sepolia) + * + * Multi-step workflow: + * 1. Fetch wallet balance (starknet_get_balance) + * 2. Check low-balance alert threshold + * 3. Validate transfer policy (balance > X, reserve check) + * 4. Simulate then optionally execute transfer (starknet_transfer) + * 5. Follow-up contract read on recipient (starknet_call_contract balance_of) + * 6. Log structured output + write artifact JSON + * + * Usage: + * npx tsx run.ts # JSON logs (default) + * npx tsx run.ts --pretty # human-readable terminal output + */ + +import dotenv from "dotenv"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseConfig } from "./src/config.js"; +import { McpSidecar } from "./src/mcp.js"; +import { createAgentLogger, hasPrettyFlag } from "./src/pretty.js"; +import { checkLowBalanceAlert, evaluateTransferPolicy } from "./src/policy.js"; +import type { RunArtifact } from "./src/types.js"; +import { + executeTransferWorkflow, + fetchBalance, + sendLowBalanceAlertWebhook, +} from "./src/workflow.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, ".env") }); + +const pretty = hasPrettyFlag(); +const log = createAgentLogger(pretty); + +function buildMcpEnv(cfg: ReturnType): Record { + const keys = [ + "STARKNET_RPC_URL", + "STARKNET_ACCOUNT_ADDRESS", + "STARKNET_PRIVATE_KEY", + "AVNU_BASE_URL", + "AVNU_PAYMASTER_URL", + "AVNU_PAYMASTER_API_KEY", + "AVNU_PAYMASTER_FEE_MODE", + ]; + + const env: Record = {}; + for (const key of keys) { + const value = process.env[key]; + if (typeof value === "string" && value.length > 0) { + env[key] = value; + } + } + + return env; +} + +async function main(): Promise { + const cfg = parseConfig(); + const mcpEntry = path.isAbsolute(cfg.TRANSFER_AGENT_MCP_ENTRY) + ? cfg.TRANSFER_AGENT_MCP_ENTRY + : path.resolve(__dirname, cfg.TRANSFER_AGENT_MCP_ENTRY); + + log("INFO", "Starting transfer-agent run.", { + token: cfg.TRANSFER_TOKEN, + runMode: cfg.RUN_MODE, + recipient: cfg.TRANSFER_RECIPIENT, + }); + + const sidecar = new McpSidecar(mcpEntry, buildMcpEnv(cfg)); + await sidecar.connect(cfg.TRANSFER_AGENT_MCP_LABEL); + + try { + const tools = await sidecar.listTools(); + const required = ["starknet_get_balance", "starknet_transfer", "starknet_call_contract"]; + for (const name of required) { + if (!tools.includes(name)) { + throw new Error(`Required MCP tool missing: ${name}`); + } + } + + const balanceBefore = await fetchBalance(sidecar, cfg.TRANSFER_TOKEN); + log("INFO", "Fetched wallet balance.", { balanceBefore }); + + const balanceRaw = BigInt(balanceBefore.raw); + const lowBalanceAlert = checkLowBalanceAlert({ + balanceRaw, + alertThresholdWei: cfg.lowBalanceAlertThresholdWei, + }); + + if (lowBalanceAlert.triggered) { + log("WARN", "Low balance alert triggered.", { lowBalanceAlert, balanceBefore }); + if (cfg.ALERT_WEBHOOK_URL) { + await sendLowBalanceAlertWebhook(cfg.ALERT_WEBHOOK_URL, { + event: "LOW_BALANCE_ALERT", + ...lowBalanceAlert, + balance: balanceBefore.balance, + token: balanceBefore.token, + address: balanceBefore.address, + }); + log("INFO", "Low balance alert sent to webhook.", {}); + } + } else { + log("INFO", "Balance above alert threshold.", { lowBalanceAlert }); + } + + const transferPolicy = evaluateTransferPolicy({ + balanceRaw, + minBalanceToTransferWei: cfg.minBalanceToTransferWei, + transferAmountWei: cfg.transferAmountWei, + reserveAfterTransferWei: cfg.reserveAfterTransferWei, + }); + + log("INFO", "Transfer policy evaluated.", { transferPolicy }); + + const { transfer, followUp } = await executeTransferWorkflow({ + sidecar, + cfg, + balance: balanceBefore, + }); + + if (transfer.skipped) { + log("INFO", "Transfer skipped by policy.", { transfer, transferPolicy }); + } else if (transfer.executed) { + log("INFO", "Transfer executed on-chain.", { transfer }); + } else { + log("INFO", "Transfer simulated (dry-run).", { transfer }); + } + + if (followUp) { + log("INFO", "Follow-up balance_of read on recipient.", { followUp }); + } + + const runId = `transfer-agent-${new Date().toISOString().replace(/[:.]/g, "-")}`; + const outputDir = path.resolve(__dirname, cfg.TRANSFER_AGENT_OUTPUT_DIR); + fs.mkdirSync(outputDir, { recursive: true }); + const artifactPath = path.join(outputDir, `${runId}.json`); + + const artifact: RunArtifact = { + runId, + generatedAt: new Date().toISOString(), + config: { + token: cfg.TRANSFER_TOKEN, + runMode: cfg.RUN_MODE, + minBalanceToTransfer: cfg.MIN_BALANCE_TO_TRANSFER, + transferAmount: cfg.TRANSFER_AMOUNT, + reserveAfterTransfer: cfg.RESERVE_AFTER_TRANSFER, + lowBalanceAlertThreshold: cfg.LOW_BALANCE_ALERT_THRESHOLD, + recipient: cfg.TRANSFER_RECIPIENT, + }, + balanceBefore, + lowBalanceAlert, + transferPolicy, + transfer, + followUp, + }; + + fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, "utf8"); + + log("INFO", "Transfer-agent run completed.", { + runId, + artifactPath, + transfer, + transferPolicy, + lowBalanceAlert, + }); + } finally { + await sidecar.close(); + } +} + +main().catch((error) => { + log("ERROR", "Transfer-agent run failed.", { + reason: error instanceof Error ? error.message : String(error), + }); + process.exitCode = 1; +}); diff --git a/starknet-agentic/examples/transfer-agent/src/config.ts b/starknet-agentic/examples/transfer-agent/src/config.ts new file mode 100644 index 0000000..7609aa9 --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/src/config.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +const TOKEN_DECIMALS: Record = { + ETH: 18, + STRK: 18, + USDC: 6, + USDT: 6, +}; + +const strictBooleanInput = z.enum(["0", "1", "true", "false"]); + +function booleanEnv(defaultValue: "0" | "1") { + return z + .string() + .default(defaultValue) + .transform((value) => value.trim().toLowerCase()) + .pipe(strictBooleanInput) + .transform((value) => value === "1" || value === "true"); +} + +/** Parse a human-readable decimal amount (e.g. "1.5") to base units. */ +export function parseDecimalToWei(amount: string, decimals: number): bigint { + const trimmed = amount.trim(); + if (!/^\d+(\.\d+)?$/.test(trimmed)) { + throw new Error(`Invalid decimal amount: ${amount}`); + } + + const [wholePart, fracPart = ""] = trimmed.split("."); + if (fracPart.length > decimals) { + throw new Error(`Amount ${amount} exceeds ${decimals} decimal places`); + } + + const paddedFrac = fracPart.padEnd(decimals, "0"); + const combined = `${wholePart}${paddedFrac}`.replace(/^0+/, "") || "0"; + return BigInt(combined); +} + +const envSchema = z + .object({ + STARKNET_RPC_URL: z.string().url(), + STARKNET_ACCOUNT_ADDRESS: z.string().regex(/^0x[0-9a-fA-F]+$/), + STARKNET_PRIVATE_KEY: z.string().regex(/^0x[0-9a-fA-F]+$/), + + TRANSFER_TOKEN: z.enum(["ETH", "STRK", "USDC", "USDT"]).default("STRK"), + MIN_BALANCE_TO_TRANSFER: z.string().default("50"), + TRANSFER_AMOUNT: z.string().default("1"), + RESERVE_AFTER_TRANSFER: z.string().default("10"), + LOW_BALANCE_ALERT_THRESHOLD: z.string().default("20"), + TRANSFER_RECIPIENT: z.string().regex(/^0x[0-9a-fA-F]+$/), + + RUN_MODE: z.enum(["dry-run", "execute"]).default("dry-run"), + TRANSFER_GASFREE: booleanEnv("0"), + TRANSFER_AGENT_MCP_ENTRY: z.string().default("../../packages/starknet-mcp-server/dist/index.js"), + TRANSFER_AGENT_MCP_LABEL: z.string().default("transfer-agent"), + TRANSFER_AGENT_OUTPUT_DIR: z.string().default("./artifacts"), + ALERT_WEBHOOK_URL: z.string().url().optional(), + }) + .strict(); + +export type TransferAgentConfig = z.infer & { + tokenDecimals: number; + minBalanceToTransferWei: bigint; + transferAmountWei: bigint; + reserveAfterTransferWei: bigint; + lowBalanceAlertThresholdWei: bigint; +}; + +/** Pick only schema-defined keys so strict validation ignores shell/npm env noise. */ +function pickKnownEnv(env: NodeJS.ProcessEnv): Record { + const picked: Record = {}; + for (const key of Object.keys(envSchema.shape)) { + const value = env[key]; + if (value !== undefined) { + picked[key] = value; + } + } + return picked; +} + +export function parseConfig(env: NodeJS.ProcessEnv = process.env): TransferAgentConfig { + const parsed = envSchema.parse(pickKnownEnv(env)); + const tokenDecimals = TOKEN_DECIMALS[parsed.TRANSFER_TOKEN] ?? 18; + + return { + ...parsed, + tokenDecimals, + minBalanceToTransferWei: parseDecimalToWei(parsed.MIN_BALANCE_TO_TRANSFER, tokenDecimals), + transferAmountWei: parseDecimalToWei(parsed.TRANSFER_AMOUNT, tokenDecimals), + reserveAfterTransferWei: parseDecimalToWei(parsed.RESERVE_AFTER_TRANSFER, tokenDecimals), + lowBalanceAlertThresholdWei: parseDecimalToWei(parsed.LOW_BALANCE_ALERT_THRESHOLD, tokenDecimals), + }; +} diff --git a/starknet-agentic/examples/transfer-agent/src/mcp.ts b/starknet-agentic/examples/transfer-agent/src/mcp.ts new file mode 100644 index 0000000..7cf0a45 --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/src/mcp.ts @@ -0,0 +1,101 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +export class McpSidecar { + private client: Client | null = null; + private transport: StdioClientTransport | null = null; + + constructor( + private readonly mcpEntry: string, + private readonly env: Record, + ) {} + + async connect(label: string): Promise { + if (this.client || this.transport) { + await this.close(); + } + + const passthroughKeys = [ + "PATH", + "HOME", + "USER", + "TMPDIR", + "TMP", + "TEMP", + "SystemRoot", + "WINDIR", + "COMSPEC", + "PATHEXT", + ]; + + const mergedEnv: Record = {}; + for (const key of passthroughKeys) { + const value = process.env[key]; + if (typeof value === "string" && value.length > 0) { + mergedEnv[key] = value; + } + } + + for (const [key, value] of Object.entries(this.env)) { + mergedEnv[key] = value; + } + + const transport = new StdioClientTransport({ + command: "node", + args: [this.mcpEntry], + env: mergedEnv, + }); + + const client = new Client( + { name: `transfer-agent-${label}`, version: "0.1.0" }, + { capabilities: {} }, + ); + + await client.connect(transport); + this.client = client; + this.transport = transport; + } + + async close(): Promise { + await this.client?.close(); + await this.transport?.close(); + this.client = null; + this.transport = null; + } + + async listTools(): Promise { + if (!this.client) { + throw new Error("MCP client is not connected"); + } + + const response = await this.client.listTools(); + return (response.tools || []).map((tool) => tool.name); + } + + async callTool(name: string, args: Record): Promise { + if (!this.client) { + throw new Error("MCP client is not connected"); + } + + const response = (await this.client.callTool({ name, arguments: args })) as { + isError?: boolean; + content?: Array<{ type?: string; text?: string }>; + }; + + if (response?.isError) { + const toolMessage = response.content?.find((part) => part.type === "text")?.text; + throw new Error(toolMessage || `Tool ${name} returned an error`); + } + + const text = response?.content?.find((part) => part.type === "text")?.text; + if (!text) { + return response; + } + + try { + return JSON.parse(text); + } catch { + return { text }; + } + } +} diff --git a/starknet-agentic/examples/transfer-agent/src/policy.ts b/starknet-agentic/examples/transfer-agent/src/policy.ts new file mode 100644 index 0000000..956cd58 --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/src/policy.ts @@ -0,0 +1,87 @@ +export type TransferPolicyInput = { + balanceRaw: bigint; + minBalanceToTransferWei: bigint; + transferAmountWei: bigint; + reserveAfterTransferWei: bigint; +}; + +export type TransferPolicyResult = { + allowed: boolean; + reasonCode: string; + message: string; +}; + +export type LowBalanceAlertInput = { + balanceRaw: bigint; + alertThresholdWei: bigint; +}; + +export type LowBalanceAlertResult = { + triggered: boolean; + reasonCode: string; + message: string; +}; + +/** + * Only transfer when balance is strictly greater than X and the post-transfer + * balance stays at or above the configured reserve. + */ +export function evaluateTransferPolicy(input: TransferPolicyInput): TransferPolicyResult { + const { balanceRaw, minBalanceToTransferWei, transferAmountWei, reserveAfterTransferWei } = input; + + if (balanceRaw <= minBalanceToTransferWei) { + return { + allowed: false, + reasonCode: "BLOCK_BALANCE_NOT_ABOVE_THRESHOLD", + message: `Balance ${balanceRaw} is not greater than minimum ${minBalanceToTransferWei}.`, + }; + } + + if (transferAmountWei <= 0n) { + return { + allowed: false, + reasonCode: "BLOCK_ZERO_TRANSFER_AMOUNT", + message: "Transfer amount must be positive.", + }; + } + + if (balanceRaw < transferAmountWei) { + return { + allowed: false, + reasonCode: "BLOCK_INSUFFICIENT_BALANCE", + message: `Balance ${balanceRaw} is less than transfer amount ${transferAmountWei}.`, + }; + } + + const balanceAfter = balanceRaw - transferAmountWei; + if (balanceAfter < reserveAfterTransferWei) { + return { + allowed: false, + reasonCode: "BLOCK_RESERVE_VIOLATION", + message: + `Post-transfer balance ${balanceAfter} would be below reserve ${reserveAfterTransferWei}.`, + }; + } + + return { + allowed: true, + reasonCode: "ALLOW_TRANSFER", + message: "Transfer preconditions satisfied.", + }; +} + +export function checkLowBalanceAlert(input: LowBalanceAlertInput): LowBalanceAlertResult { + if (input.balanceRaw < input.alertThresholdWei) { + return { + triggered: true, + reasonCode: "ALERT_LOW_BALANCE", + message: `Balance ${input.balanceRaw} is below alert threshold ${input.alertThresholdWei}.`, + }; + } + + return { + triggered: false, + reasonCode: "OK_BALANCE_ABOVE_ALERT", + message: "Balance is above the low-balance alert threshold.", + }; +} diff --git a/starknet-agentic/examples/transfer-agent/src/pretty.ts b/starknet-agentic/examples/transfer-agent/src/pretty.ts new file mode 100644 index 0000000..c1ca69c --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/src/pretty.ts @@ -0,0 +1,199 @@ +import type { LowBalanceAlertResult, TransferPolicyResult } from "./policy.js"; +import type { + BalanceSnapshot, + FollowUpResult, + TransferResult, +} from "./types.js"; + +export function hasPrettyFlag(argv: string[] = process.argv.slice(2)): boolean { + return argv.includes("--pretty") || process.env.TRANSFER_AGENT_PRETTY === "1"; +} + +/** Remove --pretty from argv before parsing subcommands. */ +export function stripPrettyFlag(argv: string[]): string[] { + return argv.filter((a) => a !== "--pretty"); +} + +function u256FromCalldata(result: string[]): string { + if (!result.length) return "0"; + const low = BigInt(result[0] || "0"); + const high = BigInt(result[1] || "0"); + return (low + (high << 128n)).toString(); +} + +export function formatBalancePretty(data: { + address?: string; + token?: string; + balance?: string; + tokenAddress?: string; +}): string { + return [ + "Wallet balance", + ` Account: ${data.address ?? "unknown"}`, + ` Token: ${data.token ?? "unknown"}`, + ` Amount: ${data.balance ?? "unknown"} ${data.token ?? ""}`, + ].join("\n"); +} + +export function formatBalancesPretty(data: { + address?: string; + balances?: Array<{ token: string; balance: string }>; +}): string { + const lines = [`Wallet balances for ${data.address ?? "unknown"}:`]; + for (const b of data.balances ?? []) { + lines.push(` ${b.token}: ${b.balance}`); + } + return lines.join("\n"); +} + +export function formatTransferPretty(data: { + success?: boolean; + dryRun?: boolean; + simulated?: boolean; + transactionHash?: string | null; + recipient?: string; + token?: string; + amount?: string; +}): string { + const lines: string[] = []; + if (data.dryRun || data.simulated) { + lines.push("Transfer simulated (dry-run) — no on-chain transaction."); + } else if (data.transactionHash) { + lines.push("Transfer sent on-chain."); + lines.push(` Tx hash: ${data.transactionHash}`); + } else { + lines.push("Transfer result"); + } + lines.push(` Amount: ${data.amount ?? "?"} ${data.token ?? ""}`); + lines.push(` Recipient: ${data.recipient ?? "?"}`); + return lines.join("\n"); +} + +export function formatCallPretty(data: { + contractAddress?: string; + entrypoint?: string; + result?: string[]; +}): string { + const raw = data.result ? u256FromCalldata(data.result) : null; + return [ + "Contract call (read-only)", + ` Contract: ${data.contractAddress ?? "?"}`, + ` Function: ${data.entrypoint ?? "?"}`, + raw ? ` Result: ${raw} (raw u256)` : "", + ] + .filter(Boolean) + .join("\n"); +} + +export type WorkflowLogContext = { + token?: string; + runMode?: string; + recipient?: string; + balanceBefore?: BalanceSnapshot; + lowBalanceAlert?: LowBalanceAlertResult; + transferPolicy?: TransferPolicyResult; + transfer?: TransferResult; + followUp?: FollowUpResult | null; + artifactPath?: string; + runId?: string; + reason?: string; +}; + +export function formatWorkflowLog( + level: "INFO" | "WARN" | "ERROR", + message: string, + data?: WorkflowLogContext, +): string { + const prefix = + level === "ERROR" ? "[ERROR]" : level === "WARN" ? "[ALERT]" : "[INFO]"; + + switch (message) { + case "Starting transfer-agent run.": + return `${prefix} Starting transfer-agent\n Token: ${data?.token} | Mode: ${data?.runMode} | Recipient: ${data?.recipient}`; + + case "Fetched wallet balance.": + return `${prefix} Balance: ${data?.balanceBefore?.balance ?? "?"} ${data?.balanceBefore?.token ?? ""}\n Account: ${data?.balanceBefore?.address ?? "?"}`; + + case "Low balance alert triggered.": + return `${prefix} LOW BALANCE ALERT\n ${data?.lowBalanceAlert?.message ?? "Balance below threshold."}\n Current: ${data?.balanceBefore?.balance ?? "?"} ${data?.balanceBefore?.token ?? ""}`; + + case "Balance above alert threshold.": + return `${prefix} Balance OK (above alert threshold)`; + + case "Transfer policy evaluated.": + if (data?.transferPolicy?.allowed) { + return `${prefix} Policy: transfer allowed (${data.transferPolicy.reasonCode})`; + } + return `${prefix} Policy: transfer blocked\n ${data?.transferPolicy?.message ?? ""}`; + + case "Transfer skipped by policy.": + return `${prefix} Transfer skipped — ${data?.transfer?.reasonCode ?? "policy"}`; + + case "Transfer executed on-chain.": + return `${prefix} Transfer executed\n Sent ${data?.transfer?.amount} ${data?.transfer?.token} → ${data?.transfer?.recipient}\n Tx: ${data?.transfer?.transactionHash}`; + + case "Transfer simulated (dry-run).": + return `${prefix} Transfer simulated (dry-run)\n Would send ${data?.transfer?.amount} ${data?.transfer?.token} → ${data?.transfer?.recipient}`; + + case "Follow-up balance_of read on recipient.": + if (data?.followUp?.result) { + const raw = u256FromCalldata(data.followUp.result); + return `${prefix} Follow-up: recipient ${data.followUp.entrypoint}\n Recipient: ${data.followUp.recipient}\n Raw balance: ${raw}`; + } + return `${prefix} Follow-up contract read completed`; + + case "Low balance alert sent to webhook.": + return `${prefix} Alert webhook notified`; + + case "Transfer-agent run completed.": + return [ + `${prefix} Run completed`, + ` Artifact: ${data?.artifactPath ?? "?"}`, + ` Transfer executed: ${data?.transfer?.executed ? "yes" : "no"}`, + ` Policy allowed: ${data?.transferPolicy?.allowed ? "yes" : "no"}`, + ` Alert triggered: ${data?.lowBalanceAlert?.triggered ? "yes" : "no"}`, + ].join("\n"); + + case "Transfer-agent run failed.": + return `${prefix} Run failed\n ${data?.reason ?? "Unknown error"}`; + + default: + return `${prefix} ${message}`; + } +} + +export function createAgentLogger(pretty: boolean) { + return function log( + level: "INFO" | "WARN" | "ERROR", + message: string, + data?: WorkflowLogContext, + ): void { + if (pretty) { + const line = formatWorkflowLog(level, message, data); + if (level === "ERROR") { + console.error(line); + } else if (level === "WARN") { + console.warn(line); + } else { + console.log(line); + } + return; + } + + const payload = { + timestamp: new Date().toISOString(), + level, + component: "transfer-agent", + message, + ...(data || {}), + }; + const line = JSON.stringify(payload); + if (level === "ERROR") { + console.error(line); + } else if (level === "WARN") { + console.warn(line); + } else { + console.log(line); + } + }; +} diff --git a/starknet-agentic/examples/transfer-agent/src/types.ts b/starknet-agentic/examples/transfer-agent/src/types.ts new file mode 100644 index 0000000..f5c16ec --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/src/types.ts @@ -0,0 +1,48 @@ +import type { LowBalanceAlertResult, TransferPolicyResult } from "./policy.js"; + +export type BalanceSnapshot = { + address: string; + token: string; + tokenAddress: string; + balance: string; + raw: string; + decimals: number; +}; + +export type TransferResult = { + executed: boolean; + dryRun: boolean; + simulated?: boolean; + transactionHash?: string | null; + recipient: string; + token: string; + amount: string; + skipped?: boolean; + reasonCode?: string; +}; + +export type FollowUpResult = { + contractAddress: string; + entrypoint: string; + recipient: string; + result: string[]; +}; + +export type RunArtifact = { + runId: string; + generatedAt: string; + config: { + token: string; + runMode: string; + minBalanceToTransfer: string; + transferAmount: string; + reserveAfterTransfer: string; + lowBalanceAlertThreshold: string; + recipient: string; + }; + balanceBefore: BalanceSnapshot; + lowBalanceAlert: LowBalanceAlertResult; + transferPolicy: TransferPolicyResult; + transfer: TransferResult; + followUp: FollowUpResult | null; +}; diff --git a/starknet-agentic/examples/transfer-agent/src/workflow.ts b/starknet-agentic/examples/transfer-agent/src/workflow.ts new file mode 100644 index 0000000..06d86bb --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/src/workflow.ts @@ -0,0 +1,150 @@ +import type { TransferAgentConfig } from "./config.js"; +import type { McpSidecar } from "./mcp.js"; +import { evaluateTransferPolicy } from "./policy.js"; +import type { + BalanceSnapshot, + FollowUpResult, + TransferResult, +} from "./types.js"; + +export async function fetchBalance( + sidecar: McpSidecar, + token: string, +): Promise { + const result = (await sidecar.callTool("starknet_get_balance", { token })) as BalanceSnapshot; + return result; +} + +export async function readRecipientBalance( + sidecar: McpSidecar, + tokenAddress: string, + recipient: string, +): Promise { + const response = (await sidecar.callTool("starknet_call_contract", { + contractAddress: tokenAddress, + entrypoint: "balance_of", + calldata: [recipient], + })) as { + result: string[]; + contractAddress: string; + entrypoint: string; + }; + + return { + contractAddress: response.contractAddress, + entrypoint: response.entrypoint, + recipient, + result: response.result, + }; +} + +export async function executeTransferWorkflow(args: { + sidecar: McpSidecar; + cfg: TransferAgentConfig; + balance: BalanceSnapshot; +}): Promise<{ transfer: TransferResult; followUp: FollowUpResult | null }> { + const { sidecar, cfg, balance } = args; + const balanceRaw = BigInt(balance.raw); + + const policy = evaluateTransferPolicy({ + balanceRaw, + minBalanceToTransferWei: cfg.minBalanceToTransferWei, + transferAmountWei: cfg.transferAmountWei, + reserveAfterTransferWei: cfg.reserveAfterTransferWei, + }); + + if (!policy.allowed) { + return { + transfer: { + executed: false, + dryRun: cfg.RUN_MODE === "dry-run", + skipped: true, + reasonCode: policy.reasonCode, + recipient: cfg.TRANSFER_RECIPIENT, + token: cfg.TRANSFER_TOKEN, + amount: cfg.TRANSFER_AMOUNT, + }, + followUp: null, + }; + } + + const transferArgs = { + recipient: cfg.TRANSFER_RECIPIENT, + token: cfg.TRANSFER_TOKEN, + amount: cfg.TRANSFER_AMOUNT, + gasfree: cfg.TRANSFER_GASFREE, + }; + + // Always simulate first + const simulation = (await sidecar.callTool("starknet_transfer", { + ...transferArgs, + dryRun: true, + })) as { + success: boolean; + simulated?: boolean; + dryRun: boolean; + }; + + if (!simulation.success) { + throw new Error("Transfer simulation failed"); + } + + if (cfg.RUN_MODE === "dry-run") { + return { + transfer: { + executed: false, + dryRun: true, + simulated: true, + transactionHash: null, + recipient: cfg.TRANSFER_RECIPIENT, + token: cfg.TRANSFER_TOKEN, + amount: cfg.TRANSFER_AMOUNT, + reasonCode: policy.reasonCode, + }, + followUp: await readRecipientBalance(sidecar, balance.tokenAddress, cfg.TRANSFER_RECIPIENT), + }; + } + + const execution = (await sidecar.callTool("starknet_transfer", { + ...transferArgs, + dryRun: false, + })) as { + success: boolean; + transactionHash: string; + dryRun: boolean; + }; + + const followUp = await readRecipientBalance( + sidecar, + balance.tokenAddress, + cfg.TRANSFER_RECIPIENT, + ); + + return { + transfer: { + executed: true, + dryRun: false, + transactionHash: execution.transactionHash, + recipient: cfg.TRANSFER_RECIPIENT, + token: cfg.TRANSFER_TOKEN, + amount: cfg.TRANSFER_AMOUNT, + reasonCode: policy.reasonCode, + }, + followUp, + }; +} + +export async function sendLowBalanceAlertWebhook( + webhookUrl: string, + payload: Record, +): Promise { + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Alert webhook failed with status ${response.status}`); + } +} diff --git a/starknet-agentic/examples/transfer-agent/test/policy.test.ts b/starknet-agentic/examples/transfer-agent/test/policy.test.ts new file mode 100644 index 0000000..55ec43e --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/test/policy.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { checkLowBalanceAlert, evaluateTransferPolicy } from "../src/policy.js"; + +const ONE_STRK = 10n ** 18n; + +describe("evaluateTransferPolicy", () => { + const base = { + minBalanceToTransferWei: 50n * ONE_STRK, + transferAmountWei: 1n * ONE_STRK, + reserveAfterTransferWei: 10n * ONE_STRK, + }; + + it("blocks when balance is not strictly above threshold", () => { + const result = evaluateTransferPolicy({ + ...base, + balanceRaw: 50n * ONE_STRK, + }); + assert.equal(result.allowed, false); + assert.equal(result.reasonCode, "BLOCK_BALANCE_NOT_ABOVE_THRESHOLD"); + }); + + it("blocks when post-transfer balance violates reserve", () => { + const result = evaluateTransferPolicy({ + ...base, + balanceRaw: 52n * ONE_STRK, + reserveAfterTransferWei: 52n * ONE_STRK, + }); + assert.equal(result.allowed, false); + assert.equal(result.reasonCode, "BLOCK_RESERVE_VIOLATION"); + }); + + it("allows transfer when balance and reserve checks pass", () => { + const result = evaluateTransferPolicy({ + ...base, + balanceRaw: 100n * ONE_STRK, + }); + assert.equal(result.allowed, true); + assert.equal(result.reasonCode, "ALLOW_TRANSFER"); + }); +}); + +describe("checkLowBalanceAlert", () => { + const ONE_STRK = 10n ** 18n; + + it("triggers alert when balance is below threshold", () => { + const result = checkLowBalanceAlert({ + balanceRaw: 15n * ONE_STRK, + alertThresholdWei: 20n * ONE_STRK, + }); + assert.equal(result.triggered, true); + assert.equal(result.reasonCode, "ALERT_LOW_BALANCE"); + }); + + it("does not trigger when balance is at or above threshold", () => { + const result = checkLowBalanceAlert({ + balanceRaw: 100n * ONE_STRK, + alertThresholdWei: 20n * ONE_STRK, + }); + assert.equal(result.triggered, false); + assert.equal(result.reasonCode, "OK_BALANCE_ABOVE_ALERT"); + }); +}); diff --git a/starknet-agentic/examples/transfer-agent/tsconfig.json b/starknet-agentic/examples/transfer-agent/tsconfig.json new file mode 100644 index 0000000..6434580 --- /dev/null +++ b/starknet-agentic/examples/transfer-agent/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "types": ["node"], + "strict": true, + "noEmit": true + }, + "include": ["./**/*.ts"] +} diff --git a/starknet_contracts/src/lib.cairo b/starknet_contracts/src/lib.cairo index 02257ad..12fe59c 100644 --- a/starknet_contracts/src/lib.cairo +++ b/starknet_contracts/src/lib.cairo @@ -10,8 +10,8 @@ pub trait ICounter { fn get_count(self: @T) -> u32; } -/// Simple contract for managing count. -#[starknet::contract] +/// Simple contract fobr managing count. +#[starknet::contract] pub mod Counter { use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; use starknet::{ContractAddress, get_caller_address}; diff --git a/target/CACHEDIR.TAG b/target/CACHEDIR.TAG deleted file mode 100644 index cdb98c4..0000000 --- a/target/CACHEDIR.TAG +++ /dev/null @@ -1,3 +0,0 @@ -Signature: 8a477f597d28d172789f06886806bc55 -# This file is a cache directory tag created by scarb-cairo-language-server. -# For information about cache directory tags see https://bford.info/cachedir/ diff --git a/target/cairo-language-server/2.18.0_proc_macro.cache b/target/cairo-language-server/2.18.0_proc_macro.cache deleted file mode 100644 index 9a48e13bcbd69a2d1ebbb364d1124538540e3eb1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15413 zcmeI236xaTxrO_jb8n2qBx(eiErFKqfPf5*7BmWiI0W#4Od=N5)wiMO>grB)HG|Ng zf)isj2%RvRAd|*KOca8~M2LVHm|_qVG$3dcWCqP2lehmKXs;i+gkj{Sr3v>Nlz_X)LWbxo@_p`5a4dPIw& zcp?b5ZvEdrwp@>&K8t3nqGX6Ijx3f6qUAxPAex9KV?j=CXk4uj{!0YuQ1#1;L< zvs9k;Anvdt1wm11D&PZEFhTPWX z)dzdpL!R|^v~OFz7r!CZj~gvY+gEkkJ^%T^xI5*C+PD432UA(=(_fmey|!`e(1G+b zuj!C@Nm(Kui>D)HsUQ{<2K@2F@GgYIWk{*=AQ=wd?p({vhQ86{@ZiP~L9E!_V83}m zDje<>Pmhk52kzz|77q7_^(k|)th79c7nbtlIp;R?3x<~j$#mCLkc_&UV)0b0BAyP1 zKYOn0@@j7DT3Fbtw6Nx_y($u)-8wv49uD`6mUpX&C(`j`%YI|a(?Ln@(56GmllZKM z2liK!N=6eA`%MkT1hI;Av>*`-{k$)4B=V2%Iy^`Q`1J*aIVBb8P^2)Lj)wb{MU$P2 zf<(G)hqhgEukOM*9F|+gW!_JI`}h#T;Z*SLig*eKpexIk}zA-g#7MaS(}? zMfmhk98V6Hol492ED#R&vH!m#6^*BF!9#|_!I-ijmUbVoeRD>~(<2H~(a{m4oZObp z`{3Y9i$X~F>SmtuUwpJkG?6&{ne3N0^Eu$BU7p6%)qK9sIa_q%p9R0G(NrqGYbq5T zYhO}0oD4?GYsR+d*l_st%{VnAb8f(f+{#es^FGfIpN-;0q3TbyJY5(L%hv*5pLjW+ ziHhRGBdLmHPVTr+Do9tPlAZ9F=g(m7g#JHz2FuFIQnX5uXCPI#F8Kyh_3F`=45aGU zr+fpcMvbVefz*{(Qg;KXX3eOFfmC)j^*4~}(18XRNaf|xKm)0c9chq()V0^ra098H zJ!yo2)Xg{3XalK%18Izb)Sy8$)drf9qJdN-LiZa;6%^1U1F2YyrW;6= zl+X+VsbrF78c3Cv(svA`%F1Y&fz-nf({cl;M;@US22xX|&{GCdQ>W4z1F5-l=|uyn zdGqKc1F8A*=@$l43l`AJ22u+b(mDgF#fxdZfz;Baw821X*)sa0fz0JYLWUq;~D1zZppF-c1*IkUDUHzUo2h;6eJj2dP7c=wc62hY!;w9;A*Op-VkT z9X(3l@E~>U7&Y@Cd=IR$J<5jjt*-LuDugdtHTS4F!Ua|>J!*+?kyV~Yc?fl^I(pO* z;c}~<9`!_MZgsOqHzVX&-RjY;2;a22&7<28T3CI{qi-R!wCd$iFNCYD`g+tCp{-Sa zkNP8Yuo~de0E9fNm`5>$8?6dGDn#gR6?haN^sp-Os0iUEt1^$u5N@~nwnyJa_z$b` z9*sx1%c|0&N`#?S6Fi!LaJSV&k0v5StnT&bUW8#*lRcV@P-r#9qbUd@t)_Z36`|Ow z%A+cTF;?I6=z9obt>$_(7h#;$JdfrfjJI0g(E@}CRtr5^h%nJ=kw=RV?zMW>qh}Ew zwOZ-XN`%L(p7ZEAgzvJll=^Gd-J4flptLi(wllTsI8&#-GxZxd)1aX<4VyXBEZdpv ztDL#2l{2kcJJY(IGws?t)4qc<9jM&h+l%OrHVH z3>fIlz#+~IxyzZmhB`Ae;!I?iGsB|JL<47nB4>(5Iy17^nc{>qiL^863TG-tIWua4 zGZQ8{GjWnLlO{Ve`2lAhc+{CkA9Loh+0M+KgfTHOVrYo8uVTPia5@sq|C}E+ZMG_V%dP2ezihdyB z2a1+SSf*&Xgyo7>NLZohDG5(0dRoHMihd&DCyJhx@T{Vh5>_huxrCo9S|eeNq8BB+ zsOa|+ey?bggiVSzOW3Svi-awTUYGE?qCZObqoVB+wkz5tVV9!a5_T(kTf*ClK9cZ} zqC*l6DLO3Su%eR^PAWPj;glkhK#Kk<;jfB5mGG&etm?}G9LY5Tj_%mTS~p{b^35}IksmXNKfg@hKGT1sfC>1qjA zYw9eav!;9r`I@>&=%T5+gzlPpNa&&I774d#x>drhnr@SDo2K3pdTZ(4mnx2vHjHVw; z__3yy5>{$@PQr7VUXt*Vre8?-g{GG!ysYUJ39o2cD`BmsbrRNT`n7~#YuYGbqo&s+ zyr$_l5`Lp;vxLo>wn*5bX{&^-n%7ayznm&^7ktSoRuM5VO#>{$=8zhQj-Fn<0Q5@^l z=LUHJ8e{|fOJ9~dec4bg3AGG;MZ#AM)saxg(B%>?H&jWwBqazT#wO;JLGXa9*BC}k?Zjw)Z=Tp9-|ds+mq`tT5->txgL*3Js!yQcns?C zAg;$~#e)WOJw_`Yd?(jqwBkD>T#wO;BL!TK(TWRVT#wO;VN4OrN6+beC>oHpKl&M^g(Tb{n#@e5p!-#|TH#q}7i zc-3mI$7sc?*K$2ZD_*;f>oHpKx=mb<(TX>1=6Z})ym<@PW3=KeTe%*i6>r_f_4pL( z@pi7qXvN!iaXm&W-nEyF zEk;4+P7H@yjDpOa7%or2*-i|HT8x6sofxh=mY$hT42N2bg3O&54z(BsnL9BYYB35j zcValyViaWV#Bg_^hh{o4+)ykdGo2U?>s=IN?!;g%yeQn z)M6B5?!<6d{GuRpCx$~UMnUFI42N2bg3O&54z(BsnL9Ducm-!WF&x&eD9GH2;jnf^ zLFP^jhgyt+%$*qSQLF+pofrzH0^2j{_rgIqaRQx-qSTzpj ztWj1!9Bg-)NrnEaJtF zsz)k?Q6}G?r!V$FGjm4XiT`txyKjEBQ>$n?2ZxoPA8OvUq_iT5d8GaHLj^&oP4kwa zvvzfF6F*v|Jpc?6PoJqR@z2lHss~!1J5xKyU)cFk+c^&I{?$?2Ierc}H~bqWa9@1# zmHFH`9@GE!*sjLO?))j+ULF7MoU-i@v7tssP0MfD7%l%o0uQUF3%8c1DuA*vg< zw_3GO!MKGrXn^{~4Xj~9R4;B{7&M{^p%eA$chzJH3O;f{4RpB&sf`=CKcGzP*g-iSq;~G4Tn|!jzDeKoAobQ; z)WU;!_}9jR)Sf-m&V$sxebnBA)c*a{&4bkO<8-43sS_uty9cS0C#i=AsZ*!u4iBoH z?72}IiVHN%gA`qA74;~J@C~a1j|vbjvvNbW+KQmK2Qf!yjr1Tz4XuhjDn@8zmGCHm z(AcWPqY{KGtWYSpT{N{C?a^q2T&po2jX^*Sf*$lBMb}zAxrp`XI08HK$vIsp+_Gg%(pt^(IEuzd+3MYsN`w*jZRtq0ubk4fk zhnSYL+V~J&z11~7U4yXKs*_Kh5Dr>(_Ng<%M^^bhzaP(LjWRp*TJQI2tWkyx;{Vh#<>6d$Qy6M`H?sO*KNo*Uu9YPShA*`!baB z`C?lAXt=CiAOV-vuS)o8jk)xtvIW=JZ%Dv3_A&{V@x`?Ip>bKQD*>0)dJ=G1&6bd@ z=_(0VX=*N^xuzTmIhxu_Xs@Y*gbteWB;;udO9*S~B%xD{@pU)Zg3IcS5^z~PV|?9D zw)Eo*WA%%}C9=N+Tq1`^7^3Me33q+bkh(y2D>&zg^=P@zXiZ}zjL|e+!uT(mSf4SM zzF+QhzotnNCSd^~VV0)HB|NTawuIT5=17>MsY*iCInT1s80v152iT_R4-)>M>2(RO zYkEV%8=7`V*zrYE;df-WcQpM;!k=o)aSzCr12u-ahh@uQO-Cdg(R5V8QBB7r9Mg14 z!YNH3OZZsRClWr<^fw8A)AV-h~8CoD=fuV&G7FvGejtwve45#k^hB;t30|qn90mH$78!!h9=gyG~bHH#$7BkEN z!zoTM%)wwfQNl0>45wr~!yGW2@s$j7z;G(3G0XwOnKqqa4j9h#84Pp4aAwS8m;;70 za~8uKFq~O)80LWC%&B6S1BO$zlwl4S4u;o&Ibb-;mNU!&!&$z9VGbD1idPxtfZ@El zo?#9c&iV}ubHH#mY-E@NhO==8!yGW29XlE3fZ^fV0mC`P(`Vou9{j?A z-~i_^!GXKf;v6P8aDX_#IZSZiE>m!JBn$_F1DwMI2Mz=WIEM)i93T#GPB8);7Kg z2Z95f!vqKJhZg5B!GZgk0?V-k2M$YG;2b76aDX_#IZSZieuuA9hTy<~-~i_^!GQz8 z0nTB90|$ZwoWleMZkNS5OmN`dwm63g4%{A#bC}@3?FFgG5FEIJ`1)iB4%|oh`eX_U}s4ilXIOmG, +allowances: Map::<(ContractAddress, ContractAddress), u128>, +admin: ContractAddress, +revoked: Map::, +max_limit: u128, +``` + +`name` stores the token name. In this contract, it is set to `'D_Chef'`. + +`symbol` stores the token symbol. In this contract, it is set to `'chef'`. + +`decimals` stores the number of decimal places. This contract uses `18`, like many ERC20 tokens. + +`total_supply` stores the total number of tokens that currently exist. + +`balances` stores how many tokens each address owns. + +`allowances` stores how much one address has approved another address to spend. + +For example: + +```text +allowances[(owner, spender)] = amount +``` + +means `spender` is allowed to spend `amount` from `owner`. + +`admin` stores the address that is allowed to call restricted functions like `burn`, `revoke`, `unrevoke`, and `update_transfer_limit`. + +`revoked` stores whether an address is blocked. + +```text +revoked[address] = true +``` + +means the address is blocked from transfers. + +`max_limit` stores the maximum amount allowed in a single transfer. + +## Constructor + +```cairo +fn constructor( + ref self: ContractState, + recipient: ContractAddress, + initial_supply: u128, + admin: ContractAddress, +) +``` + +The constructor runs once when the contract is deployed. + +It receives three arguments: + +```text +recipient +initial_supply +admin +``` + +`recipient` is the address that receives the initial token supply. + +`initial_supply` is the amount minted during deployment. + +`admin` is the address that will control admin-only functions. + +Inside the constructor, the contract does this: + +```cairo +self.admin.write(admin); +self.name.write('D_Chef'); +self.symbol.write('chef'); +self.decimals.write(18); +``` + +This saves the admin address and token details. + +Then it checks: + +```cairo +if initial_supply != 0 { + self.mint(recipient, initial_supply); +} +``` + +This means tokens are minted only if the initial supply is greater than zero. + +Finally: + +```cairo +self.max_limit.write(10000); +``` + +This sets the starting transfer limit to `10000`. + +## Public Read Functions + +These functions only read contract storage. They do not change balances or other state. + +### `get_name` + +```cairo +fn get_name(self: @ContractState) -> felt252 +``` + +Returns the token name. + +In this contract, it returns: + +```text +D_Chef +``` + +### `get_symbol` + +```cairo +fn get_symbol(self: @ContractState) -> felt252 +``` + +Returns the token symbol. + +In this contract, it returns: + +```text +chef +``` + +### `get_decimals` + +```cairo +fn get_decimals(self: @ContractState) -> u8 +``` + +Returns the number of decimals used by the token. + +In this contract, it returns: + +```text +18 +``` + +### `get_total_supply` + +```cairo +fn get_total_supply(self: @ContractState) -> u128 +``` + +Returns the current total supply. + +The total supply increases when tokens are minted in the constructor. + +The total supply decreases when the admin burns tokens. + +### `balance_of` + +```cairo +fn balance_of(self: @ContractState, account: ContractAddress) -> u128 +``` + +Returns the token balance of an address. + +Example: + +```text +balance_of(alice) +``` + +means: + +```text +How many tokens does Alice own? +``` + +### `allowance` + +```cairo +fn allowance( + self: @ContractState, + owner: ContractAddress, + spender: ContractAddress, +) -> u128 +``` + +Returns how many tokens a spender is allowed to spend from an owner. + +Example: + +```text +allowance(alice, bob) +``` + +means: + +```text +How many tokens can Bob spend from Alice's balance? +``` + +### `get_transfer_limit` + +```cairo +fn get_transfer_limit(self: @ContractState) -> u128 +``` + +Returns the current maximum transfer amount. + +At deployment, this value starts as: + +```text +10000 +``` + +The admin can update it later with `update_transfer_limit`. + +## Public Write Functions + +These functions change contract storage. They must be sent as transactions using `sncast invoke`. + +### `transfer` + +```cairo +fn transfer( + ref self: ContractState, + recipient: ContractAddress, + amount: u128, +) +``` + +Transfers tokens from the caller to another address. + +The function gets the sender from: + +```cairo +let sender = get_caller_address(); +``` + +Then it calls the internal `_transfer` function: + +```cairo +self._transfer(sender, recipient, amount); +``` + +This is important because the actual transfer checks are inside `_transfer`. + +So `transfer` is the public function users call, while `_transfer` is the internal function that enforces the rules and moves balances. + +### `transfer_from` + +```cairo +fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u128, +) +``` + +Transfers tokens from one address to another using allowance. + +This is used when an owner has approved a spender. + +Example: + +```text +Alice approves Bob to spend 500 tokens. +Bob calls transfer_from(Alice, Charlie, 200). +200 tokens move from Alice to Charlie. +Bob's allowance from Alice is reduced. +``` + +The function does two things: + +```cairo +self.spend_allowance(sender, caller, amount); +self._transfer(sender, recipient, amount); +``` + +First, it reduces the allowance. + +Then it performs the actual token transfer. + +Because it also calls `_transfer`, it follows the same transfer rules as normal `transfer`. + +### `approve` + +```cairo +fn approve( + ref self: ContractState, + spender: ContractAddress, + amount: u128, +) +``` + +Allows another address to spend tokens on behalf of the caller. + +Example: + +```text +Alice calls approve(Bob, 500). +Bob can now spend up to 500 tokens from Alice using transfer_from. +``` + +This function calls: + +```cairo +self.approve_helper(caller, spender, amount); +``` + +The helper stores the allowance in: + +```text +allowances[(owner, spender)] +``` + +## Admin Functions + +These functions are restricted by: + +```cairo +self.assert_only_admin(); +``` + +Only the stored admin address can call them successfully. + +### `update_transfer_limit` + +```cairo +fn update_transfer_limit( + ref self: ContractState, + new_limit: u128, +) +``` + +Updates the maximum amount allowed in one transfer. + +It checks: + +```cairo +self.assert_only_admin(); +assert(new_limit > 0, 'INVALID_LIMIT'); +``` + +This means: + +```text +Only admin can update the limit. +The new limit must be greater than zero. +``` + +Then it saves the new limit: + +```cairo +self.max_limit.write(new_limit); +``` + +### `burn` + +```cairo +fn burn( + ref self: ContractState, + account: ContractAddress, + amount: u128, +) +``` + +Destroys tokens from an account. + +Only the admin can call this function. + +It checks: + +```cairo +self.assert_only_admin(); +assert(account.is_non_zero(), 'BURN FROM ZERO'); +assert(amount > 0, 'INVALID AMOUNT'); +``` + +This means: + +```text +Caller must be admin. +The account cannot be the zero address. +The amount must be greater than zero. +``` + +Then it checks that the account has enough balance: + +```cairo +let balance = self.balances.read(account); +assert(balance >= amount, 'INSUFFICIENT BALANCE'); +``` + +If the account has enough tokens, the contract subtracts the amount from the account balance: + +```cairo +self.balances.write(account, balance - amount); +``` + +Then it reduces total supply: + +```cairo +self.total_supply.write(self.total_supply.read() - amount); +``` + +### `revoke` + +```cairo +fn revoke( + ref self: ContractState, + account: ContractAddress, +) +``` + +Blocks an account from transferring tokens. + +Only the admin can call this function. + +It checks: + +```cairo +self.assert_only_admin(); +assert(account.is_non_zero(), 'INVALID ACCOUNT'); +``` + +Then it writes: + +```cairo +self.revoked.write(account, true); +``` + +After this, the account cannot send or receive tokens because `_transfer` checks the `revoked` map. + +### `unrevoke` + +```cairo +fn unrevoke( + ref self: ContractState, + account: ContractAddress, +) +``` + +Unblocks a previously revoked account. + +Only the admin can call this function. + +It checks that the account is not the zero address, then writes: + +```cairo +self.revoked.write(account, false); +``` + +After this, the account can send and receive tokens again. + +## Internal Helper Functions + +These functions are not in the external interface, so users do not call them directly. + +They are used inside the contract to keep the code organized. + +### `assert_only_admin` + +```cairo +fn assert_only_admin(self: @ContractState) +``` + +Checks whether the caller is the admin. + +```cairo +assert(get_caller_address() == self.admin.read(), 'Caller not the Owner'); +``` + +If the caller is not the admin, the transaction fails. + +This function is not private because of the name `PrivateTrait`. It is internal because it is not exposed with `#[abi(embed_v0)]`. + +### `_transfer` + +```cairo +fn _transfer( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u128, +) +``` + +This is the main transfer engine. + +Both `transfer` and `transfer_from` call `_transfer`, so this is the best place to enforce the transfer rules. + +The function checks: + +```cairo +assert(sender.is_non_zero(), 'TRANSFER_FROM_ZERO'); +assert(recipient.is_non_zero(), 'TRANSFER_TO_ZERO'); +assert(amount > 0, 'Invalid Amount'); +assert(amount <= self.max_limit.read(), 'Limit Exceeded'); +assert(!self.revoked.read(sender), 'Sender Revoked'); +assert(!self.revoked.read(recipient), 'Recipient Revoked'); +``` + +This means: + +```text +Sender cannot be zero address. +Recipient cannot be zero address. +Amount must be greater than zero. +Amount must be less than or equal to the transfer limit. +Sender must not be revoked. +Recipient must not be revoked. +``` + +Then it checks the sender balance: + +```cairo +let sender_balance = self.balances.read(sender); +assert(sender_balance >= amount, 'Insufficient Balance'); +``` + +If everything is valid, it moves the tokens: + +```cairo +self.balances.write(sender, sender_balance - amount); +self.balances.write(recipient, self.balances.read(recipient) + amount); +``` + +### `spend_allowance` + +```cairo +fn spend_allowance( + ref self: ContractState, + owner: ContractAddress, + spender: ContractAddress, + amount: u128, +) +``` + +Reduces the allowance when `transfer_from` is used. + +It reads the current allowance: + +```cairo +let allowance = self.allowances.read((owner, spender)); +``` + +Then subtracts the amount: + +```cairo +self.allowances.write((owner, spender), allowance - amount); +``` + +This means if Alice approved Bob for `500`, and Bob spends `200`, Bob's remaining allowance becomes `300`. + +### `approve_helper` + +```cairo +fn approve_helper( + ref self: ContractState, + owner: ContractAddress, + spender: ContractAddress, + amount: u128, +) +``` + +Stores an allowance. + +It checks: + +```cairo +assert(spender.is_non_zero(), 'APPROVE_TO_ZERO'); +``` + +Then writes: + +```cairo +self.allowances.write((owner, spender), amount); +``` + +This function is used by the public `approve` function. + +### `mint` + +```cairo +fn mint( + ref self: ContractState, + recipient: ContractAddress, + amount: u128, +) +``` + +Creates new tokens and gives them to a recipient. + +In this contract, `mint` is internal. It is only used inside the constructor. + +It checks: + +```cairo +assert(recipient.is_non_zero(), 'MINT_TO_ZERO'); +``` + +Then it increases total supply: + +```cairo +let supply = self.total_supply.read() + amount; +self.total_supply.write(supply); +``` + +Then it increases the recipient balance: + +```cairo +let balance = self.balances.read(recipient) + amount; +self.balances.write(recipient, balance); +``` + +## Transfer Rules Summary + +A transfer is allowed only when all of these are true: + +```text +sender is not zero address +recipient is not zero address +amount > 0 +amount <= max_limit +sender is not revoked +recipient is not revoked +sender balance >= amount +``` + +If any rule fails, the transaction reverts. + +## Admin Rules Summary + +Only the admin can call: + +```text +update_transfer_limit +burn +revoke +unrevoke +``` + +The admin is the address passed into the constructor during deployment. + +## Important Notes + +This contract currently does not emit ERC20 `Transfer` and `Approval` events. Because of that, block explorers or wallets may not automatically display token activity even if balances are stored correctly in the contract. + +To confirm balances, call `balance_of` directly. + +Amounts are raw token units. Since the token uses 18 decimals, `500000` raw units is much smaller than `500000` full display tokens. + +The class hash is used when deploying a contract. The deployed contract address is used when calling or invoking contract functions. + +## Sncast Setup + +Replace these values before running commands: + +```bash +TOKEN=0xYOUR_DEPLOYED_TOKEN_CONTRACT_ADDRESS +WALLET=0xYOUR_WALLET_ADDRESS +ACCOUNT=Savage +NETWORK=sepolia +``` + +## Sncast Read Commands + +```bash +sncast call --contract-address $TOKEN --function get_name --network $NETWORK +sncast call --contract-address $TOKEN --function get_symbol --network $NETWORK +sncast call --contract-address $TOKEN --function get_decimals --network $NETWORK +sncast call --contract-address $TOKEN --function get_total_supply --network $NETWORK +sncast call --contract-address $TOKEN --function get_transfer_limit --network $NETWORK +``` + +Check balance: + +```bash +sncast call \ + --contract-address $TOKEN \ + --function balance_of \ + --calldata $WALLET \ + --network $NETWORK +``` + +Check allowance: + +```bash +sncast call \ + --contract-address $TOKEN \ + --function allowance \ + --calldata \ + --network $NETWORK +``` + +## Sncast User Transactions + +Transfer: + +```bash +sncast --account $ACCOUNT invoke \ + --contract-address $TOKEN \ + --function transfer \ + --calldata \ + --network $NETWORK +``` + +Approve: + +```bash +sncast --account $ACCOUNT invoke \ + --contract-address $TOKEN \ + --function approve \ + --calldata \ + --network $NETWORK +``` + +Transfer from: + +```bash +sncast --account $ACCOUNT invoke \ + --contract-address $TOKEN \ + --function transfer_from \ + --calldata \ + --network $NETWORK +``` + +## Sncast Admin Transactions + +Update transfer limit: + +```bash +sncast --account $ACCOUNT invoke \ + --contract-address $TOKEN \ + --function update_transfer_limit \ + --calldata \ + --network $NETWORK +``` + +Burn: + +```bash +sncast --account $ACCOUNT invoke \ + --contract-address $TOKEN \ + --function burn \ + --calldata \ + --network $NETWORK +``` + +Revoke: + +```bash +sncast --account $ACCOUNT invoke \ + --contract-address $TOKEN \ + --function revoke \ + --calldata \ + --network $NETWORK +``` + +Unrevoke: + +```bash +sncast --account $ACCOUNT invoke \ + --contract-address $TOKEN \ + --function unrevoke \ + --calldata \ + --network $NETWORK +``` + +## Deploy + +The constructor expects: + +```text +recipient +initial_supply +admin +``` + +Deploy with: + +```bash +sncast --account $ACCOUNT deploy \ + --class-hash \ + --constructor-calldata \ + --network $NETWORK +``` + +## Verify + +Verify with Voyager: + +```bash +sncast verify \ + --contract-address $TOKEN \ + --contract-name ERC20 \ + --verifier voyager \ + --network $NETWORK \ + --confirm-verification +``` + +Verify with Walnut: + +```bash +sncast verify \ + --contract-address $TOKEN \ + --contract-name ERC20 \ + --verifier walnut \ + --network $NETWORK \ + --confirm-verification +``` diff --git a/writing_test/Scarb.lock b/writing_test/Scarb.lock new file mode 100644 index 0000000..a27e181 --- /dev/null +++ b/writing_test/Scarb.lock @@ -0,0 +1,24 @@ +# Code generated by scarb DO NOT EDIT. +version = 1 + +[[package]] +name = "snforge_scarb_plugin" +version = "0.59.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:871fba677c03b66a1bf40815dac0ab1b385eb1b9be6e6c3cf2ad9788eeb2b6bb" + +[[package]] +name = "snforge_std" +version = "0.59.0" +source = "registry+https://scarbs.xyz/" +checksum = "sha256:3620924fa08bd2d740b2b5b01ef86c8dab3d4b9c2206387c8dbdc8d2ec15133e" +dependencies = [ + "snforge_scarb_plugin", +] + +[[package]] +name = "writing_test" +version = "0.1.0" +dependencies = [ + "snforge_std", +] diff --git a/writing_test/Scarb.toml b/writing_test/Scarb.toml new file mode 100644 index 0000000..203d10d --- /dev/null +++ b/writing_test/Scarb.toml @@ -0,0 +1,52 @@ +[package] +name = "writing_test" +version = "0.1.0" +edition = "2024_07" + +# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + +[dependencies] +starknet = "2.17.0" + +[dev-dependencies] +snforge_std = "0.59.0" +assert_macros = "2.17.0" + +[[target.starknet-contract]] +sierra = true + +[scripts] +test = "snforge test" + +[tool.scarb] +allow-prebuilt-plugins = ["snforge_std"] + +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/scarb-toml.html for more information + +# [tool.snforge] # Define `snforge` tool section +# exit_first = true # Stop tests execution immediately upon the first failure +# fuzzer_runs = 1234 # Number of runs of the random fuzzer +# fuzzer_seed = 1111 # Seed for the random fuzzer + +# [[tool.snforge.fork]] # Used for fork testing +# name = "SOME_NAME" # Fork name +# url = "http://your.rpc.url" # Url of the RPC provider +# block_id.tag = "latest" # Block to fork from (block tag) + +# [[tool.snforge.fork]] +# name = "SOME_SECOND_NAME" +# url = "http://your.second.rpc.url" +# block_id.number = "123" # Block to fork from (block number) + +# [[tool.snforge.fork]] +# name = "SOME_THIRD_NAME" +# url = "http://your.third.rpc.url" +# block_id.hash = "0x123" # Block to fork from (block hash) + +# [profile.dev.cairo] # Configure Cairo compiler +# unstable-add-statements-code-locations-debug-info = true # Should be used if you want to use coverage +# unstable-add-statements-functions-debug-info = true # Should be used if you want to use coverage/profiler +# inlining-strategy = "avoid" # Should be used if you want to use coverage + +# [features] # Used for conditional compilation +# enable_for_tests = [] # Feature name and list of other features that should be enabled with it diff --git a/writing_test/TASK_EXPLANATION.md b/writing_test/TASK_EXPLANATION.md new file mode 100644 index 0000000..090057d --- /dev/null +++ b/writing_test/TASK_EXPLANATION.md @@ -0,0 +1,458 @@ +# Transaction Restriction Token Task + +This file explains what was done for the token task and how each requirement was implemented in the contract. + +## Task Objective + +The goal was to modify a simple ERC20-style token so that it enforces a transfer restriction: + +```text +amount <= MAX_LIMIT +``` + +The default maximum transfer limit is: + +```text +MAX_LIMIT = 10000 +``` + +The contract was also required to include: + +```text +revoke functionality +admin-only burn functionality +admin privilege to update transfer limit +conditional logic +validation checks +``` + +## What We Built + +We built an ERC20-style token called `D_Chef` with symbol `chef`. + +The contract allows users to: + +```text +check token information +check balances +transfer tokens +approve another address to spend tokens +use transfer_from after approval +``` + +The contract also gives the admin special powers: + +```text +burn tokens from an account +revoke an account +unrevoke an account +update the transfer limit +``` + +## 1. Added Token Storage + +The contract stores the normal ERC20 values: + +```cairo +name: felt252, +symbol: felt252, +decimals: u8, +total_supply: u128, +balances: Map::, +allowances: Map::<(ContractAddress, ContractAddress), u128>, +``` + +These handle the basic token data. + +`name`, `symbol`, and `decimals` describe the token. + +`total_supply` tracks how many tokens currently exist. + +`balances` tracks how many tokens each address owns. + +`allowances` tracks how many tokens a spender is allowed to spend from an owner. + +## 2. Added Admin Storage + +We added: + +```cairo +admin: ContractAddress, +``` + +This stores the address that has admin privileges. + +The admin is passed into the constructor during deployment: + +```cairo +fn constructor( + ref self: ContractState, + recipient: ContractAddress, + initial_supply: u128, + admin: ContractAddress, +) +``` + +Then it is saved with: + +```cairo +self.admin.write(admin); +``` + +This means the address passed as `admin` during deployment is the only address allowed to call admin-only functions. + +## 3. Added Transfer Limit Storage + +We added: + +```cairo +max_limit: u128, +``` + +This stores the maximum amount that can be transferred at once. + +In the constructor, we set it to: + +```cairo +self.max_limit.write(10000); +``` + +This implements the task requirement: + +```text +MAX_LIMIT = 10000 +``` + +We used storage instead of a fixed constant so the admin can update the limit later. + +## 4. Added Revoke Storage + +We added: + +```cairo +revoked: Map::, +``` + +This tracks whether an account is blocked. + +If: + +```text +revoked[address] = true +``` + +then that address cannot send or receive tokens. + +If: + +```text +revoked[address] = false +``` + +then that address is allowed to transfer again. + +## 5. Added Public Task Functions To The Interface + +We added these function signatures to the interface: + +```cairo +fn burn(ref self: TContractState, account: ContractAddress, amount: u128); +fn update_transfer_limit(ref self: TContractState, new_limit: u128); +fn revoke(ref self: TContractState, account: ContractAddress); +fn unrevoke(ref self: TContractState, account: ContractAddress); +fn get_transfer_limit(self: @TContractState) -> u128; +``` + +These were added because outside users and tests need to call them. + +`burn` lets the admin destroy tokens. + +`update_transfer_limit` lets the admin change the maximum transfer amount. + +`revoke` lets the admin block an account. + +`unrevoke` lets the admin unblock an account. + +`get_transfer_limit` lets anyone read the current transfer limit. + +## 6. Added Admin-Only Validation + +We added this internal helper: + +```cairo +fn assert_only_admin(self: @ContractState ) { + assert(get_caller_address() == self.admin.read(), 'Caller not the Owner'); +} +``` + +This checks that the caller is the stored admin. + +If the caller is not the admin, the transaction fails. + +This helper is used in: + +```text +burn +update_transfer_limit +revoke +unrevoke +``` + +It is internal because it is not exposed with `#[abi(embed_v0)]`. + +The name `PrivateTrait` does not make it private by itself. It is internal because users cannot call it from outside the contract. + +## 7. Implemented Transfer Restriction Logic + +The most important logic was added inside `_transfer`. + +The rule is: + +```cairo +assert(amount <= self.max_limit.read(), 'Limit Exceeded'); +``` + +This means a transfer will only succeed if the amount is less than or equal to the current transfer limit. + +We placed this check inside `_transfer` because both `transfer` and `transfer_from` call `_transfer`. + +The flow is: + +```text +transfer -> _transfer +transfer_from -> spend_allowance -> _transfer +``` + +So by putting the rule in `_transfer`, both normal transfers and allowance-based transfers follow the same restriction. + +## 8. Added Transfer Validation Checks + +Inside `_transfer`, we added these validations: + +```cairo +assert(sender.is_non_zero(), 'TRANSFER_FROM_ZERO'); +assert(recipient.is_non_zero(), 'TRANSFER_TO_ZERO'); +assert(amount > 0, 'Invalid Amount'); +assert(amount <= self.max_limit.read(), 'Limit Exceeded'); +assert(!self.revoked.read(sender), 'Sender Revoked'); +assert(!self.revoked.read(recipient), 'Recipient Revoked'); +``` + +These checks mean: + +```text +sender cannot be the zero address +recipient cannot be the zero address +amount must be greater than zero +amount must not exceed the transfer limit +sender must not be revoked +recipient must not be revoked +``` + +Then we check the sender balance: + +```cairo +let sender_balance = self.balances.read(sender); +assert(sender_balance >= amount, 'Insufficient Balance'); +``` + +This prevents users from transferring more tokens than they own. + +## 9. Implemented Balance Movement + +After all checks pass, `_transfer` moves the tokens: + +```cairo +self.balances.write(sender, sender_balance - amount); +self.balances.write(recipient, self.balances.read(recipient) + amount); +``` + +This subtracts the amount from the sender and adds it to the recipient. + +## 10. Implemented Admin Transfer Limit Update + +We added: + +```cairo +fn update_transfer_limit(ref self: ContractState, new_limit: u128) { + self.assert_only_admin(); + assert(new_limit > 0, 'INVALID_LIMIT'); + self.max_limit.write(new_limit); +} +``` + +This function does three things: + +```text +checks that caller is admin +checks that the new limit is greater than zero +saves the new transfer limit +``` + +This satisfies the requirement: + +```text +admin privilege to update transfer limit +``` + +## 11. Implemented Burn Functionality + +We added: + +```cairo +fn burn(ref self: ContractState, account: ContractAddress, amount: u128) { + self.assert_only_admin(); + assert(account.is_non_zero(), 'BURN FROM ZERO'); + assert(amount > 0, 'INVALID AMOUNT'); + + let balance = self.balances.read(account); + assert(balance >= amount, 'INSUFFICIENT BALANCE'); + + self.balances.write(account, balance - amount); + self.total_supply.write(self.total_supply.read() - amount); +} +``` + +This function allows the admin to destroy tokens from an account. + +It checks: + +```text +caller must be admin +account cannot be zero address +amount must be greater than zero +account must have enough tokens +``` + +Then it subtracts the amount from the account balance and also reduces the total supply. + +This satisfies the requirement: + +```text +add burn functionality privilege called only by admin +``` + +## 12. Implemented Revoke Functionality + +We added: + +```cairo +fn revoke(ref self: ContractState, account: ContractAddress) { + self.assert_only_admin(); + assert(account.is_non_zero(), 'INVALID ACCOUNT'); + self.revoked.write(account, true); +} +``` + +This allows the admin to block an address. + +Once revoked, the address cannot send or receive tokens because `_transfer` checks: + +```cairo +assert(!self.revoked.read(sender), 'Sender Revoked'); +assert(!self.revoked.read(recipient), 'Recipient Revoked'); +``` + +This satisfies the requirement: + +```text +revoke functionality +``` + +## 13. Implemented Unrevoke Functionality + +We added: + +```cairo +fn unrevoke(ref self: ContractState, account: ContractAddress) { + self.assert_only_admin(); + assert(account.is_non_zero(), 'INVALID ACCOUNT'); + self.revoked.write(account, false); +} +``` + +This allows the admin to unblock a revoked account. + +After this, the account can send and receive tokens again. + +## 14. Implemented Read Function For Transfer Limit + +We added: + +```cairo +fn get_transfer_limit(self: @ContractState) -> u128 { + self.max_limit.read() +} +``` + +This allows anyone to check the current transfer limit. + +It is a read-only function, so it uses: + +```cairo +self: @ContractState +``` + +instead of: + +```cairo +ref self: ContractState +``` + +## 15. Added Conditional Logic In The Constructor + +We added: + +```cairo +if initial_supply != 0 { + self.mint(recipient, initial_supply); +} +``` + +This means the contract only mints during deployment if the initial supply is not zero. + +If `initial_supply` is zero, minting is skipped. + +This prevents unnecessary minting and avoids checking the recipient when no tokens are being minted. + +## 16. Internal Mint Logic + +The internal `mint` function creates tokens: + +```cairo +fn mint(ref self: ContractState, recipient: ContractAddress, amount: u128) { + assert(recipient.is_non_zero(), 'MINT_TO_ZERO'); + let supply = self.total_supply.read() + amount; + self.total_supply.write(supply); + let balance = self.balances.read(recipient) + amount; + self.balances.write(recipient, balance); +} +``` + +It checks that the recipient is not the zero address. + +Then it increases total supply and adds tokens to the recipient balance. + +In this contract, `mint` is internal and only used in the constructor. + +## Final Requirement Checklist + +```text +Transfer only if amount <= MAX_LIMIT: done in _transfer +MAX_LIMIT = 10000: done in constructor with max_limit.write(10000) +Revoke functionality: done with revoked map, revoke, and unrevoke +Burn functionality: done with admin-only burn +Admin can update transfer limit: done with update_transfer_limit +Conditional logic: done with constructor initial_supply check and transfer checks +Validation patterns: done with zero address, amount, balance, admin, revoke, and limit checks +``` + +## Important Notes + +The transfer limit is stored as `max_limit`, not as a fixed constant, because the admin needs to update it. + +The transfer restriction was placed in `_transfer`, not only in `transfer`, because both `transfer` and `transfer_from` use `_transfer`. + +The admin is the address passed into the constructor during deployment. + +The contract currently does not emit standard ERC20 `Transfer` and `Approval` events, so explorers or wallets may not automatically display token activity. To confirm balances, call `balance_of` directly. diff --git a/writing_test/scripts/README.md b/writing_test/scripts/README.md new file mode 100644 index 0000000..6097c77 --- /dev/null +++ b/writing_test/scripts/README.md @@ -0,0 +1,48 @@ +# Token interaction scripts + +Copy the example environment file and fill in your deployed token contract address: + +```bash +cp scripts/token.env.example scripts/token.env +``` + +Edit `scripts/token.env`: + +```bash +TOKEN_ADDRESS=0xYOUR_DEPLOYED_TOKEN_CONTRACT_ADDRESS +ACCOUNT=Savage +NETWORK=sepolia +WALLET_ADDRESS=0xYOUR_WALLET_ADDRESS +``` + +Read-only calls: + +```bash +bash scripts/read_token.sh name +bash scripts/read_token.sh symbol +bash scripts/read_token.sh decimals +bash scripts/read_token.sh total_supply +bash scripts/read_token.sh limit +bash scripts/read_token.sh balance $WALLET_ADDRESS +bash scripts/read_token.sh allowance +bash scripts/read_token.sh all $WALLET_ADDRESS +``` + +User transactions: + +```bash +bash scripts/transfer.sh +bash scripts/approve.sh +bash scripts/transfer_from.sh +``` + +Admin transactions: + +```bash +bash scripts/admin_update_limit.sh +bash scripts/admin_burn.sh +bash scripts/admin_revoke.sh +bash scripts/admin_unrevoke.sh +``` + +Amounts are raw token units. Since this token uses 18 decimals, `500000` raw units is not the same as `500000` full display tokens. diff --git a/writing_test/scripts/admin_burn.sh b/writing_test/scripts/admin_burn.sh new file mode 100755 index 0000000..e0377bc --- /dev/null +++ b/writing_test/scripts/admin_burn.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +if [ "$#" -ne 2 ]; then + echo "Usage: bash scripts/admin_burn.sh " >&2 + exit 1 +fi + +invoke_token burn "$1" "$2" diff --git a/writing_test/scripts/admin_revoke.sh b/writing_test/scripts/admin_revoke.sh new file mode 100755 index 0000000..a184dd7 --- /dev/null +++ b/writing_test/scripts/admin_revoke.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +if [ "$#" -ne 1 ]; then + echo "Usage: bash scripts/admin_revoke.sh " >&2 + exit 1 +fi + +invoke_token revoke "$1" diff --git a/writing_test/scripts/admin_unrevoke.sh b/writing_test/scripts/admin_unrevoke.sh new file mode 100755 index 0000000..5f52ab4 --- /dev/null +++ b/writing_test/scripts/admin_unrevoke.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +if [ "$#" -ne 1 ]; then + echo "Usage: bash scripts/admin_unrevoke.sh " >&2 + exit 1 +fi + +invoke_token unrevoke "$1" diff --git a/writing_test/scripts/admin_update_limit.sh b/writing_test/scripts/admin_update_limit.sh new file mode 100755 index 0000000..f19bba4 --- /dev/null +++ b/writing_test/scripts/admin_update_limit.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +if [ "$#" -ne 1 ]; then + echo "Usage: bash scripts/admin_update_limit.sh " >&2 + exit 1 +fi + +invoke_token update_transfer_limit "$1" diff --git a/writing_test/scripts/approve.sh b/writing_test/scripts/approve.sh new file mode 100755 index 0000000..6aa22e1 --- /dev/null +++ b/writing_test/scripts/approve.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +if [ "$#" -ne 2 ]; then + echo "Usage: bash scripts/approve.sh " >&2 + exit 1 +fi + +invoke_token approve "$1" "$2" diff --git a/writing_test/scripts/common.sh b/writing_test/scripts/common.sh new file mode 100755 index 0000000..b72f7ba --- /dev/null +++ b/writing_test/scripts/common.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/token.env" ]; then + set -a + # shellcheck disable=SC1091 + . "$SCRIPT_DIR/token.env" + set +a +fi + +NETWORK="${NETWORK:-sepolia}" +ACCOUNT="${ACCOUNT:-Savage}" + +require_env() { + local name="$1" + if [ -z "${!name:-}" ]; then + echo "Missing $name. Copy scripts/token.env.example to scripts/token.env and set $name." >&2 + exit 1 + fi +} + +call_token() { + require_env TOKEN_ADDRESS + local function_name="$1" + shift + + if [ "$#" -gt 0 ]; then + sncast call \ + --contract-address "$TOKEN_ADDRESS" \ + --function "$function_name" \ + --calldata "$@" \ + --network "$NETWORK" + else + sncast call \ + --contract-address "$TOKEN_ADDRESS" \ + --function "$function_name" \ + --network "$NETWORK" + fi +} + +invoke_token() { + require_env TOKEN_ADDRESS + local function_name="$1" + shift + + if [ "$#" -gt 0 ]; then + sncast --account "$ACCOUNT" invoke \ + --contract-address "$TOKEN_ADDRESS" \ + --function "$function_name" \ + --calldata "$@" \ + --network "$NETWORK" + else + sncast --account "$ACCOUNT" invoke \ + --contract-address "$TOKEN_ADDRESS" \ + --function "$function_name" \ + --network "$NETWORK" + fi +} diff --git a/writing_test/scripts/read_token.sh b/writing_test/scripts/read_token.sh new file mode 100755 index 0000000..5be8754 --- /dev/null +++ b/writing_test/scripts/read_token.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +usage() { + cat <<'EOF' +Usage: + bash scripts/read_token.sh name + bash scripts/read_token.sh symbol + bash scripts/read_token.sh decimals + bash scripts/read_token.sh total_supply + bash scripts/read_token.sh limit + bash scripts/read_token.sh balance + bash scripts/read_token.sh allowance + bash scripts/read_token.sh all +EOF +} + +command="${1:-}" + +case "$command" in + name) + call_token get_name + ;; + symbol) + call_token get_symbol + ;; + decimals) + call_token get_decimals + ;; + total_supply) + call_token get_total_supply + ;; + limit) + call_token get_transfer_limit + ;; + balance) + if [ "$#" -ne 2 ]; then usage; exit 1; fi + call_token balance_of "$2" + ;; + allowance) + if [ "$#" -ne 3 ]; then usage; exit 1; fi + call_token allowance "$2" "$3" + ;; + all) + if [ "$#" -ne 2 ]; then usage; exit 1; fi + echo "Name:" + call_token get_name + echo "Symbol:" + call_token get_symbol + echo "Decimals:" + call_token get_decimals + echo "Total supply:" + call_token get_total_supply + echo "Transfer limit:" + call_token get_transfer_limit + echo "Balance:" + call_token balance_of "$2" + ;; + *) + usage + exit 1 + ;; +esac diff --git a/writing_test/scripts/token.env.example b/writing_test/scripts/token.env.example new file mode 100644 index 0000000..caa799f --- /dev/null +++ b/writing_test/scripts/token.env.example @@ -0,0 +1,13 @@ +# Copy this file to scripts/token.env and fill in your values. + +# Your deployed token contract address, not the class hash. +TOKEN_ADDRESS=0xYOUR_DEPLOYED_TOKEN_CONTRACT_ADDRESS + +# Your Starknet account name from sncast accounts. +ACCOUNT=Savage + +# Network to use. +NETWORK=sepolia + +# Optional convenience address for balance checks. +WALLET_ADDRESS=0xYOUR_WALLET_ADDRESS diff --git a/writing_test/scripts/transfer.sh b/writing_test/scripts/transfer.sh new file mode 100755 index 0000000..4d83e66 --- /dev/null +++ b/writing_test/scripts/transfer.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +if [ "$#" -ne 2 ]; then + echo "Usage: bash scripts/transfer.sh " >&2 + exit 1 +fi + +invoke_token transfer "$1" "$2" diff --git a/writing_test/scripts/transfer_from.sh b/writing_test/scripts/transfer_from.sh new file mode 100755 index 0000000..83ec266 --- /dev/null +++ b/writing_test/scripts/transfer_from.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/common.sh" + +if [ "$#" -ne 3 ]; then + echo "Usage: bash scripts/transfer_from.sh " >&2 + exit 1 +fi + +invoke_token transfer_from "$1" "$2" "$3" diff --git a/writing_test/snfoundry.toml b/writing_test/snfoundry.toml new file mode 100644 index 0000000..686c2ab --- /dev/null +++ b/writing_test/snfoundry.toml @@ -0,0 +1,11 @@ +# Visit https://foundry-rs.github.io/starknet-foundry/appendix/snfoundry-toml.html +# and https://foundry-rs.github.io/starknet-foundry/projects/configuration.html for more information + +# [sncast.default] # Define a profile name +# url = "https://api.zan.top/public/starknet-sepolia/rpc/v0_10" # Url of the RPC provider +# accounts-file = "../account-file" # Path to the file with the account data +# account = "mainuser" # Account from `accounts_file` or default account file that will be used for the transactions +# keystore = "~/keystore" # Path to the keystore file +# wait-params = { timeout = 300, retry-interval = 10 } # Wait for submitted transaction parameters +# block-explorer = "Voyager" # Block explorer service used to display links to transaction details +# show-explorer-links = true # Print links pointing to pages with transaction details in the chosen block explorer diff --git a/writing_test/src/lib.cairo b/writing_test/src/lib.cairo new file mode 100644 index 0000000..9b4fdde --- /dev/null +++ b/writing_test/src/lib.cairo @@ -0,0 +1,241 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IERC20 { + fn get_name(self: @TContractState) -> felt252; + fn get_symbol(self: @TContractState) -> felt252; + fn get_decimals(self: @TContractState) -> u8; + fn get_total_supply(self: @TContractState) -> u128; + fn balance_of(self: @TContractState, account: ContractAddress) -> u128; + fn allowance( + self: @TContractState, owner: ContractAddress, spender: ContractAddress, + ) -> u128; + fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u128); + fn transfer_from( + ref self: TContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u128, + ); + fn approve(ref self: TContractState, spender: ContractAddress, amount: u128); + + // Added public functions needed. + fn burn(ref self: TContractState, account: ContractAddress, amount: u128); + fn update_transfer_limit(ref self: TContractState, new_limit: u128); + fn revoke(ref self: TContractState, account: ContractAddress); + fn unrevoke(ref self: TContractState, account: ContractAddress); + fn get_transfer_limit(self: @TContractState) -> u128; +} + + + +#[starknet::contract] +pub mod ERC20 { + use core::num::traits::Zero; + use starknet::get_caller_address; + use starknet::ContractAddress; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + + + + + #[storage] + struct Storage { + name: felt252, + symbol: felt252, + decimals: u8, + total_supply: u128, + balances: Map::, + allowances: Map::<(ContractAddress, ContractAddress), u128>, + admin: ContractAddress, + revoked: Map::, + max_limit: u128, + } + + + + #[constructor] + fn constructor( + ref self: ContractState, + recipient: ContractAddress, + initial_supply: u128, + admin: ContractAddress, + ) { + self.admin.write(admin); + self.name.write('D_Chef'); + self.symbol.write('chef'); + self.decimals.write(18); + + if initial_supply != 0 { + self.mint(recipient, initial_supply); + } + + self.max_limit.write(10000); + } + + #[generate_trait] + impl PrivateImpl of PrivateTrait { + fn assert_only_admin(self: @ContractState ) { + assert(get_caller_address() == self.admin.read(), 'Caller not the Owner'); + } + } + + + + + #[abi(embed_v0)] + impl IERC20Impl of super::IERC20 { + fn get_name(self: @ContractState) -> felt252 { + self.name.read() + } + + fn get_symbol(self: @ContractState) -> felt252 { + self.symbol.read() + } + + fn get_decimals(self: @ContractState) -> u8 { + self.decimals.read() + } + + fn get_total_supply(self: @ContractState) -> u128 { + self.total_supply.read() + } + + fn balance_of(self: @ContractState, account: ContractAddress) -> u128 { + self.balances.read(account) + } + + fn allowance( + self: @ContractState, owner: ContractAddress, spender: ContractAddress, + ) -> u128 { + self.allowances.read((owner, spender)) + } + + fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u128) { + let sender = get_caller_address(); + self._transfer(sender, recipient, amount); + } + + fn transfer_from( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u128, + ) { + let caller = get_caller_address(); + self.spend_allowance(sender, caller, amount); + self._transfer(sender, recipient, amount); + } + + fn approve(ref self: ContractState, spender: ContractAddress, amount: u128) { + let caller = get_caller_address(); + self.approve_helper(caller, spender, amount); + } + + + + fn update_transfer_limit(ref self: ContractState, new_limit: u128) { + self.assert_only_admin(); + assert(new_limit > 0, 'INVALID_LIMIT'); + self.max_limit.write(new_limit); + } + + /// Burning is not sending tokens to zero address storage. + // Burning is decreasing balance and decreasing total supply. + // The zero address is used in the Transfer event to represent destruction. + + fn burn(ref self: ContractState, account: ContractAddress, amount: u128) { + self.assert_only_admin(); + assert(account.is_non_zero(), 'BURN FROM ZERO'); + assert(amount > 0, 'INVALID AMOUNT'); + + let balance = self.balances.read(account); + assert(balance >= amount, 'INSUFFICIENT BALANCE'); + + self.balances.write(account, balance - amount); + self.total_supply.write(self.total_supply.read() - amount); + } + + fn revoke(ref self: ContractState, account: ContractAddress) { + self.assert_only_admin(); + assert(account.is_non_zero(), 'INVALID ACCOUNT'); + self.revoked.write(account, true); + } + + fn unrevoke(ref self: ContractState, account: ContractAddress) { + self.assert_only_admin(); + assert(account.is_non_zero(), 'INVALID ACCOUNT'); + self.revoked.write(account, false); + } + + fn get_transfer_limit(self: @ContractState) -> u128 { + self.max_limit.read() + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _transfer( + ref self: ContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: u128, + ) { + self.assert_only_admin(); + assert(sender.is_non_zero(), 'TRANSFER_FROM_ZERO'); + assert(recipient.is_non_zero(), 'TRANSFER_TO_ZERO'); + assert(amount > 0, 'Invalid Amount'); + assert(amount <= self.max_limit.read(), 'Limit Exceeded'); + + assert(!self.revoked.read(sender), 'Sender Revoked'); + assert(!self.revoked.read(recipient), 'Recipient Revoked'); + + let sender_balance = self.balances.read(sender); + assert(sender_balance >= amount, 'Insufficient Balance'); + + + self.balances.write(sender, sender_balance - amount); + self.balances.write(recipient, self.balances.read(recipient) + amount); + } + + fn spend_allowance( + ref self: ContractState, + owner: ContractAddress, + spender: ContractAddress, + amount: u128, + ) { + let allowance = self.allowances.read((owner, spender)); + self.allowances.write((owner, spender), allowance - amount); + } + + fn approve_helper( + ref self: ContractState, + owner: ContractAddress, + spender: ContractAddress, + amount: u128, + ) { + assert(spender.is_non_zero(), 'APPROVE_TO_ZERO'); + self.assert_only_admin(); + self.allowances.write((owner, spender), amount); + } + + fn mint(ref self: ContractState, recipient: ContractAddress, amount: u128) { + self.assert_only_admin(); + assert(recipient.is_non_zero(), 'MINT_TO_ZERO'); + + let supply = self.total_supply.read() + amount; + self.total_supply.write(supply); + + let balance = self.balances.read(recipient) + amount; + self.balances.write(recipient, balance); + + } + } +} + + + + diff --git a/writing_test/tests/test_contract.cairo b/writing_test/tests/test_contract.cairo new file mode 100644 index 0000000..88d0462 --- /dev/null +++ b/writing_test/tests/test_contract.cairo @@ -0,0 +1,47 @@ +use starknet::ContractAddress; + +use snforge_std::{declare, ContractClassTrait, DeclareResultTrait}; + +use writing_test::IHelloStarknetSafeDispatcher; +use writing_test::IHelloStarknetSafeDispatcherTrait; +use writing_test::IHelloStarknetDispatcher; +use writing_test::IHelloStarknetDispatcherTrait; + +fn deploy_contract(name: ByteArray) -> ContractAddress { + let contract = declare(name).unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap(); + contract_address +} + +#[test] +fn test_increase_balance() { + let contract_address = deploy_contract("HelloStarknet"); + + let dispatcher = IHelloStarknetDispatcher { contract_address }; + + let balance_before = dispatcher.get_balance(); + assert(balance_before == 0, 'Invalid balance'); + + dispatcher.increase_balance(42); + + let balance_after = dispatcher.get_balance(); + assert(balance_after == 42, 'Invalid balance'); +} + +#[test] +#[feature("safe_dispatcher")] +fn test_cannot_increase_balance_with_zero_value() { + let contract_address = deploy_contract("HelloStarknet"); + + let safe_dispatcher = IHelloStarknetSafeDispatcher { contract_address }; + + let balance_before = safe_dispatcher.get_balance().unwrap(); + assert(balance_before == 0, 'Invalid balance'); + + match safe_dispatcher.increase_balance(0) { + Result::Ok(_) => core::panic_with_felt252('Should have panicked'), + Result::Err(panic_data) => { + assert(*panic_data.at(0) == 'Amount cannot be 0', *panic_data.at(0)); + } + }; +} From a8f0b69cd150d427678e527dfb1a235a08b4160b Mon Sep 17 00:00:00 2001 From: savagechucks Date: Thu, 21 May 2026 16:29:27 +0100 Subject: [PATCH 4/4] chore: cleaning errors --- writing_test/src/lib.cairo | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/writing_test/src/lib.cairo b/writing_test/src/lib.cairo index 9b4fdde..29ce5b9 100644 --- a/writing_test/src/lib.cairo +++ b/writing_test/src/lib.cairo @@ -23,7 +23,6 @@ pub trait IERC20 { fn burn(ref self: TContractState, account: ContractAddress, amount: u128); fn update_transfer_limit(ref self: TContractState, new_limit: u128); fn revoke(ref self: TContractState, account: ContractAddress); - fn unrevoke(ref self: TContractState, account: ContractAddress); fn get_transfer_limit(self: @TContractState) -> u128; } @@ -51,7 +50,6 @@ pub mod ERC20 { balances: Map::, allowances: Map::<(ContractAddress, ContractAddress), u128>, admin: ContractAddress, - revoked: Map::, max_limit: u128, } @@ -135,8 +133,6 @@ pub mod ERC20 { self.approve_helper(caller, spender, amount); } - - fn update_transfer_limit(ref self: ContractState, new_limit: u128) { self.assert_only_admin(); assert(new_limit > 0, 'INVALID_LIMIT'); @@ -145,7 +141,7 @@ pub mod ERC20 { /// Burning is not sending tokens to zero address storage. // Burning is decreasing balance and decreasing total supply. - // The zero address is used in the Transfer event to represent destruction. + // The zero address is used in the Transfer event to represent destruction fn burn(ref self: ContractState, account: ContractAddress, amount: u128) { self.assert_only_admin(); @@ -159,16 +155,14 @@ pub mod ERC20 { self.total_supply.write(self.total_supply.read() - amount); } + + fn revoke(ref self: ContractState, account: ContractAddress) { - self.assert_only_admin(); + // self.assert_only_admin(); + let owner = get_caller_address(); assert(account.is_non_zero(), 'INVALID ACCOUNT'); - self.revoked.write(account, true); - } - fn unrevoke(ref self: ContractState, account: ContractAddress) { - self.assert_only_admin(); - assert(account.is_non_zero(), 'INVALID ACCOUNT'); - self.revoked.write(account, false); + self.allowances.write((owner, account), 0); } fn get_transfer_limit(self: @ContractState) -> u128 { @@ -184,15 +178,11 @@ pub mod ERC20 { recipient: ContractAddress, amount: u128, ) { - self.assert_only_admin(); assert(sender.is_non_zero(), 'TRANSFER_FROM_ZERO'); assert(recipient.is_non_zero(), 'TRANSFER_TO_ZERO'); assert(amount > 0, 'Invalid Amount'); assert(amount <= self.max_limit.read(), 'Limit Exceeded'); - assert(!self.revoked.read(sender), 'Sender Revoked'); - assert(!self.revoked.read(recipient), 'Recipient Revoked'); - let sender_balance = self.balances.read(sender); assert(sender_balance >= amount, 'Insufficient Balance'); @@ -218,24 +208,24 @@ pub mod ERC20 { amount: u128, ) { assert(spender.is_non_zero(), 'APPROVE_TO_ZERO'); - self.assert_only_admin(); self.allowances.write((owner, spender), amount); } fn mint(ref self: ContractState, recipient: ContractAddress, amount: u128) { - self.assert_only_admin(); assert(recipient.is_non_zero(), 'MINT_TO_ZERO'); let supply = self.total_supply.read() + amount; self.total_supply.write(supply); - + let balance = self.balances.read(recipient) + amount; self.balances.write(recipient, balance); } + + + } } -