Skip to content

@mysten/sui: gas budget suppresses the required ValidDuring expiration on address-balance sponsored transactions #1147

Description

@nikola0x0

Steps to Reproduce Issue

  1. Create a Transaction with any command.
  2. Configure address-balance sponsored gas: setSender(...), setGasOwner(sponsor), setGasPayment([]).
  3. Also set a gas budget: setGasBudget(2_000_000n).
  4. Build against a testnet SuiGrpcClient and read the expiration from the built bytes: Transaction.from(bytes).getData().expiration.
  5. Compare with the identical transaction built without step 3.
import { SuiGrpcClient } from "@mysten/sui/grpc"
import { Transaction } from "@mysten/sui/transactions"

// Any two testnet addresses work; the gas owner should hold a little SUI so
// the budget-unset control can do gas selection.
const SENDER = "0x<any address>"
const GAS_OWNER = "0x<funded address>"

const client = new SuiGrpcClient({
  network: "testnet",
  baseUrl: "https://fullnode.testnet.sui.io:443",
})

async function sponsoredExpiration(withBudget: boolean) {
  const tx = new Transaction()
  tx.moveCall({ target: "0x1::option::none", typeArguments: ["u64"] })
  tx.setSender(SENDER)
  tx.setGasOwner(GAS_OWNER)
  tx.setGasPayment([]) // address-balance sponsored gas
  if (withBudget) tx.setGasBudget(2_000_000n)
  const bytes = await tx.build({ client })
  return Transaction.from(bytes).getData().expiration
}

console.log(await sponsoredExpiration(false))
console.log(await sponsoredExpiration(true))

Expected Result

Both calls print a ValidDuring expiration, a transaction with an empty gas payment needs the bounded expiration regardless of whether a budget is present:

{ $kind: "ValidDuring", ValidDuring: { minEpoch: "1164", maxEpoch: "1165", ... } }
{ $kind: "ValidDuring", ValidDuring: { ... } }

Actual Result

The budget-set build comes back with no expiration:

{ $kind: "ValidDuring", ValidDuring: { minEpoch: "1164", maxEpoch: "1165", ... } }
{ $kind: "None", None: true }

Downstream, an @mysten-incubation/sponsor service rejects those bytes:

403 {"error":"Sponsor policy rejected the transaction","reason":"POLICY_REJECTED",
"issues":[{"code":"EXPIRATION_REQUIRED","message":"Transaction must set a bounded
epoch expiration (valid through the next epoch)."}]}

We hit this in production behind a sponsor service: transactions rebuilt after a wallet-side retry began failing with the 403 above, while identical freshly-constructed transactions from other sessions succeeded, which made a deterministic client-side state bug look like a flaky, user-specific server issue.

Root cause. A transaction with address-balance sponsored gas must carry a ValidDuring expiration, it is Sui's replay protection when there is no owned gas object to lock, and sponsor policies built on @mysten-incubation/sponsor (boundedExpiration, part of defaults) hard-reject bytes without it.

The resolver only attaches that expiration while doing gas selection, and gas selection is skipped whenever the transaction already carries a budget AND a payment:

doGasSelection:
!options.onlyTransactionKind &&
(snapshot.gasData.budget == null || snapshot.gasData.payment == null),

doGasSelection:
!options.onlyTransactionKind &&
(snapshot.gasData.budget == null || snapshot.gasData.payment == null),

payment: [] is a defined payment, so a pre-set gas budget alone disables gas selection and with it, the expiration attach. Two ways this state arises in ordinary API usage:

  1. Explicit budget, sponsored from the start. A developer caps the sponsor's per-transaction gas with setGasBudget() a natural thing for a sponsorship setup to do. The very first build produces expiration-less bytes; the flow has never worked. No instance reuse involved.
  2. Deserialized / reused transactions. Transaction.from(bytes) always materializes an explicit None expiration (BCS encodes an expiration variant), and a once-built instance caches its resolved gas fields back into itself. A truthy None with an empty payment then defeats the SDK twice over: needsTransactionResolution treats the expiration as "set" and skips resolution entirely, and even when resolution does run, applyResolvedData only fills the expiration when it is falsy, so the ValidDuring the node returns is silently discarded. Re-targeting any re-hydrated or previously wallet-built transaction to sponsored gas (e.g. a retry after a wallet-side failure) deterministically produces expiration-less bytes, even though the caller never touched setGasBudget.

Notably, the JSON-RPC coreClientResolveTransactionPlugin already handles the preset-budget case correctly, it attaches the expiration in a final payment.length === 0 check after gas resolution, so the gRPC and GraphQL resolvers just lack that parity, while the explicit-None state defeats all three.

#956 and #1070 previously patched other gaps in the same attach logic; this is a third path around it.

Suggested fix (three coordinated changes, verified end-to-end against testnet, all variants then attach ValidDuring, and simulation with checks enabled passes; PR to follow):

  1. resolveTransactionPlugin (packages/sui/src/transactions/resolve.ts): normalize an explicit None expiration to unset when the gas payment is empty, before resolution, so resolution runs and applyResolvedData can apply the expiration the node attaches.
  2. needsTransactionResolution (same file): treat None + empty payment as needing resolution, for standalone callers.
  3. Both doGasSelection conditions (packages/sui/src/grpc/core.ts, packages/sui/src/graphql/core.ts): also request gas selection when the payment is empty and no expiration is set, regardless of a preset budget, matching the final expiration check coreClientResolveTransactionPlugin already performs.

Even if skipping gas selection for fully-specified gas is intentional, the current outcome can't be: a transaction with an empty payment and no expiration is invalid at the protocol level (address-balance gas has no owned object to lock, so the bounded expiration IS the replay protection), so the build silently emits bytes that can never execute. If attaching here is undesirable, the build should throw a descriptive error instead and note the pre-set budget is not a user choice: build() itself writes the resolved budget back into the Transaction instance, so any rebuilt instance is in this state. #1070 established the same invariant (gasless ⇒ ValidDuring) for the JSON-RPC path.

Environment Information

  • Runtime: bun@1.3.9 (also reproduced in-browser, Chrome via gRPC-web transport)
  • Package manager: bun@1.3.9
  • @mysten/sui: reproduced on 2.20.3 and 2.22.0 (latest)
  • Network: testnet, chain 69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions