Steps to Reproduce Issue
- Create a
Transaction with any command.
- Configure address-balance sponsored gas:
setSender(...), setGasOwner(sponsor), setGasPayment([]).
- Also set a gas budget:
setGasBudget(2_000_000n).
- Build against a testnet
SuiGrpcClient and read the expiration from the built bytes: Transaction.from(bytes).getData().expiration.
- 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:
- 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.
- 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):
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.
needsTransactionResolution (same file): treat None + empty payment as needing resolution, for standalone callers.
- 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
Steps to Reproduce Issue
Transactionwith any command.setSender(...),setGasOwner(sponsor),setGasPayment([]).setGasBudget(2_000_000n).SuiGrpcClientand read the expiration from the built bytes:Transaction.from(bytes).getData().expiration.Expected Result
Both calls print a
ValidDuringexpiration, a transaction with an empty gas payment needs the bounded expiration regardless of whether a budget is present:Actual Result
The budget-set build comes back with no expiration:
Downstream, an
@mysten-incubation/sponsorservice rejects those bytes: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
ValidDuringexpiration, 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 ofdefaults) 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:
ts-sdks/packages/sui/src/grpc/core.ts
Lines 728 to 730 in a65bfe9
ts-sdks/packages/sui/src/graphql/core.ts
Lines 740 to 742 in a65bfe9
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: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.Transaction.from(bytes)always materializes an explicitNoneexpiration (BCS encodes an expiration variant), and a once-built instance caches its resolved gas fields back into itself. A truthyNonewith an empty payment then defeats the SDK twice over:needsTransactionResolutiontreats the expiration as "set" and skips resolution entirely, and even when resolution does run,applyResolvedDataonly fills the expiration when it is falsy, so theValidDuringthe 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 touchedsetGasBudget.Notably, the JSON-RPC
coreClientResolveTransactionPluginalready handles the preset-budget case correctly, it attaches the expiration in a finalpayment.length === 0check after gas resolution, so the gRPC and GraphQL resolvers just lack that parity, while the explicit-Nonestate 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):resolveTransactionPlugin(packages/sui/src/transactions/resolve.ts): normalize an explicitNoneexpiration to unset when the gas payment is empty, before resolution, so resolution runs andapplyResolvedDatacan apply the expiration the node attaches.needsTransactionResolution(same file): treatNone+ empty payment as needing resolution, for standalone callers.doGasSelectionconditions (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 checkcoreClientResolveTransactionPluginalready 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 theTransactioninstance, so any rebuilt instance is in this state. #1070 established the same invariant (gasless ⇒ ValidDuring) for the JSON-RPC path.Environment Information
@mysten/sui: reproduced on 2.20.3 and 2.22.0 (latest)69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD