Skip to main content

sui_rosetta/
construction.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::str::FromStr;
5use std::sync::Arc;
6
7use axum::extract::State;
8use axum::{Extension, Json};
9use axum_extra::extract::WithRejection;
10use fastcrypto::encoding::{Encoding, Hex};
11use fastcrypto::hash::HashFunction;
12use prost_types::FieldMask;
13use sui_rpc::field::FieldMaskUtil;
14use sui_rpc::proto::sui::rpc::v2::{
15    ExecuteTransactionRequest, SimulateTransactionRequest, UserSignature,
16    simulate_transaction_request::TransactionChecks,
17};
18
19use shared_crypto::intent::{Intent, IntentMessage};
20use sui_types::base_types::SuiAddress;
21use sui_types::crypto::{DefaultHash, SignatureScheme, ToFromBytes};
22use sui_types::digests::TransactionDigest;
23use sui_types::signature::{GenericSignature, VerifyParams};
24use sui_types::signature_verification::{
25    VerifiedDigestCache, verify_sender_signed_data_message_signatures,
26};
27use sui_types::transaction::TransactionDataAPI;
28
29use crate::errors::{Error, insufficient_gas_required_budget};
30use crate::operations::reconstruct_operations;
31use crate::types::internal_operation::{PayCoin, TransactionObjectData, TryConstructTransaction};
32use crate::types::transaction_envelope;
33use crate::types::{
34    Amount, AuxData, ConstructionCombineRequest, ConstructionCombineResponse,
35    ConstructionDeriveRequest, ConstructionDeriveResponse, ConstructionHashRequest,
36    ConstructionMetadata, ConstructionMetadataRequest, ConstructionMetadataResponse,
37    ConstructionParseRequest, ConstructionParseResponse, ConstructionPayloadsRequest,
38    ConstructionPayloadsResponse, ConstructionPreprocessRequest, ConstructionPreprocessResponse,
39    ConstructionSubmitRequest, InternalOperation, MetadataOptions, RosettaTransaction,
40    SignatureType, SigningPayload, TransactionIdentifier, TransactionIdentifierResponse,
41};
42use crate::{OnlineServerContext, SuiEnv};
43use move_core_types::language_storage::TypeTag;
44
45// This module implements the [Mesh Construction API](https://docs.cdp.coinbase.com/mesh/mesh-api-spec/api-reference#construction)
46
47/// Derive returns the AccountIdentifier associated with a public key.
48///
49/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/derive-accountidentifier-from-publickey)
50pub async fn derive(
51    Extension(env): Extension<SuiEnv>,
52    WithRejection(Json(request), _): WithRejection<Json<ConstructionDeriveRequest>, Error>,
53) -> Result<ConstructionDeriveResponse, Error> {
54    env.check_network_identifier(&request.network_identifier)?;
55    let address: SuiAddress = request.public_key.try_into()?;
56    Ok(ConstructionDeriveResponse {
57        account_identifier: address.into(),
58    })
59}
60
61/// Payloads is called with an array of operations and the response from /construction/metadata.
62/// It returns an unsigned transaction blob and a collection of payloads that must be signed by
63/// particular AccountIdentifiers using a certain SignatureType.
64///
65/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/generate-unsigned-transaction-and-signing-payloads)
66pub async fn payloads(
67    Extension(env): Extension<SuiEnv>,
68    WithRejection(Json(request), _): WithRejection<Json<ConstructionPayloadsRequest>, Error>,
69) -> Result<ConstructionPayloadsResponse, Error> {
70    env.check_network_identifier(&request.network_identifier)?;
71    let metadata = request.metadata.ok_or(Error::MissingMetadata)?;
72    if metadata.estimate_only {
73        return Err(Error::InvalidInput(
74            "metadata from an `estimate_only` request is a gas quote and cannot be used to build \
75             a transaction; call /construction/metadata without `estimate_only`"
76                .to_string(),
77        ));
78    }
79
80    let internal = request.operations.into_internal()?;
81    let data = internal.clone().try_into_data(metadata)?;
82    let wrapper = RosettaTransaction {
83        transaction: transaction_envelope::encode_inner_proto(&data),
84        signatures: vec![],
85        aux: internal.aux(),
86    };
87    let unsigned = transaction_envelope::encode(&wrapper)?;
88
89    let sender = data.sender();
90    let intent_msg = IntentMessage::new(Intent::sui_transaction(), data);
91    let mut hasher = DefaultHash::default();
92    bcs::serialize_into(&mut hasher, &intent_msg).expect("Message serialization should not fail");
93    let digest = hasher.finalize().digest;
94
95    Ok(ConstructionPayloadsResponse {
96        unsigned_transaction: unsigned,
97        payloads: vec![SigningPayload {
98            account_identifier: sender.into(),
99            hex_bytes: Hex::encode(digest),
100            signature_type: Some(SignatureType::Ed25519),
101        }],
102    })
103}
104
105/// Combine creates a network-specific transaction from an unsigned transaction
106/// and an array of provided signatures.
107///
108/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/create-network-transaction-from-signatures)
109pub async fn combine(
110    Extension(env): Extension<SuiEnv>,
111    WithRejection(Json(request), _): WithRejection<Json<ConstructionCombineRequest>, Error>,
112) -> Result<ConstructionCombineResponse, Error> {
113    env.check_network_identifier(&request.network_identifier)?;
114
115    let unsigned = transaction_envelope::decode(&request.unsigned_transaction)?;
116    let proto = transaction_envelope::decode_inner_proto(&unsigned.transaction)?;
117    let data = transaction_envelope::proto_to_transaction_data(proto)?;
118
119    let sig = request
120        .signatures
121        .first()
122        .ok_or_else(|| Error::MissingInput("Signature".to_string()))?;
123    let sig_bytes = sig.hex_bytes.to_vec()?;
124    let pub_key = sig.public_key.hex_bytes.to_vec()?;
125    let flag = vec![
126        match sig.signature_type {
127            SignatureType::Ed25519 => SignatureScheme::ED25519,
128            SignatureType::Ecdsa => SignatureScheme::Secp256k1,
129        }
130        .flag(),
131    ];
132    let generic_sig_bytes = [&*flag, &*sig_bytes, &*pub_key].concat();
133    let generic_sig = GenericSignature::from_bytes(&generic_sig_bytes)?;
134
135    let signed_tx =
136        sui_types::transaction::Transaction::from_generic_sig_data(data, vec![generic_sig]);
137    // TODO: this will likely fail with zklogin authenticator, since we do not know the current epoch.
138    // As long as coinbase doesn't need to use zklogin for custodial wallets this is okay.
139    let place_holder_epoch = 0;
140    verify_sender_signed_data_message_signatures(
141        &signed_tx,
142        place_holder_epoch,
143        &VerifyParams::default(),
144        Arc::new(VerifiedDigestCache::new_empty()), // no need to use cache in rosetta
145        // TODO: This will fail for tx sent from aliased addresses.
146        vec![],
147    )?;
148
149    // Pass the unchanged proto bytes and aux data through to the signed
150    // wrapper. Signatures live alongside; the inner transaction (and its
151    // aux data) is identical to what came in.
152    let signed_wrapper = RosettaTransaction {
153        transaction: unsigned.transaction,
154        signatures: vec![Hex::from_bytes(&generic_sig_bytes)],
155        aux: unsigned.aux,
156    };
157
158    Ok(ConstructionCombineResponse {
159        signed_transaction: transaction_envelope::encode(&signed_wrapper)?,
160    })
161}
162
163/// Submit a pre-signed transaction to the node.
164///
165/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/submit-signed-transaction)
166pub async fn submit(
167    State(context): State<OnlineServerContext>,
168    Extension(env): Extension<SuiEnv>,
169    WithRejection(Json(request), _): WithRejection<Json<ConstructionSubmitRequest>, Error>,
170) -> Result<TransactionIdentifierResponse, Error> {
171    env.check_network_identifier(&request.network_identifier)?;
172
173    let wrapper = transaction_envelope::decode(&request.signed_transaction)?;
174    if wrapper.signatures.is_empty() {
175        return Err(Error::DataError(
176            "cannot submit an unsigned transaction: wrapper carries no signatures".to_string(),
177        ));
178    }
179    // The wire-form proto has structured fields populated and bcs cleared,
180    // and gRPC accepts the structured form directly.
181    let proto_transaction = transaction_envelope::decode_inner_proto(&wrapper.transaction)?;
182
183    // Carry signatures straight from the wrapper.
184    let signatures = wrapper
185        .signatures
186        .iter()
187        .map(|sig_hex| {
188            let bytes = sig_hex.to_vec()?;
189            let generic = GenericSignature::from_bytes(&bytes)?;
190            Ok::<UserSignature, Error>(UserSignature::from(&generic))
191        })
192        .collect::<Result<Vec<_>, Error>>()?;
193
194    // According to RosettaClient.rosseta_flow() (see tests), this transaction has already passed
195    // through a dry_run with a possibly invalid budget (metadata endpoint), but the requirements
196    // are that it should pass from there and fail here.
197    //
198    // The balance-change read mask lets us verify, online, that a PayCoin
199    // wrapper's currency label matches the coin the transaction actually
200    // moves — the one part of the aux data that is fundamentally
201    // unverifiable offline (the source coin's on-chain type is not in the PTB).
202    let request = SimulateTransactionRequest::new(proto_transaction.clone())
203        .with_read_mask(FieldMask::from_paths([
204            "transaction.effects.status",
205            "transaction.balance_changes",
206        ]))
207        .with_checks(TransactionChecks::Enabled)
208        .with_do_gas_selection(false);
209
210    let response = context
211        .client
212        .clone()
213        .execution_client()
214        .simulate_transaction(request)
215        .await?
216        .into_inner();
217
218    let effects = response.transaction().effects();
219
220    if !effects.status().success() {
221        return Err(Error::TransactionDryRunError(Box::new(
222            effects.status().error().clone(),
223        )));
224    };
225
226    // Close the offline label-vs-reality gap (§7.7): if the wrapper claims
227    // a PayCoin currency, require the simulated balance changes to contain a
228    // non-SUI delta of that exact coin type. Otherwise the currency label
229    // disagrees with what the transaction actually moves — reject before
230    // broadcast. FSS validator / AtMost-cap online verification is deferred for
231    // v1 (those labels are display-only — the signed PTB determines execution,
232    // and `/block` re-derives the truth from chain).
233    verify_pay_coin_currency(
234        &wrapper.aux,
235        response
236            .transaction()
237            .balance_changes()
238            .iter()
239            .map(|bc| bc.coin_type()),
240    )?;
241
242    let mut client = context.client.clone();
243    let mut execution_client = client.execution_client();
244
245    let exec_request = ExecuteTransactionRequest::default()
246        .with_transaction(proto_transaction)
247        .with_signatures(signatures)
248        .with_read_mask(FieldMask::from_paths(["*"]));
249
250    let grpc_response = execution_client
251        .execute_transaction(exec_request)
252        .await?
253        .into_inner();
254
255    let transaction = grpc_response.transaction();
256    let effects = transaction.effects();
257    if !effects.status().success() {
258        return Err(Error::TransactionExecutionError(Box::new(
259            effects.status().error().clone(),
260        )));
261    }
262
263    let digest = transaction
264        .digest()
265        .parse::<TransactionDigest>()
266        .map_err(|e| Error::DataError(format!("Invalid transaction digest: {}", e)))?;
267
268    Ok(TransactionIdentifierResponse {
269        transaction_identifier: TransactionIdentifier { hash: digest },
270        metadata: None,
271    })
272}
273
274/// Preprocess is called prior to /construction/payloads to construct a request for any metadata
275/// that is needed for transaction construction given (i.e. account nonce).
276///
277/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/create-request-to-fetch-metadata)
278pub async fn preprocess(
279    Extension(env): Extension<SuiEnv>,
280    WithRejection(Json(request), _): WithRejection<Json<ConstructionPreprocessRequest>, Error>,
281) -> Result<ConstructionPreprocessResponse, Error> {
282    env.check_network_identifier(&request.network_identifier)?;
283
284    let internal_operation = request.operations.into_internal()?;
285    let sender = internal_operation.sender();
286    let (budget, estimate_only) = request
287        .metadata
288        .map_or((None, false), |m| (m.budget, m.estimate_only));
289    check_estimate_only_without_budget(estimate_only, budget)?;
290    Ok(ConstructionPreprocessResponse {
291        options: Some(MetadataOptions {
292            internal_operation,
293            budget,
294            estimate_only,
295        }),
296        required_public_keys: vec![sender.into()],
297    })
298}
299
300/// TransactionHash returns the network-specific transaction hash for a signed transaction.
301///
302/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/get-hash-of-signed-transaction)
303pub async fn hash(
304    Extension(env): Extension<SuiEnv>,
305    WithRejection(Json(request), _): WithRejection<Json<ConstructionHashRequest>, Error>,
306) -> Result<TransactionIdentifierResponse, Error> {
307    env.check_network_identifier(&request.network_identifier)?;
308
309    let wrapper = transaction_envelope::decode(&request.signed_transaction)?;
310    if wrapper.signatures.is_empty() {
311        return Err(Error::DataError(
312            "cannot hash an unsigned transaction: wrapper carries no signatures".to_string(),
313        ));
314    }
315    let proto = transaction_envelope::decode_inner_proto(&wrapper.transaction)?;
316    let data = transaction_envelope::proto_to_transaction_data(proto)?;
317
318    // sui_types::transaction::Transaction::digest() is a hash over
319    // bcs(TransactionData) with the intent prefix — signatures don't affect it.
320    // Reconstruct the signatures purely to satisfy the constructor; the digest
321    // would be identical with a dummy signature too.
322    let signatures = wrapper
323        .signatures
324        .iter()
325        .map(|sig_hex| {
326            let bytes = sig_hex.to_vec()?;
327            GenericSignature::from_bytes(&bytes).map_err(Error::from)
328        })
329        .collect::<Result<Vec<_>, Error>>()?;
330    let tx = sui_types::transaction::Transaction::from_generic_sig_data(data, signatures);
331
332    Ok(TransactionIdentifierResponse {
333        transaction_identifier: TransactionIdentifier { hash: *tx.digest() },
334        metadata: None,
335    })
336}
337
338/// Get any information required to construct a transaction for a specific network.
339/// For Sui, we are returning the latest object refs for all the input objects,
340/// which will be used in transaction construction.
341///
342/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/get-metadata-for-transaction-construction)
343pub async fn metadata(
344    State(mut context): State<OnlineServerContext>,
345    Extension(env): Extension<SuiEnv>,
346    WithRejection(Json(request), _): WithRejection<Json<ConstructionMetadataRequest>, Error>,
347) -> Result<ConstructionMetadataResponse, Error> {
348    env.check_network_identifier(&request.network_identifier)?;
349    let option = request.options.ok_or(Error::MissingMetadata)?;
350    let budget = option.budget;
351    let estimate_only = option.estimate_only;
352    check_estimate_only_without_budget(estimate_only, budget)?;
353    let sender = option.internal_operation.sender();
354    let currency = match &option.internal_operation {
355        InternalOperation::PayCoin(PayCoin { currency, .. }) => Some(currency.clone()),
356        _ => None,
357    };
358
359    let mut gas_price = context.client.get_reference_gas_price().await?;
360    // make sure it works over epoch changes
361    gas_price += 100;
362
363    // Check operation type before moving it
364    let is_pay_sui_or_stake = matches!(
365        &option.internal_operation,
366        InternalOperation::PaySui(_) | InternalOperation::Stake(_)
367    );
368
369    let fetched = option
370        .internal_operation
371        .try_fetch_needed_objects(&mut context.client.clone(), Some(gas_price), budget)
372        .await;
373
374    // A quote request for an account that cannot pay gas: the fullnode reports the budget the
375    // transaction needs with the account's current SUI (the `requiredBudget` metadata entry). A
376    // detail without a parseable budget is not a usable quote, so the original error is returned
377    // instead.
378    if estimate_only
379        && let Err(e) = &fetched
380        && let Some(required_budget) = insufficient_gas_required_budget(e)
381    {
382        return Ok(ConstructionMetadataResponse {
383            metadata: ConstructionMetadata {
384                sender,
385                gas_coins: vec![],
386                extra_gas_coins: vec![],
387                objects: vec![],
388                party_objects: vec![],
389                total_coin_value: 0,
390                gas_price,
391                budget: required_budget,
392                currency,
393                address_balance_withdrawal: 0,
394                epoch: None,
395                chain_id: None,
396                nonce: None,
397                fss_object_count: None,
398                redeem_token_amount: None,
399                redeem_plan: None,
400                bind_epoch: None,
401                estimate_only,
402            },
403            suggested_fee: vec![Amount::new(required_budget as i128, None)],
404        });
405    }
406    let needed_objects = fetched?;
407
408    // Gasless ("free tier") PayCoin: zero the gas price so the downstream tx is recognized as
409    // gasless (`is_gasless_transaction` requires `price == 0`). Priced address-balance gas keeps
410    // `gas_price > 0`.
411    if needed_objects.is_gasless() {
412        gas_price = 0;
413    }
414
415    let TransactionObjectData {
416        gas_coins,
417        objects,
418        party_objects,
419        total_sui_balance,
420        budget,
421        address_balance_withdrawal,
422        fss_object_count,
423        redeem_token_amount,
424        redeem_plan,
425        bind_epoch,
426    } = needed_objects;
427
428    // For backwards compatibility during rolling deployments, populate extra_gas_coins.
429    // Old clients expect this field to be present.
430    // For PaySui/Stake: extra_gas_coins contains the coins to merge (same as objects)
431    // For PayCoin/WithdrawStake: extra_gas_coins is empty
432    let extra_gas_coins = if is_pay_sui_or_stake {
433        objects.clone()
434    } else {
435        vec![]
436    };
437
438    let nonce = if gas_coins.is_empty() {
439        Some(rand::random::<u32>())
440    } else {
441        None
442    };
443
444    // Fetch epoch and chain_id for address-balance gas transactions.
445    //
446    // Prefer `bind_epoch` (atomic with the rate snapshot from
447    // `get_validator_set_snapshot`) over a separate `get_current_epoch` RPC.
448    // If both are needed and `bind_epoch` is set, reusing it both saves an
449    // RPC and guarantees `metadata.epoch == bind_epoch`. Without this, an
450    // epoch transition between the two RPCs would leave them disagreeing,
451    // causing the bind-epoch mismatch check at signing time to reject the
452    // metadata even though both reads were individually valid.
453    let needs_address_balance_metadata = gas_coins.is_empty() || address_balance_withdrawal > 0;
454    let (epoch, chain_id) = if needs_address_balance_metadata {
455        let epoch = match bind_epoch {
456            Some(e) => e,
457            None => crate::get_current_epoch(&mut context.client.clone()).await?,
458        };
459        let chain_id_str =
460            sui_types::digests::CheckpointDigest::new(*context.chain_id.as_bytes()).base58_encode();
461        (Some(epoch), Some(chain_id_str))
462    } else {
463        (None, None)
464    };
465
466    Ok(ConstructionMetadataResponse {
467        metadata: ConstructionMetadata {
468            sender,
469            gas_coins,
470            extra_gas_coins,
471            objects,
472            party_objects,
473            total_coin_value: total_sui_balance,
474            gas_price,
475            budget,
476            currency,
477            address_balance_withdrawal,
478            epoch,
479            chain_id,
480            nonce,
481            fss_object_count,
482            redeem_token_amount,
483            redeem_plan,
484            bind_epoch,
485            estimate_only,
486        },
487        suggested_fee: vec![Amount::new(budget as i128, None)],
488    })
489}
490
491/// `estimate_only` quotes the budget the fullnode estimates, so a caller-supplied budget would make
492/// the quote meaningless.
493fn check_estimate_only_without_budget(
494    estimate_only: bool,
495    budget: Option<u64>,
496) -> Result<(), Error> {
497    if estimate_only && budget.is_some() {
498        return Err(Error::InvalidInput(
499            "`estimate_only` cannot be combined with `budget`".to_string(),
500        ));
501    }
502    Ok(())
503}
504
505///  This is run as a sanity check before signing (after /construction/payloads)
506/// and before broadcast (after /construction/combine).
507///
508/// [Mesh API Spec](https://docs.cdp.coinbase.com/api-reference/mesh/construction/parse-transaction)
509pub async fn parse(
510    Extension(env): Extension<SuiEnv>,
511    WithRejection(Json(request), _): WithRejection<Json<ConstructionParseRequest>, Error>,
512) -> Result<ConstructionParseResponse, Error> {
513    env.check_network_identifier(&request.network_identifier)?;
514
515    // /parse reconstructs operations *from the transaction* (the same parser
516    // the indexing/`/block` path uses), then applies the wrapper's aux data
517    // (the labels the PTB cannot encode). The PTB fields are signature-covered;
518    // the aux-data labels are server-supplied (PayCoin currency is verified
519    // online in `/submit`, FSS labels are display-only).
520    let wrapper = transaction_envelope::decode(&request.transaction)?;
521    let (aux, transaction, signed) = (wrapper.aux, wrapper.transaction, request.signed);
522
523    let proto = transaction_envelope::decode_inner_proto(&transaction)?;
524    let operations = reconstruct_operations(&proto, &aux, None)?;
525
526    // Signers come from the transaction sender, never the aux data.
527    let account_identifier_signers = if signed {
528        vec![
529            SuiAddress::from_str(proto.sender())
530                .map_err(|e| Error::DataError(format!("invalid transaction sender: {e}")))?
531                .into(),
532        ]
533    } else {
534        vec![]
535    };
536
537    // Force a full `TransactionData` decode so envelopes with a valid-looking
538    // `kind` but malformed gas payment / expiration / etc. are rejected here
539    // rather than at `/hash` / `/combine` / `/submit`. `/parse` is the spec's
540    // sanity check; it must surface structural decode failures the same way
541    // the downstream endpoints would.
542    let _ = transaction_envelope::proto_to_transaction_data(proto)?;
543
544    Ok(ConstructionParseResponse {
545        operations,
546        account_identifier_signers,
547        metadata: None,
548    })
549}
550
551/// For a `PayCoin` aux-data label, require that the transaction's (simulated)
552/// balance changes actually move a non-SUI coin of the labelled type. This is
553/// the one part of the aux data that cannot be verified offline — the source
554/// coin's on-chain type is not encoded in the PTB — so it is checked online in
555/// `/submit` against the simulate response. Non-`PayCoin` aux data is a
556/// no-op.
557fn verify_pay_coin_currency<'a>(
558    aux: &AuxData,
559    moved_coin_types: impl IntoIterator<Item = &'a str>,
560) -> Result<(), Error> {
561    let AuxData::PayCoin { currency } = aux else {
562        return Ok(());
563    };
564    let want = TypeTag::from_str(&currency.metadata.coin_type)
565        .map_err(|e| Error::DataError(format!("invalid PayCoin currency coin_type: {e}")))?;
566    let sui = TypeTag::from_str("0x2::sui::SUI").expect("0x2::sui::SUI is a valid type tag");
567    let matched = moved_coin_types.into_iter().any(|ct| {
568        TypeTag::from_str(ct)
569            .map(|t| t == want && t != sui)
570            .unwrap_or(false)
571    });
572    if matched {
573        Ok(())
574    } else {
575        Err(Error::DataError(format!(
576            "PayCoin currency {} does not match any non-SUI balance change in the simulated \
577             transaction",
578            currency.metadata.coin_type
579        )))
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::types::{Currency, CurrencyMetadata};
587
588    fn pay_coin(coin_type: &str) -> AuxData {
589        AuxData::PayCoin {
590            currency: Currency {
591                symbol: "USDC".to_string(),
592                decimals: 6,
593                metadata: CurrencyMetadata {
594                    coin_type: coin_type.to_string(),
595                },
596            },
597        }
598    }
599
600    /// §12 test 14: the `/submit` PayCoin currency check accepts a balance
601    /// change of the labelled coin type and rejects when only SUI / a
602    /// different coin moves.
603    #[test]
604    fn test_verify_pay_coin_currency() {
605        let usdc = "0x5::usdc::USDC";
606        let aux = pay_coin(usdc);
607
608        // Labelled coin present among the balance changes → ok.
609        assert!(verify_pay_coin_currency(&aux, ["0x2::sui::SUI", usdc]).is_ok());
610
611        // A different non-SUI coin moves (currency label disagrees with
612        // reality) → reject.
613        let err = verify_pay_coin_currency(&aux, ["0x2::sui::SUI", "0x9::other::OTHER"])
614            .expect_err("mismatched currency must be rejected");
615        assert!(format!("{err:?}").contains("does not match"));
616
617        // Only SUI moves → reject.
618        assert!(verify_pay_coin_currency(&aux, ["0x2::sui::SUI"]).is_err());
619
620        // A PayCoin label that (wrongly) names SUI never matches — SUI is
621        // explicitly excluded as a non-SUI delta.
622        assert!(verify_pay_coin_currency(&pay_coin("0x2::sui::SUI"), ["0x2::sui::SUI"]).is_err());
623
624        // Non-PayCoin aux data is a no-op regardless of balance changes.
625        assert!(verify_pay_coin_currency(&AuxData::None, std::iter::empty()).is_ok());
626    }
627}