Transaction
48407FBF306B18…42488E9B3B09
Block 265,888 · index 0 · indexed
Summary
- Hash
- 48407FBF306B182C7C7D39D112845E771ABC84332EF371654B4442488E9B3B09
- Block
- 265,888
- Size
- 38805 bytes
- Gas used
- 54,701,805 / 90,000,000
- Fee
- 180000ugnot
- Status
- success
Messages
- Attached funds
- 8000000ugnot
- Package
- gno.land/r/samcrew/escrow_v3
Arguments · 17
- #1escrow_v3
- #2admin.gno
- #3package escrow_v3 // Admin authority and its rotation path. // // No admin is compiled in: `admin` below is seeded at package load from the // publishing transaction's signer (on gnoland-1 the samcrew namespace // multisig, the stamped creator at enable time). Every gate in this realm // reads the mutable `admin`, so the address that holds admin can be changed // after deployment. // // WHY THIS IS NOT OPTIONAL ON MAINNET. Realms are immutable once published and // there is no faucet; mainnet genesis additionally locks ugnot transfers under // Constitution §126. A compile-time admin therefore cannot be corrected after // the fact. A compile-time testnet key would permanently disable Pause/Unpause // and ResolveDispute, the only path that settles a disputed milestone, // stranding custodied funds with nobody able to move them. Redeploying at a // new path is not a recovery: it abandons the funds already held at this one. // // TWO-STEP BY DESIGN. The handoff stages a pending address that must claim it // with its own transaction. A one-step setter would let admin be handed to an // address that cannot act — a typo, or an unfunded address under §126 — with no // way back, which is precisely the failure this file exists to prevent. // // CALLER AUTH: `cur.IsCurrent()` then `cur.Previous().Address()`, matching // memba_arcade_leaderboard_v1 / memba_points_v1 and the project's contract-review // checklist #9. This file deliberately imports none of the frame-unverified // caller package that checklist flags — a new path should not inherit it. // // escrow.gno's older gates still read the frame-unverified caller and stay // grandfathered in antipattern-prevrealm-baseline.txt. That is not a dangerous // mix: for the direct multisig calls these entrypoints are for, both forms // resolve to the same address, and `IsCurrent()` rejects the sibling/stale-`cur` // cases where they could differ at all — so this file is strictly the tighter // of the two. import "chain" var ( // admin is the LIVE authority, seeded with the publisher at package load. // Initialized at declaration rather than in init() so there is no // init-ordering question about gates that read it. admin address = publisherAtLoad() // feeFallback receives the protocol fee only when memba_market_config // reports no treasury (see resolveFee). It is the publisher too, so a config // misread routes fees to the namespace multisig rather than to a testnet // key or to nobody. feeFallback address = publisherAtLoad() // pendingAdmin is the staged successor; "" when no handoff is in flight. pendingAdmin address ) // TransferOwnership stages a handoff to newAdmin. Admin only. The transfer does // not take effect until newAdmin calls AcceptOwnership, so admin is never moved // to an address that has not demonstrated it can transact. // // Calling it again before acceptance replaces the staged address, which is how // a mistyped proposal is corrected. func TransferOwnership(cur realm, newAdmin address) { assertAdmin(cur) if newAdmin == "" { panic("newAdmin must be non-empty") } pendingAdmin = newAdmin chain.Emit("OwnershipTransferStarted", "pending", newAdmin.String()) } // AcceptOwnership completes the handoff. Only the staged pendingAdmin may call // it — including against the outgoing admin, so the two steps cannot be // collapsed into one by the party giving up the role. func AcceptOwnership(cur realm) { if !cur.IsCurrent() { panic("spoofed realm") } if pendingAdmin == "" { panic("no pending ownership transfer") } if cur.Previous().Address() != pendingAdmin { panic("unauthorized: only the pending admin may accept") } admin = pendingAdmin pendingAdmin = "" chain.Emit("OwnershipTransferAccepted", "admin", admin.String()) } // CancelOwnershipTransfer clears a staged handoff. Admin only — the outgoing // admin must be able to abort a proposal it no longer wants, without needing // the proposed address to cooperate. func CancelOwnershipTransfer(cur realm) { assertAdmin(cur) if pendingAdmin == "" { panic("no pending ownership transfer") } cancelled := pendingAdmin pendingAdmin = "" chain.Emit("OwnershipTransferCancelled", "cancelled", cancelled.String()) } // GetAdmin returns the address that currently holds admin. func GetAdmin() string { return admin.String() } // GetPendingAdmin returns the staged successor ("" when none is in flight). func GetPendingAdmin() string { return pendingAdmin.String() } // assertAdmin is the authorization gate for this file's entrypoints. It rejects // a stale/sibling `cur` first, then takes the caller from cur.Previous(). func assertAdmin(cur realm) { if !cur.IsCurrent() { panic("spoofed realm") } if cur.Previous().Address() != admin { panic("unauthorized: only admin may perform this action") } }
- #4escrow.gno
- #5package escrow_v3 // Milestone-based Escrow — On-chain freelance service contracts for Memba. // // Flow: CreateContract → FundMilestone → CompleteMilestone → ReleaseFunds // Disputes: RaiseDispute → admin resolves (or auto-resolves after timeout) // Timeouts: ClaimRefund (auto-refund if milestone not completed after N blocks) // ClaimDisputeTimeout (auto-release to freelancer if admin doesn't act) // // Security: // - STATE-BEFORE-SEND: All state updates happen before SendCoins calls // - fee fail-safe recipient seeded from the publisher at load (admin.gno) // - Self-hire prevention: freelancer != client // - Input validation: title/description length, milestone amounts > 0 // - Auto-refund: prevents permanent fund locking // - Dispute timeout: prevents permanent dispute locking // // Render() contract: // Home — Render(""): // # Escrow Contracts // | ID | Title | Client | Freelancer | Status | Total | // // Detail — Render("contract/ID"): // # Title // **Client:** g1... // **Freelancer:** g1... // **Status:** active // ## Milestones // - **Milestone Title** — 1000000 ugnot [funded] import ( "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "gno.land/p/samcrew/avl" "gno.land/p/nt/ufmt/v0" cfg "gno.land/r/samcrew/memba_market_config" ) // ── Constants ──────────────────────────────────────────────── // laneService is this engine's lane key into the DAO fee spine (memba_market_config). // Seeded there at 200 bps (2.0%) — see memba_market_config/config.gno's init(), which // explicitly documents "service" as escrow's release fee lane. const laneService = "service" const ( // No authority address is compiled in. admin.gno seeds the live `admin` and // the fee fail-safe `feeFallback` from the publishing transaction's signer at // package load (on gnoland-1 the samcrew namespace multisig, the stamped // creator at enable time); admin is rotated via TransferOwnership/ // AcceptOwnership. Do not compare a caller against a constant: on mainnet a // compile-time authority is unrecoverable (immutable realm, no faucet). // PlatformFeePct is now only the FAIL-SAFE fallback bps source (see resolveFee) — // the live path reads cfg.GetFeeBPS(laneService). Kept as a 0-100 percent (not bps) // for backward-compat readability; resolveFee converts it. PlatformFeePct = 2 // 2% fallback only CancelFeePct = 5 // 5% cancellation fee — escrow-internal, NOT on the fee spine // (paid to the freelancer as compensation, not a protocol fee) AutoRefundBlks = int64(864000) // ~30 days at 3s/block AutoResolveBlks = int64(806400) // ~28 days at 3s/block MaxTitleLen = 200 MaxDescLen = 5000 MaxMilestones = 20 MaxContracts = 500 MinMilestoneAmount = int64(1000) // 0.001 GNOT — prevents fee evasion via truncation ) // ── Types ──────────────────────────────────────────────────── type ContractStatus string const ( StatusActive ContractStatus = "active" StatusCompleted ContractStatus = "completed" StatusDisputed ContractStatus = "disputed" StatusCancelled ContractStatus = "cancelled" ) type MilestoneStatus string const ( MsPending MilestoneStatus = "pending" MsFunded MilestoneStatus = "funded" MsCompleted MilestoneStatus = "completed" MsReleased MilestoneStatus = "released" MsDisputed MilestoneStatus = "disputed" MsRefunded MilestoneStatus = "refunded" ) type Contract struct { ID string Client address Freelancer address Title string Description string Status ContractStatus CreatedAt int64 // block height Milestones []Milestone } type Milestone struct { ID int Title string Amount int64 // ugnot Status MilestoneStatus FundedAt int64 // block height (0 if not funded) CompletedAt int64 // block height (0 if not completed) DisputedAt int64 // block height (0 if not disputed) PreDisputeStatus MilestoneStatus // status before dispute (MsFunded or MsCompleted) } // ── State ──────────────────────────────────────────────────── var ( contracts *avl.Tree // id -> *Contract nextID int paused bool totalLiable int64 // NF-2: ugnot owed to funded/disputed milestones — see getters.gno ) func init() { contracts = avl.NewTree() } // resolveFee reads the "service" lane protocol fee (bps) and treasury from the DAO // fee spine (memba_market_config), same fail-safe pattern as memba_nft_market_v3_2's // resolveFee and memba_token_otc_v2's Fill: the config getters are pure and // non-failing, and on any implausible value this falls back to the engine's own // frozen constants rather than reverting a client's release/refund — a config // misread must never strand escrowed funds. func resolveFee() (int64, address) { bps := int64(cfg.GetFeeBPS(laneService)) if bps < 0 || bps > cfg.MaxFeeBPS { bps = int64(PlatformFeePct) * 100 // fallback, expressed in bps } treasury := cfg.GetTreasury() if treasury == "" { treasury = feeFallback // fallback to the publisher-seeded recipient } return bps, treasury } // ── Emergency Pause ──────────────────────────────────────── func assertNotPaused() { if paused { panic("realm is paused — emergency maintenance") } } // Pause halts all write operations. Admin only. func Pause(cur realm) { caller := unsafe.PreviousRealm().Address() if caller != admin { panic("only admin can pause") } paused = true } // Unpause resumes normal operations. Admin only. func Unpause(cur realm) { caller := unsafe.PreviousRealm().Address() if caller != admin { panic("only admin can unpause") } paused = false } // IsPaused returns the current pause state. func IsPaused() bool { return paused } // ── Contract Lifecycle ────────────────────────────────────── // CreateContract creates a new escrow contract with milestones. // Milestones format: "title1:amount1,title2:amount2" func CreateContract(cur realm, freelancer address, title, description, milestones string) string { assertNotPaused() caller := unsafe.PreviousRealm().Address() // Validations if freelancer == caller { panic("cannot hire yourself") } if len(title) == 0 || len(title) > MaxTitleLen { panic(ufmt.Sprintf("title must be 1-%d characters", MaxTitleLen)) } if len(description) > MaxDescLen { panic(ufmt.Sprintf("description must be under %d characters", MaxDescLen)) } if contracts.Size() >= MaxContracts { panic("contract limit reached") } ms := parseMilestones(milestones) if len(ms) == 0 { panic("at least one milestone required") } if len(ms) > MaxMilestones { panic(ufmt.Sprintf("maximum %d milestones allowed", MaxMilestones)) } id := strconv.Itoa(nextID) nextID++ c := &Contract{ ID: id, Client: caller, Freelancer: freelancer, Title: sanitizeMilestoneTitle(title), Description: sanitizeMilestoneTitle(description), Status: StatusActive, CreatedAt: runtime.ChainHeight(), Milestones: ms, } contracts.Set(id, c) chain.Emit("ContractCreated", "id", id, "client", caller.String(), "freelancer", freelancer.String(), "milestones", strconv.Itoa(len(ms)), ) return id } // FundMilestone deposits funds for a specific milestone. Client only. func FundMilestone(cur realm, contractId string, milestoneIdx int) { assertNotPaused() // P0 fund-guard: a payable entrypoint MUST be a direct user call. unsafe.OriginSend() // reports the tx-level `--send`, credited to the DIRECT message target realm — not to // an inner realm reached via a cross-call. Without this guard an attacker funds a // milestone through their own intermediary realm (coins land there), the escrow marks // the milestone funded though it received nothing, then the attacker extracts real // pooled client funds via ReleaseFunds/ClaimRefund. Requiring a direct user call forces // this realm to be the message target, the one case where the OriginSend coins are its own. if !unsafe.PreviousRealm().IsUserCall() { panic("FundMilestone must be a direct user call") } caller := unsafe.PreviousRealm().Address() c := getContract(contractId) if c.Client != caller { panic("only client can fund") } if c.Status != StatusActive { panic("contract not active") } if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) { panic("invalid milestone index") } ms := &c.Milestones[milestoneIdx] if ms.Status != MsPending { panic("milestone already funded or processed") } // Verify coins sent (accumulate to handle multi-entry defensively) sent := unsafe.OriginSend() sentAmount := int64(0) for _, coin := range sent { if coin.Denom == "ugnot" { sentAmount += coin.Amount } } if sentAmount != ms.Amount { panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", ms.Amount, sentAmount)) } ms.Status = MsFunded ms.FundedAt = runtime.ChainHeight() contracts.Set(contractId, c) totalLiable += ms.Amount chain.Emit("MilestoneFunded", "contractId", contractId, "milestone", strconv.Itoa(milestoneIdx), "amount", strconv.FormatInt(ms.Amount, 10), ) } // CompleteMilestone marks a milestone as completed. Freelancer only. func CompleteMilestone(cur realm, contractId string, milestoneIdx int) { assertNotPaused() caller := unsafe.PreviousRealm().Address() c := getContract(contractId) if c.Freelancer != caller { panic("only freelancer can mark complete") } // Only allow completion when contract is Active. Disputed contracts are frozen // until ResolveDispute/ClaimDisputeTimeout returns the contract to Active. if c.Status != StatusActive { panic("contract is " + string(c.Status) + " — cannot complete milestones during dispute") } if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) { panic("invalid milestone index") } ms := &c.Milestones[milestoneIdx] if ms.Status != MsFunded { panic("milestone not funded") } ms.Status = MsCompleted ms.CompletedAt = runtime.ChainHeight() contracts.Set(contractId, c) chain.Emit("MilestoneCompleted", "contractId", contractId, "milestone", strconv.Itoa(milestoneIdx), "freelancer", caller.String(), ) } // ReleaseFunds releases funds to freelancer after client approves. Client or Admin. func ReleaseFunds(cur realm, contractId string, milestoneIdx int) { assertNotPaused() caller := unsafe.PreviousRealm().Address() c := getContract(contractId) if c.Client != caller && caller != admin { panic("only client or admin can release") } // Only allow release when contract is Active. Disputed contracts are frozen // until ResolveDispute/ClaimDisputeTimeout returns the contract to Active. if c.Status != StatusActive { panic("contract is " + string(c.Status) + " — cannot release funds during dispute") } if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) { panic("invalid milestone index") } ms := &c.Milestones[milestoneIdx] if ms.Status != MsCompleted { panic("milestone not completed") } // Calculate fees — read live from the DAO fee spine (fail-safe fallback inside). bps, treasury := resolveFee() platformAmount := basisPointsFee(ms.Amount, bps) freelancerAmount := ms.Amount - platformAmount // STATE-BEFORE-SEND: update all state before any coin transfers ms.Status = MsReleased if allMilestonesReleased(c) { c.Status = StatusCompleted } contracts.Set(contractId, c) totalLiable -= ms.Amount // Transfer funds bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) realmAddr := unsafe.CurrentRealm().Address() bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)}) if platformAmount > 0 { bnk.SendCoins(realmAddr, treasury, chain.Coins{chain.NewCoin("ugnot", platformAmount)}) } chain.Emit("FundsReleased", "contractId", contractId, "milestone", strconv.Itoa(milestoneIdx), "freelancer", c.Freelancer.String(), "amount", strconv.FormatInt(freelancerAmount, 10), "fee", strconv.FormatInt(platformAmount, 10), ) } // ── Disputes ──────────────────────────────────────────────── // RaiseDispute escalates a milestone to admin arbitration. Client or Freelancer. func RaiseDispute(cur realm, contractId string, milestoneIdx int) { assertNotPaused() caller := unsafe.PreviousRealm().Address() c := getContract(contractId) if c.Client != caller && c.Freelancer != caller { panic("only client or freelancer can dispute") } if c.Status == StatusCancelled || c.Status == StatusCompleted { panic("contract is " + string(c.Status)) } if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) { panic("invalid milestone index") } ms := &c.Milestones[milestoneIdx] if ms.Status != MsFunded && ms.Status != MsCompleted { panic("can only dispute funded or completed milestones") } // Capture pre-dispute status so ClaimDisputeTimeout can resolve fairly: // if work was delivered (MsCompleted), pay freelancer; if not (MsFunded), refund client. ms.PreDisputeStatus = ms.Status ms.Status = MsDisputed ms.DisputedAt = runtime.ChainHeight() c.Status = StatusDisputed contracts.Set(contractId, c) chain.Emit("DisputeRaised", "contractId", contractId, "milestone", strconv.Itoa(milestoneIdx), "raisedBy", caller.String(), ) } // ResolveDispute resolves a dispute. Admin only. // refundClient=true refunds to client, false pays freelancer. func ResolveDispute(cur realm, contractId string, milestoneIdx int, refundClient bool) { caller := unsafe.PreviousRealm().Address() if caller != admin { panic("only admin can resolve disputes") } c := getContract(contractId) if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) { panic("invalid milestone index") } ms := &c.Milestones[milestoneIdx] if ms.Status != MsDisputed { panic("milestone not in dispute") } // STATE-BEFORE-SEND: update state before transfers if refundClient { ms.Status = MsRefunded } else { ms.Status = MsReleased } // Reset contract status if no other milestones are disputed c.Status = StatusActive for _, m := range c.Milestones { if m.Status == MsDisputed { c.Status = StatusDisputed break } } if allMilestonesReleased(c) { c.Status = StatusCompleted } contracts.Set(contractId, c) totalLiable -= ms.Amount bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) realmAddr := unsafe.CurrentRealm().Address() if refundClient { bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", ms.Amount)}) } else { bps, treasury := resolveFee() platformAmount := basisPointsFee(ms.Amount, bps) freelancerAmount := ms.Amount - platformAmount bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)}) if platformAmount > 0 { bnk.SendCoins(realmAddr, treasury, chain.Coins{chain.NewCoin("ugnot", platformAmount)}) } } resolution := "released-to-freelancer" if refundClient { resolution = "refunded-to-client" } chain.Emit("DisputeResolved", "contractId", contractId, "milestone", strconv.Itoa(milestoneIdx), "resolution", resolution, ) } // ── Cancellation ──────────────────────────────────────────── // CancelContract cancels an active contract. Client only. // Funded milestones are refunded minus cancellation fee. func CancelContract(cur realm, contractId string) { assertNotPaused() caller := unsafe.PreviousRealm().Address() c := getContract(contractId) if c.Client != caller { panic("only client can cancel") } if c.Status != StatusActive { panic("contract not active") } // STATE-BEFORE-SEND: update all state before transfers. // Track which milestones are NEWLY transitioned so we only pay those, // preventing double-refund of milestones already resolved via ResolveDispute. c.Status = StatusCancelled var newlyRefunded []int var newlyReleased []int for i := range c.Milestones { if c.Milestones[i].Status == MsFunded { c.Milestones[i].Status = MsRefunded newlyRefunded = append(newlyRefunded, i) } else if c.Milestones[i].Status == MsCompleted { c.Milestones[i].Status = MsReleased newlyReleased = append(newlyReleased, i) } // Already-terminal milestones (MsRefunded from ResolveDispute, MsReleased from // ReleaseFunds) are NOT added to the payment lists — their funds were already // distributed in the original operation. } contracts.Set(contractId, c) bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) realmAddr := unsafe.CurrentRealm().Address() for _, i := range newlyRefunded { ms := c.Milestones[i] // Refund funded milestones minus cancellation fee fee := basisPointsFee(ms.Amount, int64(CancelFeePct)*100) refund := ms.Amount - fee if refund > 0 { bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", refund)}) } // Cancellation fee goes to freelancer as compensation for lost opportunity // (escrow-internal, not on the DAO fee spine — see CancelFeePct doc comment). if fee > 0 { bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", fee)}) } totalLiable -= ms.Amount } if len(newlyReleased) > 0 { bps, treasury := resolveFee() for _, i := range newlyReleased { ms := c.Milestones[i] // Pay freelancer for completed work (full amount minus platform fee) platformAmount := basisPointsFee(ms.Amount, bps) freelancerAmount := ms.Amount - platformAmount bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)}) if platformAmount > 0 { bnk.SendCoins(realmAddr, treasury, chain.Coins{chain.NewCoin("ugnot", platformAmount)}) } totalLiable -= ms.Amount } } } // ── Timeouts (permissionless) ─────────────────────────────── // ClaimRefund refunds a funded milestone that has timed out. // Anyone can call — permissionless, prevents fund locking. func ClaimRefund(cur realm, contractId string, milestoneIdx int) { c := getContract(contractId) if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) { panic("invalid milestone index") } ms := &c.Milestones[milestoneIdx] if ms.Status != MsFunded { panic("milestone not funded") } if ms.FundedAt == 0 { panic("milestone has no funding record") } elapsed := runtime.ChainHeight() - ms.FundedAt if elapsed < AutoRefundBlks { panic(ufmt.Sprintf("too early: %d blocks remaining", AutoRefundBlks-elapsed)) } // STATE-BEFORE-SEND ms.Status = MsRefunded // Update contract status if all milestones are now terminal if allMilestonesTerminal(c) { c.Status = StatusCancelled } contracts.Set(contractId, c) totalLiable -= ms.Amount bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins( unsafe.CurrentRealm().Address(), c.Client, chain.Coins{chain.NewCoin("ugnot", ms.Amount)}, ) } // ClaimDisputeTimeout auto-resolves a dispute that admin hasn't acted on after // AutoResolveBlks (~28 days). Resolution follows the pre-dispute status: // - If the milestone was MsCompleted (freelancer delivered work) before dispute, // funds go to freelancer (minus platform fee). This prevents griefing where // a client disputes after delivery and simply waits out the clock. // - If the milestone was MsFunded (work not yet delivered) before dispute, // funds are refunded to client. // Anyone can call (permissionless safety valve). func ClaimDisputeTimeout(cur realm, contractId string, milestoneIdx int) { c := getContract(contractId) if milestoneIdx < 0 || milestoneIdx >= len(c.Milestones) { panic("invalid milestone index") } ms := &c.Milestones[milestoneIdx] if ms.Status != MsDisputed { panic("milestone not in dispute") } if ms.DisputedAt == 0 { panic("milestone has no dispute record") } elapsed := runtime.ChainHeight() - ms.DisputedAt if elapsed < AutoResolveBlks { panic(ufmt.Sprintf("too early: %d blocks remaining", AutoResolveBlks-elapsed)) } // Resolve based on pre-dispute status — fair to both parties. payFreelancer := ms.PreDisputeStatus == MsCompleted // STATE-BEFORE-SEND if payFreelancer { ms.Status = MsReleased } else { ms.Status = MsRefunded } // Reset contract status c.Status = StatusActive for _, m := range c.Milestones { if m.Status == MsDisputed { c.Status = StatusDisputed break } } if allMilestonesReleased(c) { c.Status = StatusCompleted } else if allMilestonesTerminal(c) { c.Status = StatusCancelled } contracts.Set(contractId, c) totalLiable -= ms.Amount bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) realmAddr := unsafe.CurrentRealm().Address() if payFreelancer { // Work was delivered — pay freelancer minus platform fee bps, treasury := resolveFee() platformAmount := basisPointsFee(ms.Amount, bps) freelancerAmount := ms.Amount - platformAmount bnk.SendCoins(realmAddr, c.Freelancer, chain.Coins{chain.NewCoin("ugnot", freelancerAmount)}) if platformAmount > 0 { bnk.SendCoins(realmAddr, treasury, chain.Coins{chain.NewCoin("ugnot", platformAmount)}) } chain.Emit("DisputeTimedOut", "contractId", contractId, "milestone", strconv.Itoa(milestoneIdx), "resolution", "paid-freelancer-work-delivered", ) } else { // Work never delivered — refund client in full bnk.SendCoins(realmAddr, c.Client, chain.Coins{chain.NewCoin("ugnot", ms.Amount)}) chain.Emit("DisputeTimedOut", "contractId", contractId, "milestone", strconv.Itoa(milestoneIdx), "resolution", "refunded-client-no-delivery", ) } } // ── Render ─────────────────────────────────────────────────── func Render(path string) string { if path == "" { return renderHome() } if strings.HasPrefix(path, "contract/") { id := strings.TrimPrefix(path, "contract/") return renderContract(id) } if path == "stats" { return renderStats() } return "# 404\nNot found: " + path } func renderHome() string { var sb strings.Builder sb.WriteString("# Escrow Contracts\n\n") sb.WriteString("Milestone-based escrow for freelance services on Gno.\n\n") if contracts.Size() == 0 { sb.WriteString("*No contracts yet.*\n") return sb.String() } sb.WriteString("| ID | Title | Client | Freelancer | Status | Total |\n") sb.WriteString("| --- | --- | --- | --- | --- | --- |\n") contracts.Iterate("", "", func(key string, value interface{}) bool { c := value.(*Contract) total := int64(0) for _, ms := range c.Milestones { total += ms.Amount } sb.WriteString(ufmt.Sprintf("| %s | [%s](:contract/%s) | %s | %s | %s | %d ugnot |\n", c.ID, c.Title, c.ID, truncAddr(c.Client), truncAddr(c.Freelancer), string(c.Status), total)) return false }) return sb.String() } func renderContract(id string) string { val, exists := contracts.Get(id) if !exists { return "# 404\nContract not found: " + id } c := val.(*Contract) var sb strings.Builder sb.WriteString("# " + c.Title + "\n\n") if len(c.Description) > 0 { sb.WriteString(c.Description + "\n\n") } sb.WriteString("**ID:** " + c.ID + "\n") sb.WriteString("**Client:** " + c.Client.String() + "\n") sb.WriteString("**Freelancer:** " + c.Freelancer.String() + "\n") sb.WriteString("**Status:** " + string(c.Status) + "\n") sb.WriteString("**Created:** block " + strconv.FormatInt(c.CreatedAt, 10) + "\n\n") total := int64(0) for _, ms := range c.Milestones { total += ms.Amount } sb.WriteString("**Total Value:** " + strconv.FormatInt(total, 10) + " ugnot\n\n") sb.WriteString("## Milestones\n\n") for _, ms := range c.Milestones { sb.WriteString(ufmt.Sprintf("- **%s** — %d ugnot [%s]", ms.Title, ms.Amount, string(ms.Status))) if ms.FundedAt > 0 { sb.WriteString(ufmt.Sprintf(" (funded block %d)", ms.FundedAt)) } if ms.CompletedAt > 0 { sb.WriteString(ufmt.Sprintf(" (completed block %d)", ms.CompletedAt)) } if ms.DisputedAt > 0 { sb.WriteString(ufmt.Sprintf(" (disputed block %d)", ms.DisputedAt)) } sb.WriteString("\n") } return sb.String() } func renderStats() string { var sb strings.Builder sb.WriteString("# Escrow Stats\n\n") totalContracts := contracts.Size() active, completed, disputed, cancelled := 0, 0, 0, 0 totalValue := int64(0) contracts.Iterate("", "", func(key string, value interface{}) bool { c := value.(*Contract) switch c.Status { case StatusActive: active++ case StatusCompleted: completed++ case StatusDisputed: disputed++ case StatusCancelled: cancelled++ } for _, ms := range c.Milestones { totalValue += ms.Amount } return false }) sb.WriteString(ufmt.Sprintf("**Total Contracts:** %d\n", totalContracts)) sb.WriteString(ufmt.Sprintf("**Active:** %d | **Completed:** %d | **Disputed:** %d | **Cancelled:** %d\n", active, completed, disputed, cancelled)) sb.WriteString(ufmt.Sprintf("**Total Value:** %d ugnot\n", totalValue)) return sb.String() } // ── Helpers ────────────────────────────────────────────────── func getContract(id string) *Contract { val, exists := contracts.Get(id) if !exists { panic("contract not found: " + id) } return val.(*Contract) } func allMilestonesReleased(c *Contract) bool { for _, m := range c.Milestones { if m.Status != MsReleased { return false } } return true } // allMilestonesTerminal returns true if every milestone is in a final state // (released, refunded, or pending — pending with no funds is terminal). func allMilestonesTerminal(c *Contract) bool { for _, m := range c.Milestones { if m.Status == MsFunded || m.Status == MsCompleted || m.Status == MsDisputed { return false } } return true } // parseMilestones parses "title1:amount1,title2:amount2" into Milestone slice. // Invalid entries cause a panic (not silently skipped). func parseMilestones(input string) []Milestone { var result []Milestone parts := strings.Split(input, ",") for i, p := range parts { p = strings.TrimSpace(p) if len(p) == 0 { continue } kv := strings.SplitN(p, ":", 2) if len(kv) != 2 { panic(ufmt.Sprintf("invalid milestone format at position %d: expected 'title:amount'", i)) } title := strings.TrimSpace(kv[0]) if len(title) == 0 { panic(ufmt.Sprintf("empty milestone title at position %d", i)) } if len(title) > MaxTitleLen { panic(ufmt.Sprintf("milestone title too long at position %d: %d/%d chars", i, len(title), MaxTitleLen)) } title = sanitizeMilestoneTitle(title) amount, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64) if err != nil || amount <= 0 { panic(ufmt.Sprintf("invalid milestone amount at position %d: must be positive integer", i)) } // Minimum milestone amount prevents fee evasion via integer truncation. // At 2% fee, amounts < 50 ugnot would pay 0 fee. if amount < MinMilestoneAmount { panic(ufmt.Sprintf("milestone amount too small at position %d: minimum %d ugnot", i, MinMilestoneAmount)) } result = append(result, Milestone{ ID: i, Title: title, Amount: amount, Status: MsPending, }) } return result } func truncAddr(addr address) string { s := addr.String() if len(s) > 13 { return s[:10] + "..." } return s } // sanitizeMilestoneTitle strips markdown special characters to prevent injection // in gnoweb Render output. func sanitizeMilestoneTitle(s string) string { var out strings.Builder for _, c := range s { switch c { case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t': continue // strip markdown-sensitive characters and control whitespace default: out.WriteRune(c) } } return out.String() }
- #6fees.gno
- #7package escrow_v3 // basisPointsFee computes floor(amount*bps/10000) without overflowing the // intermediate product. For nonnegative amount and bps <= 10000, the quotient // product and final sum are <= amount; the remainder product is < 100000000. // Callers retain their existing configured rates, recipients and rounding. func basisPointsFee(amount, bps int64) int64 { if amount < 0 || bps < 0 || bps > 10000 { panic("invalid fee inputs") } return (amount/10000)*bps + ((amount%10000)*bps)/10000 }
- #8getters.gno
- #9package escrow_v3 // ── NF-2 solvency getters (MAINNET_READINESS.md §3) ─────────────────────────── // // escrow_v3 pools funds between FundMilestone and release/refund, making it a // pooled-fund realm. Without a machine-readable liabilities figure the solvency // monitor can only do a balance-only heuristic. escrow_v3 was the LAST unpublished // pooled-fund realm without this pair — candidature_v3 and agent_registry_v2 // already shipped on Topaz without it and can never get it now, on immutable // package paths (§3b). Added before this realm ships to any enforce chain. // // Invariant the monitor reconciles: realm balance >= TotalLiabilities(). // totalLiable is maintained in lockstep with every fund-moving mutation in // escrow.gno: +ms.Amount on FundMilestone, -ms.Amount at every point funds leave // (ReleaseFunds; ResolveDispute both branches; CancelContract both branches; // ClaimRefund; ClaimDisputeTimeout both branches) — see escrow_feespine_test.gno // for the lockstep pin across every one of those paths. import "chain/runtime/unsafe" // TotalLiabilities returns the total ugnot the realm owes across all funded and // disputed milestones (funds that have arrived but not yet left the realm). func TotalLiabilities() int64 { return totalLiable } // RealmAddress is the NF-2 canonical name for the realm's own address (the // solvency monitor pairs it with TotalLiabilities). func RealmAddress() string { return unsafe.CurrentRealm().Address().String() }
- #10gnomod.toml
- #11module = "gno.land/r/samcrew/escrow_v3" gno = "0.9"
- #12governance_reads.gno
- #13package escrow_v3 import ( "crypto/sha256" "encoding/hex" cfg "gno.land/r/samcrew/memba_market_config" "strconv" ) // Value-only copies: no Contract pointer or persistent milestone slice escapes. type GovernanceMilestone struct { ID int Amount int64 Status string FundedAt, CompletedAt, DisputedAt int64 PreDisputeStatus string } type GovernanceContract struct { Exists bool ID, ContentHash, Status string Client, Freelancer address Count int Milestones [20]GovernanceMilestone } func GetGovernanceContract(id string) GovernanceContract { v, ok := contracts.Get(id) if !ok { return GovernanceContract{} } c := v.(*Contract) if len(c.Milestones) > 20 { panic("invalid escrow milestone count") } s := GovernanceContract{Exists: true, ID: c.ID, Status: string(c.Status), Client: c.Client, Freelancer: c.Freelancer, Count: len(c.Milestones)} metadata := "escrow-metadata/v1" + escrowMetadataField(c.ID) + escrowMetadataField(string(c.Client)) + escrowMetadataField(string(c.Freelancer)) + escrowMetadataField(c.Title) + escrowMetadataField(c.Description) + escrowMetadataField(strconv.FormatInt(c.CreatedAt, 10)) + escrowMetadataField(strconv.Itoa(len(c.Milestones))) for i, m := range c.Milestones { s.Milestones[i] = GovernanceMilestone{ID: m.ID, Amount: m.Amount, Status: string(m.Status), FundedAt: m.FundedAt, CompletedAt: m.CompletedAt, DisputedAt: m.DisputedAt, PreDisputeStatus: string(m.PreDisputeStatus)} metadata += escrowMetadataField(strconv.Itoa(m.ID)) + escrowMetadataField(m.Title) + escrowMetadataField(strconv.FormatInt(m.Amount, 10)) } digest := sha256.Sum256([]byte(metadata)) s.ContentHash = hex.EncodeToString(digest[:]) return s } type GovernanceFeeTerms struct { RawBPS, EffectiveBPS int64 RawTreasury, FallbackTreasury, EffectiveTreasury address } func GetGovernanceFeeTerms() GovernanceFeeTerms { bps, treasury := resolveFee() return GovernanceFeeTerms{RawBPS: int64(cfg.GetFeeBPS(laneService)), EffectiveBPS: bps, RawTreasury: cfg.GetTreasury(), FallbackTreasury: feeFallback, EffectiveTreasury: treasury} } func escrowMetadataField(value string) string { return strconv.Itoa(len(value)) + ":" + value }
- #14publisher.gno
- #15package escrow_v3 import "chain/runtime/unsafe" // publisherAtLoad captures the AddPackage signer during package initialization. // Keeping the unsafe load-time primitive separate from crossing entrypoints lets // privileged calls use only the frame-verified cur.IsCurrent/cur.Previous API. func publisherAtLoad() address { return unsafe.OriginCaller() }
- #16/gno.MemPackageType
- #17 MPUserAll
Result log
msg:0,success:true,log:,events:[]