Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

token_order.gno

27.37 Kb · 709 lines
  1package ucs03_zkgm
  2
  3import (
  4	types "gno.land/p/onbloc/ibc/union/types"
  5
  6	"gno.land/p/demo/tokens/grc20"
  7	z "gno.land/p/onbloc/ibc/union/zkgm"
  8	u256 "gno.land/p/onbloc/math/uint256"
  9	"gno.land/r/demo/defi/grc20reg"
 10	core "gno.land/r/onbloc/ibc/union/core"
 11)
 12
 13// tagAckSuccess returns a fresh copy of the success ack tag.
 14func tagAckSuccess() *u256.Uint       { return z.Uint256FromBytes32(z.TAG_ACK_SUCCESS) }
 15func tagAckFailure() *u256.Uint       { return z.Uint256FromBytes32(z.TAG_ACK_FAILURE) }
 16func fillTypeProtocol() *u256.Uint    { return z.Uint256FromBytes32(z.FILL_TYPE_PROTOCOL) }
 17func fillTypeMarketMaker() *u256.Uint { return z.Uint256FromBytes32(z.FILL_TYPE_MARKETMAKER) }
 18
 19// verifyTokenOrderV2 validates a token order on send: rate-limit, escrow voucher
 20// or consume native funds, then escrow the channel balance.
 21// reference: https://github.com/unionlabs/union/blob/d91c5e94354e15801bd5f82dc658eae3b79f2dad/cosmwasm/app/ucs03-zkgm/src/contract.rs#L419-L547
 22func (v *ucs03ZkgmV1) verifyTokenOrderV2(_ int, rlm realm, funds *funds, channelId types.ChannelId, path *u256.Uint, order z.TokenOrderV2) error {
 23	if path == nil {
 24		path = u256.Zero()
 25	}
 26
 27	switch order.Kind {
 28	case z.TOKEN_ORDER_KIND_INITIALIZE, z.TOKEN_ORDER_KIND_ESCROW:
 29		baseDenom := string(order.BaseToken)
 30
 31		// Verify the metadata decimals match the origin decimals.
 32		if order.Kind == z.TOKEN_ORDER_KIND_INITIALIZE {
 33			if err := v.verifyMetadataDecimals(baseDenom, order.Metadata); err != nil {
 34				return err
 35			}
 36		}
 37
 38		baseTokenLedgerAmount, err := v.ledgerAmountOf(baseDenom, order.BaseAmount)
 39		if err != nil {
 40			return err
 41		}
 42
 43		// reference verify_token_order_v2 increases the channel balance before
 44		// escrowing the funds (contract.rs L511-543); rate limiting happens only
 45		// on the receive/execute side, never here.
 46		err = v.increaseChannelBalanceV2(0, rlm, channelId, path, order.BaseToken, order.QuoteToken, baseTokenLedgerAmount)
 47		if err != nil {
 48			return err
 49		}
 50
 51		err = v.localTokenEscrow(0, rlm, baseDenom, rlm.Previous().Address(), funds, order.BaseAmount)
 52		if err != nil {
 53			// rollback the channel balance
 54			v.decreaseChannelBalanceV2(0, rlm, channelId, path, order.BaseToken, order.QuoteToken, baseTokenLedgerAmount)
 55
 56			return err
 57		}
 58
 59		return nil
 60	case z.TOKEN_ORDER_KIND_UNESCROW:
 61		baseDenom := string(order.BaseToken)
 62
 63		origin, ok := v.store.GetTokenOrigin(baseDenom)
 64		if !ok {
 65			return makeError(errInvalidUnescrow)
 66		}
 67
 68		intermediatePath, destChan := z.PopChannelFromPath(origin)
 69
 70		reversed, err := z.ReverseChannelPath(intermediatePath)
 71		if err != nil {
 72			return err
 73		}
 74
 75		if path.Cmp(reversed) != 0 {
 76			return makeError(errInvalidUnescrow)
 77		}
 78
 79		if channelId != types.ChannelId(destChan) {
 80			return makeError(errInvalidUnescrow)
 81		}
 82
 83		image, ok := v.store.GetMetadataImageOf(baseDenom)
 84		if !ok {
 85			image = [32]byte{}
 86		}
 87		// reference predicts against the incoming channel_id (contract.rs L453,
 88		// L466), not the popped destination channel. The is_sending_back check
 89		// above guarantees they are equal on the valid-unescrow path.
 90		wrappedV1 := z.PredictWrappedToken(intermediatePath, uint32(channelId), order.QuoteToken)
 91		wrappedV2 := z.PredictWrappedTokenV2(intermediatePath, uint32(channelId), order.QuoteToken, image)
 92
 93		if baseDenom != wrappedV1 && baseDenom != wrappedV2 {
 94			return makeError(errInvalidUnescrow)
 95		}
 96
 97		// UNESCROW base on the valid path is always an ibc/ wrapped voucher (see wrappedV1/V2 check above).
 98		// Burn it from the sender:
 99		// supply destruction on the source chain is the correct model for IBC vouchers.
100		// GRC20 and native tokens are not valid UNESCROW bases here;
101		// they are escrowed via localTokenEscrow on INITIALIZE/ESCROW instead.
102		if isIBCTokenDenom(baseDenom) {
103			return v.burnVoucher(0, rlm, baseDenom, rlm.Previous().Address(), order.BaseAmount)
104		}
105
106		// Defensive fallback for non-ibc denoms: deduct from tx-attached native funds.
107		// Valid UNESCROW orders never reach this branch.
108		return funds.sub(baseDenom, order.BaseAmount)
109	case z.TOKEN_ORDER_KIND_SOLVE:
110		return makeError(errSolveNotImplemented)
111	default:
112		return makeError(errUnknownTokenOrderKind)
113	}
114}
115
116// executeTokenOrderV2 settles a received token order: mint wrapped vouchers, release escrowed native, or fill from the market maker.
117// reference: https://github.com/unionlabs/union/blob/d91c5e94354e15801bd5f82dc658eae3b79f2dad/cosmwasm/app/ucs03-zkgm/src/contract.rs#L2133-L2306
118func (v *ucs03ZkgmV1) executeTokenOrderV2(_ int, rlm realm, packet types.Packet, relayer address, relayerMsg []byte, salt [32]byte, path *u256.Uint, order z.TokenOrderV2, intent bool) (types.RecvPacketResult, error) {
119	// Intent packets and explicit solve orders are maker-only.
120	if intent || order.Kind == z.TOKEN_ORDER_KIND_SOLVE {
121		return v.marketMakerFillV2(0, rlm, relayer, relayerMsg, order)
122	}
123
124	if path == nil {
125		path = u256.Zero()
126	}
127
128	quoteDenom := string(order.QuoteToken)
129	baseCovers := order.BaseAmount.Cmp(order.QuoteAmount) >= 0
130
131	// Direct unescrow path: an unwrap whose base covers the quote releases the
132	// escrowed native asset to the receiver.
133	if order.Kind == z.TOKEN_ORDER_KIND_UNESCROW && baseCovers {
134		if err := v.rateLimit(0, rlm, quoteDenom, order.QuoteAmount); err != nil {
135			return failureResult(universalErrorAck()), nil
136		}
137
138		return v.protocolFillUnescrowV2(0, rlm, packet.DestinationChannelId, path, order.BaseToken, quoteDenom, address(order.Receiver), relayer, order.BaseAmount, order.QuoteAmount)
139	}
140
141	destChan, err := tokenOrderDestinationChannel(packet.DestinationChannelId)
142	if err != nil {
143		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
144	}
145
146	// Predict the wrapped denom for an escrow/initialize order.
147	// A mismatch (or an under-quote) is not an error:
148	// the order falls back to a market-maker fill.
149	var (
150		wrapped     string
151		hasWrapped  bool
152		image       [32]byte
153		meta        z.TokenMetadata
154		metaPresent bool
155	)
156
157	switch order.Kind {
158	case z.TOKEN_ORDER_KIND_ESCROW:
159		if img, ok := v.store.GetMetadataImageOf(quoteDenom); ok && !isZeroImage(img) {
160			image = img
161			wrapped = z.PredictWrappedTokenV2(path, destChan, order.BaseToken, image)
162		} else {
163			image = [32]byte{}
164			wrapped = z.PredictWrappedToken(path, destChan, order.BaseToken)
165		}
166
167		hasWrapped = true
168
169		// ESCROW targets an already-deployed wrapped voucher;
170		// the deploying first transfer of a token uses INITIALIZE (which carries the metadata).
171		// A first-seen ESCROW that matches the predicted wrapped denom has no persisted ledger/scale,
172		// so minting would lazily create the voucher at decimals=0 with no origin metadata:
173		// an unredeemable orphan whose scale then diverges from a later INITIALIZE's pre-flight check.
174		// Reject it as an invalid order so the source refunds, mirroring the INITIALIZE invalid-order handling below.
175		if quoteDenom == wrapped && !v.store.HasVoucher(wrapped) {
176			return failureResult(universalErrorAck()), nil
177		}
178	case z.TOKEN_ORDER_KIND_INITIALIZE:
179		// gno requires the metadata preimage to initialize a new wrapped token.
180		if len(order.Metadata) == 0 {
181			return failureResult(universalErrorAck()), nil
182		}
183
184		m, derr := z.DecodeTokenMetadata(order.Metadata)
185		if derr != nil {
186			return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), derr
187		}
188
189		meta, metaPresent = m, true
190		image = z.MetadataImage(meta)
191		wrapped = z.PredictWrappedTokenV2(path, destChan, order.BaseToken, image)
192		// A quote token that does not match the deterministic wrapped denom is an
193		// invalid initialize order (union InvalidTokenOrderKind).
194		if quoteDenom != wrapped {
195			return failureResult(universalErrorAck()), nil
196		}
197
198		hasWrapped = true
199	}
200
201	// Protocol fill when the quote matches the predicted wrapped denom and the base
202	// covers the quote; otherwise fall back to a market-maker fill.
203	if hasWrapped && quoteDenom == wrapped && baseCovers {
204		if err := v.rateLimit(0, rlm, quoteDenom, order.QuoteAmount); err != nil {
205			return failureResult(universalErrorAck()), nil
206		}
207
208		canDeploy := order.Kind == z.TOKEN_ORDER_KIND_INITIALIZE
209
210		var metadata *z.TokenMetadata
211		if metaPresent {
212			metadata = &meta
213		}
214
215		return v.protocolFillMint(0, rlm, packet.DestinationChannelId, path, order.BaseToken, wrapped, address(order.Receiver), relayer, order.BaseAmount, order.QuoteAmount, metadata, canDeploy)
216	}
217
218	return v.marketMakerFillV2(0, rlm, relayer, relayerMsg, order)
219}
220
221// protocolFillMint mints the wrapped voucher for an escrow/initialize order:
222// quote to the receiver, fee to the relayer, then records deploy-time wrapped
223// token metadata. State is written only after the mint succeeds so a recovered
224// failure cannot leave orphan origin state.
225// reference: https://github.com/unionlabs/union/blob/d91c5e94354e15801bd5f82dc658eae3b79f2dad/cosmwasm/app/ucs03-zkgm/src/contract.rs#L2309-L2413
226func (v *ucs03ZkgmV1) protocolFillMint(_ int, rlm realm, channelId types.ChannelId, path *u256.Uint, baseToken []byte, wrappedToken string, receiver address, relayer address, baseAmount *u256.Uint, quoteAmount *u256.Uint, metadata *z.TokenMetadata, canDeploy bool) (types.RecvPacketResult, error) {
227	// fee = base - quote; reject a quote that exceeds base instead of wrapping
228	// around to a near-2^256 fee that would only later trip the amount checks.
229	fee, underflow := u256.Zero().SubOverflow(baseAmount, quoteAmount)
230	if underflow {
231		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), makeError(errTokenOrderFeeUnderflow)
232	}
233
234	// A deployable initialize carries the token metadata (name/symbol/decimals);
235	// derive its image and reject out-of-range decimals before any mint.
236	var image [32]byte
237
238	name, symbol := "", ""
239
240	var (
241		decimals uint8
242
243		// Bound-check the amounts against the int64 ledger after downscaling.
244		// A deploying initialize derives the scale from the declared decimals,
245		// an escrow into an existing voucher from its persisted scale.
246		// The mint below re-derives the same scale,
247		// so a non-divisible or oversized amount fails gracefully here before any state write.
248		scaleExp int
249	)
250
251	if canDeploy && metadata != nil {
252		image = z.MetadataImage(*metadata)
253
254		ti, err := z.DecodeTokenInitializer(metadata.Initializer)
255		if err != nil {
256			// Malformed non-empty calldata mirrors an EVM proxy initialization
257			// revert. Gno deliberately rejects an empty initializer too instead
258			// of creating an uninitialized zero-decimal voucher.
259			return failureResult(universalErrorAck()), nil
260		}
261		if ti.Decimals > grc20.MaxDecimals {
262			return failureResult(universalErrorAck()), nil
263		}
264
265		name, symbol, decimals = ti.Name, ti.Symbol, ti.Decimals
266
267		scaleExp = scaleExpForDecimals(decimals)
268	} else {
269		scaleExp = v.scaleExpOf(wrappedToken)
270	}
271
272	if _, err := z.ScaleDownToInt64(quoteAmount, scaleExp); err != nil {
273		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
274	}
275
276	if _, err := z.ScaleDownToInt64(fee, scaleExp); err != nil {
277		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
278	}
279
280	_, hasForeignToken := v.store.GetForeignToken(wrappedToken)
281	shouldDeploy := canDeploy && metadata != nil && !hasForeignToken
282
283	var nextPath *u256.Uint
284
285	if shouldDeploy {
286		np, err := z.UpdateChannelPath(path, uint32(channelId))
287		if err != nil {
288			return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
289		}
290
291		nextPath = np
292	}
293
294	if !quoteAmount.IsZero() {
295		if err := v.mintVoucher(0, rlm, wrappedToken, image, name, symbol, decimals, receiver, quoteAmount); err != nil {
296			return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
297		}
298	}
299
300	if !fee.IsZero() {
301		if err := v.mintVoucher(0, rlm, wrappedToken, image, name, symbol, decimals, relayer, fee); err != nil {
302			panic(execFatal{err.Error()})
303		}
304	}
305
306	if shouldDeploy {
307		v.store.SetForeignToken(0, rlm, wrappedToken, baseToken)
308		v.store.SetTokenOrigin(0, rlm, wrappedToken, nextPath)
309		v.store.SetMetadataImageOf(0, rlm, wrappedToken, image)
310	}
311
312	ack, err := encodeTokenOrderSuccessAck(fillTypeProtocol(), nil)
313	if err != nil {
314		panic(execFatal{err.Error()})
315	}
316
317	return core.NewRecvPacketResult(types.PacketStatusSuccess, ack), nil
318}
319
320// protocolFillUnescrowV2 releases an escrowed local asset for an unwrap order:
321// decrease the channel balance first, then send the quote to the receiver and
322// the fee to the relayer from the proxy escrow.
323// reference: https://github.com/unionlabs/union/blob/d91c5e94354e15801bd5f82dc658eae3b79f2dad/cosmwasm/app/ucs03-zkgm/src/contract.rs#L2415-L2488
324func (v *ucs03ZkgmV1) protocolFillUnescrowV2(_ int, rlm realm, channelId types.ChannelId, path *u256.Uint, baseToken []byte, quoteTokenStr string, receiver address, relayer address, baseAmount *u256.Uint, quoteAmount *u256.Uint) (types.RecvPacketResult, error) {
325	reversedPath, err := z.ReverseChannelPath(path)
326	if err != nil {
327		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
328	}
329
330	// fee = base - quote; reject a quote that exceeds base instead of wrapping
331	// around to a near-2^256 fee that would only later trip amountInt64.
332	fee, underflow := u256.Zero().SubOverflow(baseAmount, quoteAmount)
333	if underflow {
334		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), makeError(errTokenOrderFeeUnderflow)
335	}
336
337	if _, err := v.ledgerAmountOf(quoteTokenStr, quoteAmount); err != nil {
338		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
339	}
340
341	if _, err := v.ledgerAmountOf(quoteTokenStr, fee); err != nil {
342		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
343	}
344
345	// The released asset is quoteToken, and base/quote/fee share its precision.
346	// The channel balance records the vault (ledger) amount, so derive it from
347	// base; quote/fee are bound-checked against the int64 ledger (0 for native).
348	baseTokenLedgerAmount, err := v.ledgerAmountOf(quoteTokenStr, baseAmount)
349	if err != nil {
350		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
351	}
352
353	// reference decreases the escrowed balance before unescrowing (contract.rs
354	// L2437-2444); decreaseChannelBalanceV2 rejects an underflow internally. The
355	// quote/base tokens are swapped into the balance key as union does (the EVM
356	// quoteToken occupies the baseToken slot and vice versa).
357	err = v.decreaseChannelBalanceV2(0, rlm, channelId, reversedPath, []byte(quoteTokenStr), baseToken, baseTokenLedgerAmount)
358	if err != nil {
359		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
360	}
361
362	if !quoteAmount.IsZero() {
363		if err := v.localTokenUnescrow(0, rlm, quoteTokenStr, receiver, quoteAmount); err != nil {
364			// rollback the channel balance
365			v.increaseChannelBalanceV2(0, rlm, channelId, reversedPath, []byte(quoteTokenStr), baseToken, baseTokenLedgerAmount)
366
367			panic(execFatal{err.Error()})
368		}
369	}
370
371	if !fee.IsZero() {
372		if err := v.localTokenUnescrow(0, rlm, quoteTokenStr, relayer, fee); err != nil {
373			// rollback the channel balance
374			v.increaseChannelBalanceV2(0, rlm, channelId, reversedPath, []byte(quoteTokenStr), baseToken, baseTokenLedgerAmount)
375
376			panic(execFatal{err.Error()})
377		}
378	}
379
380	ack, err := encodeTokenOrderSuccessAck(fillTypeProtocol(), nil)
381	if err != nil {
382		panic(execFatal{err.Error()})
383	}
384
385	return core.NewRecvPacketResult(types.PacketStatusSuccess, ack), nil
386}
387
388// isZeroImage reports whether a metadata image is the zero value, selecting the V1
389// wrapped-token prediction when true.
390func isZeroImage(image [32]byte) bool {
391	return image == [32]byte{}
392}
393
394// encodeTokenOrderSuccessAck builds a success token-order ack with the given fill type.
395func encodeTokenOrderSuccessAck(fillType *u256.Uint, marketMaker []byte) ([]byte, error) {
396	innerAck, err := z.EncodeTokenOrderAck(z.TokenOrderAck{
397		FillType:    fillType,
398		MarketMaker: marketMaker,
399	})
400	if err != nil {
401		return nil, err
402	}
403
404	return z.EncodeAck(z.Ack{Tag: tagAckSuccess(), InnerAck: innerAck})
405}
406
407// marketMakerFillV2 fills a token order from the market maker's voucher balance.
408// Native maker funding is intentionally unsupported in v1 because Gno does not
409// yet plumb unsafe.OriginSend() funds through recv/intent dispatch. Non-voucher
410// quotes therefore return ACK_ERR_ONLY_MAKER; OnIntentRecvPacket turns that into
411// an explicit panic so the proven recv path can handle the packet.
412//
413// TODO(market-maker): add funds plumbing and native quote-token fills in the next
414// version, then align this path with Union's LocalTokenMsg handling.
415//
416// reference: https://github.com/unionlabs/union/blob/d91c5e94354e15801bd5f82dc658eae3b79f2dad/cosmwasm/app/ucs03-zkgm/src/contract.rs#L1791-L1861
417func (v *ucs03ZkgmV1) marketMakerFillV2(_ int, rlm realm, relayer address, relayerMsg []byte, order z.TokenOrderV2) (types.RecvPacketResult, error) {
418	quoteDenom := string(order.QuoteToken)
419	if !v.store.HasVoucher(quoteDenom) {
420		// Union can fill local/native quote tokens from the maker's funds. This
421		// implementation cannot observe those funds here yet, so only voucher
422		// quote tokens are eligible for the market-maker fast path.
423		return core.NewRecvPacketResult(types.PacketStatusSuccess, types.CloneBytes(z.ACK_ERR_ONLY_MAKER)), nil
424	}
425
426	if _, err := v.ledgerAmountOf(quoteDenom, order.QuoteAmount); err != nil {
427		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
428	}
429
430	if err := v.transferVoucher(0, rlm, quoteDenom, relayer, address(order.Receiver), order.QuoteAmount); err != nil {
431		return core.NewRecvPacketResult(types.PacketStatusSuccess, types.CloneBytes(z.ACK_ERR_ONLY_MAKER)), nil
432	}
433
434	// market_maker is the relayer-supplied settlement target (union saves
435	// relayer_msg into MARKET_MAKER); fall back to the relayer's own address when
436	// no message was attached.
437	marketMaker := relayerMsg
438	if len(marketMaker) == 0 {
439		marketMaker = []byte(relayer.String())
440	}
441
442	ack, err := encodeTokenOrderSuccessAck(fillTypeMarketMaker(), marketMaker)
443	if err != nil {
444		return core.NewRecvPacketResult(types.PacketStatusUnknown, nil), err
445	}
446
447	return core.NewRecvPacketResult(types.PacketStatusSuccess, ack), nil
448}
449
450// acknowledgeTokenOrderV2 settles the ack: refund on failure, or release escrow to the market maker on a maker fill.
451// reference: https://github.com/unionlabs/union/blob/d91c5e94354e15801bd5f82dc658eae3b79f2dad/cosmwasm/app/ucs03-zkgm/src/contract.rs#L974-L1072
452func (v *ucs03ZkgmV1) acknowledgeTokenOrderV2(_ int, rlm realm, packet types.Packet, relayer address, salt [32]byte, path *u256.Uint, order z.TokenOrderV2, orderAck z.TokenOrderAck, hasOrderAck bool) error {
453	sourceChannelId := packet.SourceChannelId
454	if !hasOrderAck {
455		return v.refundV2(0, rlm, path, sourceChannelId, order)
456	}
457
458	if orderAck.FillType.Eq(fillTypeProtocol()) {
459		return nil
460	}
461
462	if !orderAck.FillType.Eq(fillTypeMarketMaker()) {
463		return makeError(errUnknownFillType)
464	}
465
466	marketMaker := address(orderAck.MarketMaker)
467
468	switch order.Kind {
469	case z.TOKEN_ORDER_KIND_UNESCROW:
470		return v.refundIBCVoucher(0, rlm, order.BaseToken, marketMaker, order.BaseAmount)
471	case z.TOKEN_ORDER_KIND_INITIALIZE, z.TOKEN_ORDER_KIND_ESCROW:
472		// union: when the maker designates the canonical zero burn address, the
473		// escrowed base is burned instead of released to the maker
474		// (acknowledge_token_order_v2, contract.rs L1030-1059).
475		if marketMaker == z.BURN_ADDRESS {
476			return v.burnEscrowedV2(0, rlm, sourceChannelId, path, order)
477		}
478
479		return v.settleEscrowedV2(0, rlm, sourceChannelId, path, order, marketMaker)
480	default:
481		return makeError(errUnsupportedTokenOrderKindForAck)
482	}
483}
484
485// refundV2 returns a failed or timed-out token order's funds to the sender.
486// reference: https://github.com/unionlabs/union/blob/d91c5e94354e15801bd5f82dc658eae3b79f2dad/cosmwasm/app/ucs03-zkgm/src/contract.rs#L918-L971
487func (v *ucs03ZkgmV1) refundV2(_ int, rlm realm, path *u256.Uint, sourceChannelId types.ChannelId, order z.TokenOrderV2) error {
488	switch order.Kind {
489	case z.TOKEN_ORDER_KIND_UNESCROW:
490		return v.refundIBCVoucher(0, rlm, order.BaseToken, address(order.Sender), order.BaseAmount)
491	case z.TOKEN_ORDER_KIND_INITIALIZE, z.TOKEN_ORDER_KIND_ESCROW:
492		return v.settleEscrowedV2(0, rlm, sourceChannelId, path, order, address(order.Sender))
493	default:
494		return makeError(errUnsupportedTokenOrderKindForRefund)
495	}
496}
497
498// settleEscrowedV2 releases a previously escrowed INITIALIZE/ESCROW BaseAmount to
499// recipient: it transfers `ibc/` vouchers from the proxy escrow address and
500// releases native tokens from the proxy escrow otherwise. Shared by refundV2 and
501// the market-maker fill branch of acknowledgeTokenOrderV2.
502func (v *ucs03ZkgmV1) settleEscrowedV2(_ int, rlm realm, sourceChannelId types.ChannelId, path *u256.Uint, order z.TokenOrderV2, recipient address) error {
503	// Base is released as the local baseToken; the channel balance records the
504	// vault (ledger) amount for that denom (0-scale for native), matching
505	// localTokenUnescrow.
506	baseDenom := string(order.BaseToken)
507
508	baseTokenLedgerAmount, err := v.ledgerAmountOf(baseDenom, order.BaseAmount)
509	if err != nil {
510		return err
511	}
512
513	// reference decreases the escrowed balance before unescrowing
514	// (refund_v2 contract.rs L894-902 / ack L1021-1028);
515	// decreaseChannelBalanceV2 rejects an underflow internally.
516	err = v.decreaseChannelBalanceV2(0, rlm, sourceChannelId, path, order.BaseToken, order.QuoteToken, baseTokenLedgerAmount)
517	if err != nil {
518		return err
519	}
520
521	err = v.localTokenUnescrow(0, rlm, baseDenom, recipient, order.BaseAmount)
522	if err != nil {
523		// rollback the channel balance
524		v.increaseChannelBalanceV2(0, rlm, sourceChannelId, path, order.BaseToken, order.QuoteToken, baseTokenLedgerAmount)
525
526		return err
527	}
528
529	return nil
530}
531
532// burnEscrowedV2 removes the escrowed channel balance and finalizes the base
533// token as burned instead of releasing it to the maker (union burn-address ack).
534// Token-type handling:
535//   - IBC voucher (ibc/): supply-burned via burnVoucher from proxy escrow.
536//   - Native coin: supply cannot be destroyed; coins stay in the proxy banker
537//     balance and are tracked in locked-base accounting instead of the
538//     channel escrow balance, since burn settlement is a terminal
539//     disposition (like release/refund), not an ongoing escrow.
540//   - GRC20: same as native — no mint/burn capability over foreign tokens;
541//     balance stays at the proxy and is tracked in locked-base accounting.
542func (v *ucs03ZkgmV1) burnEscrowedV2(_ int, rlm realm, sourceChannelId types.ChannelId, path *u256.Uint, order z.TokenOrderV2) error {
543	// Channel balance records the vault (ledger) amount for the base denom.
544	baseDenom := string(order.BaseToken)
545	tokenType := getTokenType(baseDenom)
546
547	baseTokenLedgerAmount, err := v.ledgerAmountOf(baseDenom, order.BaseAmount)
548	if err != nil {
549		return err
550	}
551
552	err = v.decreaseChannelBalanceV2(0, rlm, sourceChannelId, path, order.BaseToken, order.QuoteToken, baseTokenLedgerAmount)
553	if err != nil {
554		return err
555	}
556
557	switch tokenType {
558	case IBC_TOKEN:
559		if err := v.burnVoucher(0, rlm, baseDenom, rlm.Address(), order.BaseAmount); err != nil {
560			// rollback the channel balance
561			v.increaseChannelBalanceV2(0, rlm, sourceChannelId, path, order.BaseToken, order.QuoteToken, baseTokenLedgerAmount)
562
563			return err
564		}
565	case NATIVE_COIN:
566		// Supply cannot be destroyed: permanently lock the proxy-held balance and
567		// remove it from channel escrow accounting.
568		v.store.AddLockedTokenAmount(0, rlm, baseDenom, order.BaseAmount)
569	case GRC20_TOKEN:
570		// No mint/burn capability over a foreign GRC20: same lock accounting as
571		// native.
572		v.store.AddLockedTokenAmount(0, rlm, baseDenom, order.BaseAmount)
573	}
574
575	return nil
576}
577
578// localTokenEscrow takes custody of the base token for a local INITIALIZE/ESCROW
579// send. Classification mirrors getTokenType:
580//   - IBC voucher: transfer into proxy escrow (no burn on outbound escrow).
581//   - GRC20: TransferFrom sender into proxy; requires prior approval.
582//   - Native: deduct from tx-attached -send funds held in the funds budget.
583func (v *ucs03ZkgmV1) localTokenEscrow(_ int, rlm realm, denom string, sender address, funds *funds, amount *u256.Uint) error {
584	switch getTokenType(denom) {
585	case IBC_TOKEN:
586		return v.transferVoucher(0, rlm, denom, sender, rlm.Address(), amount)
587	case GRC20_TOKEN:
588		return v.localGRC20Escrow(0, rlm, grc20reg.Get(denom), sender, amount)
589	case NATIVE_COIN:
590		return funds.sub(denom, amount)
591	}
592
593	return nil
594}
595
596// localTokenUnescrow releases custody of the base token (recv unwrap / refund /
597// settleEscrowedV2). Classification mirrors localTokenEscrow:
598//   - IBC voucher: transfer from proxy escrow to recipient (not a mint).
599//   - GRC20: Transfer from proxy custody to recipient.
600//   - Native: sendNative from the proxy banker balance.
601func (v *ucs03ZkgmV1) localTokenUnescrow(_ int, rlm realm, denom string, recipient address, amount *u256.Uint) error {
602	switch getTokenType(denom) {
603	case IBC_TOKEN:
604		return v.transferVoucher(0, rlm, denom, rlm.Address(), recipient, amount)
605	case GRC20_TOKEN:
606		return v.localGRC20Unescrow(0, rlm, grc20reg.Get(denom), recipient, amount)
607	case NATIVE_COIN:
608		return v.sendNative(0, rlm, denom, recipient, amount)
609	}
610
611	return nil
612}
613
614// refundIBCVoucher re-mints an IBC voucher to recipient, preserving the stored metadata image.
615func (v *ucs03ZkgmV1) refundIBCVoucher(_ int, rlm realm, baseToken []byte, recipient address, amount *u256.Uint) error {
616	baseDenom := string(baseToken)
617	image, _ := v.store.GetMetadataImageOf(baseDenom)
618
619	return v.mintVoucher(0, rlm, baseDenom, image, "", "", 0, recipient, amount)
620}
621
622// localGRC20Escrow takes custody of a locally-registered grc20 token (not a
623// zkgm-issued voucher) for a local send. Unlike native's -send, custody moves
624// via TransferFrom, so the sender must have pre-approved this realm.
625func (v *ucs03ZkgmV1) localGRC20Escrow(_ int, rlm realm, token *grc20.Token, sender address, amount *u256.Uint) error {
626	n, err := amountInt64(amount)
627	if err != nil {
628		return err
629	}
630
631	teller := token.RealmTeller(0, rlm)
632
633	beforeBalance := teller.BalanceOf(rlm.Address())
634
635	err = teller.TransferFrom(0, rlm, sender, rlm.Address(), n)
636	if err != nil {
637		return err
638	}
639
640	afterBalance := teller.BalanceOf(rlm.Address())
641
642	if afterBalance-beforeBalance != n {
643		return makeError(errInvalidTokenOrderTransferAmount)
644	}
645
646	return nil
647}
648
649// localGRC20Unescrow releases a locally-registered grc20 token from proxy
650// custody (recv unwrap / refund), mirroring localGRC20Escrow.
651func (v *ucs03ZkgmV1) localGRC20Unescrow(_ int, rlm realm, token *grc20.Token, recipient address, amount *u256.Uint) error {
652	n, err := amountInt64(amount)
653	if err != nil {
654		return err
655	}
656
657	teller := token.RealmTeller(0, rlm)
658
659	beforeBalance := teller.BalanceOf(rlm.Address())
660
661	err = teller.Transfer(0, rlm, recipient, n)
662	if err != nil {
663		return err
664	}
665
666	afterBalance := teller.BalanceOf(rlm.Address())
667
668	if beforeBalance-afterBalance != n {
669		return makeError(errInvalidTokenOrderTransferAmount)
670	}
671
672	return nil
673}
674
675// verifyMetadataDecimals rejects an INITIALIZE order whose declared metadata
676// decimals do not match the locally-known ground truth for baseDenom: a
677// registered grc20's real decimals, or a held voucher's recorded
678// OriginDecimals when forwarding it onward. baseDenom that is native (or
679// otherwise unknown here) is out of scope for this check. Left unchecked, a
680// wrong declaration would still move the correct raw amount here but cause
681// the counterparty (or next hop) to deploy/interpret the wrapped copy at the
682// wrong precision.
683func (v *ucs03ZkgmV1) verifyMetadataDecimals(baseDenom string, metadata []byte) error {
684	var originDecimals uint8
685
686	if token := grc20reg.Get(baseDenom); token != nil {
687		originDecimals = uint8(token.GetDecimals())
688	} else if vou, ok := v.store.GetVoucher(baseDenom); ok {
689		originDecimals = vou.OriginDecimals()
690	} else {
691		return nil
692	}
693
694	meta, err := z.DecodeTokenMetadata(metadata)
695	if err != nil {
696		return err
697	}
698
699	ti, err := z.DecodeTokenInitializer(meta.Initializer)
700	if err != nil {
701		return err
702	}
703
704	if ti.Decimals != originDecimals {
705		return makeError(errMetadataDecimalsMismatch)
706	}
707
708	return nil
709}