payment.gno
5.08 Kb · 123 lines
1package nslogic
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7
8 "gno.land/p/nt/ufmt/v0"
9 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
10)
11
12// takePayment verifies the attached send-envelope covers `price` ugnot,
13// forwards the price to the treasury, and refunds any overpayment to the
14// caller in the same transaction. Nothing is ever retained: every ugnot
15// that arrives leaves again, as either payment or refund.
16//
17// It used to take a `beneficiary` and split the price fee/remainder,
18// routing the remainder to treasury only while `beneficiary == admin`.
19// That comparison was a proxy for "is this a project-owned domain", and
20// it silently broke the moment `admin` rotated — the planned EOA-to-
21// multisig step — because a domain's owner is frozen into its record at
22// registration time. After a rotation, every registration's remainder
23// would have been paid to the retired key instead of the treasury, with
24// no error and no log line.
25//
26// The heuristic is gone rather than patched. Domains are admin-only to
27// create and permanently non-transferable, so every domain is
28// project-owned by construction and 100% of every payment belongs to the
29// treasury. `platformFeeBps` stays in nsdata's config for a future logic
30// realm that reintroduces third-party domain ownership; reinstating the
31// split is then a logic-realm swap, not a migration.
32func takePayment(cur realm, price int64) {
33 takePaymentSplit(cur, price, "", 0)
34}
35
36// takePaymentSplit is takePayment with a referrer's cut carved out of the
37// same price. The buyer pays exactly what they were quoted; the cut is
38// not added on top, it is a share of what the treasury would otherwise
39// have received.
40//
41// All three legs settle inside the buyer's own transaction — treasury,
42// referrer, refund — so this realm's balance is the same after the call
43// as before it. That is not a stylistic preference: holding a referrer's
44// cut until they claim it would make this an account you can have a
45// balance in, and that is a different kind of thing to operate, subject
46// to a different set of rules, run by people with licences. The cut is
47// paid now or it is not paid.
48func takePaymentSplit(cur realm, price int64, cutTo address, cut int64) {
49 if !cur.Previous().IsUserCall() {
50 panic("nslogic: only direct wallet calls may attach payment")
51 }
52 caller := cur.Previous().Address()
53 sent := unsafe.OriginSend()
54 assertOnlyUgnot(sent)
55
56 paid := sent.AmountOf(ugnot)
57 if paid < price {
58 panic(ufmt.Sprintf("nslogic: insufficient payment: need %d ugnot, got %d", price, paid))
59 }
60 excess := paid - price
61
62 // Belt and braces. referralCut already caps the percentage, but this
63 // is the line where the arithmetic becomes money leaving an address,
64 // and a bug upstream must not be able to pay out more than came in.
65 if cut < 0 || cut > price || cutTo == "" {
66 cut = 0
67 }
68
69 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
70 pkgAddr := cur.Address()
71 if cut > 0 {
72 b.SendCoins(pkgAddr, cutTo, chain.Coins{chain.NewCoin(ugnot, cut)})
73 }
74 if price-cut > 0 {
75 b.SendCoins(pkgAddr, nsdata.GetTreasury(), chain.Coins{chain.NewCoin(ugnot, price-cut)})
76 }
77 if excess > 0 {
78 b.SendCoins(pkgAddr, caller, chain.Coins{chain.NewCoin(ugnot, excess)})
79 }
80}
81
82// assertOnlyUgnot rejects any other denomination outright. Only the
83// ugnot amount was ever accounted for, and neither realm has a
84// withdrawal path, so a foreign coin sent alongside a payment would have
85// been stranded in this realm's address permanently. Refusing the whole
86// transaction is simpler than sweeping it back and keeps the
87// "this realm never retains funds" invariant absolute.
88func assertOnlyUgnot(sent chain.Coins) {
89 for _, c := range sent {
90 if c.Denom != ugnot {
91 panic("nslogic: only ugnot is accepted, got: " + c.Denom)
92 }
93 }
94}
95
96// assertNoSend guards every entry point that has no price. Without it,
97// coins attached to a free call (a renewal of a project domain, a
98// profile edit, an admin setter) would land in this realm's address with
99// no way back out.
100//
101// IT APPLIES TO DIRECT WALLET CALLS ONLY, and that qualification is not
102// a loosening — it is the guard finally matching its own reason.
103//
104// OriginSend() reports what the TRANSACTION carried, not what this call
105// received. Measured on sapphire-1 with a pair of probe realms: a user
106// attaches 500000ugnot to realm A, A calls realm B, and B's OriginSend()
107// reads 500000ugnot even though every one of those coins is sitting in
108// A's address. B never saw them and cannot strand them.
109//
110// So for a realm-to-realm call this was rejecting a payment this realm
111// never received. It is the reason a marketplace could not transfer a
112// name inside the same transaction that pays for it — the transfer is a
113// free call, the transaction is not, and the two are unrelated. A
114// two-transaction sale is the alternative, and that means somebody's
115// money or somebody's name sitting somewhere in between. Not here.
116func assertNoSend(cur realm) {
117 if !cur.Previous().IsUserCall() {
118 return // the coins went to whoever called us, not to us
119 }
120 if !unsafe.OriginSend().IsZero() {
121 panic("nslogic: this function takes no payment; do not attach coins")
122 }
123}