From f94997cfd1c93df73208b7646b02c65e513aaa49 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:04:24 +0700 Subject: [PATCH 1/4] fix(server): use address balances for Walrus uploads --- .../__tests__/sidecar-query-helpers.test.ts | 67 ++++++++++++++++- .../server/scripts/sidecar/address-balance.ts | 72 +++++++++++++++++++ services/server/scripts/sidecar/enoki.ts | 2 + .../scripts/sidecar/routes/walrus-metadata.ts | 15 +++- .../sidecar/routes/walrus-upload-journal.ts | 2 + .../scripts/sidecar/routes/walrus-upload.ts | 44 ++++++++---- services/server/scripts/sidecar/wallet.ts | 9 +++ services/server/scripts/walrus-upload.ts | 7 ++ 8 files changed, 201 insertions(+), 17 deletions(-) create mode 100644 services/server/scripts/sidecar/address-balance.ts diff --git a/services/server/scripts/__tests__/sidecar-query-helpers.test.ts b/services/server/scripts/__tests__/sidecar-query-helpers.test.ts index 6377c8c64..d7d2ce1c4 100644 --- a/services/server/scripts/__tests__/sidecar-query-helpers.test.ts +++ b/services/server/scripts/__tests__/sidecar-query-helpers.test.ts @@ -16,7 +16,12 @@ import { strictWalrusEpoch, } from "../sidecar/routes/walrus-query.js"; import { assertSuccessfulMetadataTransfer, extractBlobObjectId } from "../sidecar/blob-metadata.js"; -import { parseDurableWalrusEpochs, SUI_TYPE, WALRUS_PACKAGE_ID } from "../sidecar/config.js"; +import { + ENOKI_FALLBACK_TO_DIRECT_SIGN, + parseDurableWalrusEpochs, + SUI_TYPE, + WALRUS_PACKAGE_ID, +} from "../sidecar/config.js"; import { assertAddressBalanceRegisterTransaction, createdBlobObjectIdFromTransaction, @@ -29,7 +34,11 @@ import { classifyDurableSideEffectError, NoSideEffectError } from "../sidecar/re import { uploadWalrusBlobWithEffectsRetry } from "../sidecar/routes/walrus-upload.js"; import { metadataReceiptAlreadyApplied } from "../sidecar/routes/walrus-metadata.js"; import { sidecarMetrics } from "../sidecar/state.js"; -import { DURABLE_WALLET_FALLBACK_POLICY } from "../sidecar/wallet.js"; +import { + ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, + DURABLE_WALLET_FALLBACK_POLICY, +} from "../sidecar/wallet.js"; +import { enforceAddressBalanceCoinIntents } from "../sidecar/address-balance.js"; async function preparedRegisterFixture(funding: "addressBalance" | "coin" | "mixedLegacy" = "addressBalance"): Promise<{ signer: Ed25519Keypair; @@ -110,6 +119,14 @@ test("durable submissions direct-sign only when sponsorship is unconfigured", () }); }); +test("legacy uploads preserve fallback behavior while using address balances", () => { + assert.deepEqual(ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, { + directSignIfUnconfigured: ENOKI_FALLBACK_TO_DIRECT_SIGN, + directSignAfterSponsorFailure: ENOKI_FALLBACK_TO_DIRECT_SIGN, + gasMode: "addressBalance", + }); +}); + test("prepared registration rejects tampered digest, signature, and wallet", async () => { const { signer, prepared } = await preparedRegisterFixture(); await assert.doesNotReject(validatePreparedRegisterTransaction(prepared, signer.toSuiAddress())); @@ -212,6 +229,52 @@ test("the pinned Sui SDK resolves WAL payment and relay SUI tip from address bal assert.equal(withdrawals.length, 2, "WAL payment and relay SUI tip both use address balances"); }); +test("address-balance uploads fail instead of falling back to owned coins", async () => { + const signer = new Ed25519Keypair(); + const walType = `0x${"2".repeat(64)}::wal::WAL`; + const transaction = new Transaction(); + transaction.setSender(signer.toSuiAddress()); + transaction.coin({ type: walType, balance: 1n, useGasCoin: false }); + enforceAddressBalanceCoinIntents(transaction); + + let balanceCalls = 0; + const client = { + core: { + async getBalance() { + balanceCalls += 1; + return { + balance: { + balance: "1", + addressBalance: balanceCalls === 1 ? "1" : "0", + coinBalance: balanceCalls === 1 ? "0" : "1", + }, + }; + }, + async listCoins() { + return { + objects: [{ + objectId: `0x${"1".repeat(64)}`, + version: "1", + digest: "11111111111111111111111111111111", + balance: "1", + coinType: walType, + }], + cursor: null, + hasNextPage: false, + }; + }, + }, + }; + await assert.rejects( + transaction.prepareForSerialization({ client: client as never }), + /Address-balance upload resolved owned coin objects/, + ); + await assert.rejects( + transaction.prepareForSerialization({ client: client as never }), + /Address-balance upload resolved owned coin objects/, + ); +}); + test("migration source status trusts only verified nonexistent blobs", () => { assert.equal(isVerifiedNonexistentSource({ type: "nonexistent" }), true); assert.equal(isVerifiedNonexistentSource({ type: "invalid" }), false); diff --git a/services/server/scripts/sidecar/address-balance.ts b/services/server/scripts/sidecar/address-balance.ts new file mode 100644 index 000000000..d6965725c --- /dev/null +++ b/services/server/scripts/sidecar/address-balance.ts @@ -0,0 +1,72 @@ +import type { Transaction } from "@mysten/sui/transactions"; +import { normalizeStructTag } from "@mysten/sui/utils"; +import { SUI_TYPE } from "./config.js"; + +const COIN_WITH_BALANCE_INTENT = "CoinWithBalance"; + +/** + * Prevent the SDK's CoinWithBalance resolver from falling back to owned coins + * when an address balance is insufficient. + */ +export function enforceAddressBalanceCoinIntents(transaction: Transaction): void { + const initialOwnedObjectIds = new Set( + (transaction.getData() as any).inputs + .map((input: any) => ( + input.Object?.ImmOrOwnedObject?.objectId ?? input.UnresolvedObject?.objectId + )) + .filter((objectId: unknown): objectId is string => typeof objectId === "string"), + ); + transaction.addSerializationPlugin(async (transactionData, options, next) => { + const requiredByType = new Map(); + for (const command of transactionData.commands) { + if (command.$kind !== "$Intent" || command.$Intent.name !== COIN_WITH_BALANCE_INTENT) { + continue; + } + const type = command.$Intent.data?.type; + const balance = command.$Intent.data?.balance; + if ( + typeof type !== "string" + || (typeof balance !== "bigint" && typeof balance !== "number" && typeof balance !== "string") + ) { + continue; + } + const coinType = type === "gas" ? SUI_TYPE : normalizeStructTag(type); + requiredByType.set(coinType, (requiredByType.get(coinType) ?? 0n) + BigInt(balance)); + } + + if (requiredByType.size > 0) { + const client = options.client as any; + if (!client?.core?.getBalance || !transactionData.sender) { + throw new Error("Address-balance upload requires a sender and Sui client"); + } + + await Promise.all([...requiredByType.entries()].map(async ([coinType, required]) => { + const response = await client.core.getBalance({ + owner: transactionData.sender, + coinType, + }); + const available = BigInt(response?.balance?.addressBalance ?? 0); + if (available < required) { + throw new Error( + `Insufficient ${coinType} address balance: required ${required}, available ${available}`, + ); + } + })); + } + + await next(); + const newlyResolvedOwnedObjects = transactionData.inputs + .map((input) => input.Object?.ImmOrOwnedObject?.objectId) + .filter((objectId): objectId is string => ( + typeof objectId === "string" && !initialOwnedObjectIds.has(objectId) + )); + if (newlyResolvedOwnedObjects.length > 0) { + throw new Error( + `Address-balance upload resolved owned coin objects: ${newlyResolvedOwnedObjects.join(", ")}`, + ); + } + if (transactionData.gasData.payment && transactionData.gasData.payment.length > 0) { + throw new Error("Address-balance upload resolved gas from an owned coin"); + } + }); +} diff --git a/services/server/scripts/sidecar/enoki.ts b/services/server/scripts/sidecar/enoki.ts index 6142f8ad2..85324dd86 100644 --- a/services/server/scripts/sidecar/enoki.ts +++ b/services/server/scripts/sidecar/enoki.ts @@ -21,6 +21,7 @@ import { ENOKI_TRANSIENT_MAX_DELAY_MS, } from "./config.js"; import { suiClient } from "./clients.js"; +import { enforceAddressBalanceCoinIntents } from "./address-balance.js"; import { errorMessage, truncateForLog } from "./util.js"; type EnokiDataWrapper = { data: T }; @@ -239,6 +240,7 @@ export async function executeWithEnokiSponsor( onSubmissionStarted: () => void = () => {}, ): Promise { if (fallbackPolicy.gasMode === "addressBalance") { + enforceAddressBalanceCoinIntents(tx); tx.setGasPayment([]); } diff --git a/services/server/scripts/sidecar/routes/walrus-metadata.ts b/services/server/scripts/sidecar/routes/walrus-metadata.ts index 8ed8a65d2..eeb62ca44 100644 --- a/services/server/scripts/sidecar/routes/walrus-metadata.ts +++ b/services/server/scripts/sidecar/routes/walrus-metadata.ts @@ -24,7 +24,10 @@ import { setMetadataAndTransferBlobs, type MetadataTransferBlob, } from "../blob-metadata.js"; -import { DURABLE_WALLET_FALLBACK_POLICY } from "../wallet.js"; +import { + ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, + DURABLE_WALLET_FALLBACK_POLICY, +} from "../wallet.js"; import { ownerMatchesRecipient, readBlobObject } from "./walrus-query.js"; import { assertUploadExecutionIdentity, parseUploadExecutionIdentity } from "./health.js"; @@ -104,7 +107,15 @@ function registerWalrusMetadataBatchRoute(app: Express): void { releaseWalrusUploadSlots = await acquireWalrusUploadSlots(keySlot, traceId); const { secretKey } = decodeSuiPrivateKey(privateKey); const signer = Ed25519Keypair.fromSecretKey(secretKey); - const digest = await setMetadataAndTransferBlobs(signer, normalized, targetOwner, packageId, agentId); + const digest = await setMetadataAndTransferBlobs( + signer, + normalized, + targetOwner, + packageId, + agentId, + {}, + ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, + ); console.log(`[walrus/set-metadata-batch] transferred ${normalized.length} blobs to owner`); res.json({ transferred: normalized.length, digest }); } catch (err: any) { diff --git a/services/server/scripts/sidecar/routes/walrus-upload-journal.ts b/services/server/scripts/sidecar/routes/walrus-upload-journal.ts index 83423d7c2..e37a59f1a 100644 --- a/services/server/scripts/sidecar/routes/walrus-upload-journal.ts +++ b/services/server/scripts/sidecar/routes/walrus-upload-journal.ts @@ -56,6 +56,7 @@ import { readBlobObject, } from "./walrus-query.js"; import { uploadWalrusBlobWithEffectsRetry } from "./walrus-upload.js"; +import { enforceAddressBalanceCoinIntents } from "../address-balance.js"; import { assertUploadExecutionIdentity, parseUploadExecutionIdentity, @@ -550,6 +551,7 @@ export function registerWalrusUploadJournalRoute(app: Express): void { memwal_migration_job: jobId, }, }); + enforceAddressBalanceCoinIntents(registerTx); const registerTransaction = await prepareRegisterTransaction( registerTx, signer, diff --git a/services/server/scripts/sidecar/routes/walrus-upload.ts b/services/server/scripts/sidecar/routes/walrus-upload.ts index 90b747deb..1db60d40d 100644 --- a/services/server/scripts/sidecar/routes/walrus-upload.ts +++ b/services/server/scripts/sidecar/routes/walrus-upload.ts @@ -37,7 +37,12 @@ import { requestIdFor, sanitizeRequestId, sidecarLog } from "../log.js"; import { sidecarStartedAtMs, sidecarStateSnapshot } from "../state.js"; import { dedupeAddresses, errorMessage, parseWalrusKeySlot, shortAddress, sleep, truncateForLog } from "../util.js"; import { isMoveAbortBalanceSplit, isMoveAbortWalDestroyZero } from "../enoki.js"; -import { patchGasCoinIntents, submitRebuildableWalletTransaction, submitWalletTransaction } from "../wallet.js"; +import { + ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, + patchGasCoinIntents, + submitRebuildableWalletTransaction, + submitWalletTransaction, +} from "../wallet.js"; import { extractBlobObjectId, InvalidSealPersistenceFenceError, @@ -255,7 +260,12 @@ export function registerWalrusUploadRoute(app: Express): void { ); const registerDigest = await timedPhase( "register_sponsor", - () => submitWalletTransaction(registerTx, signer, registerAllowedAddresses), + () => submitWalletTransaction( + registerTx, + signer, + registerAllowedAddresses, + ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, + ), (digest) => ({ digest }) ); await timedPhase( @@ -283,11 +293,18 @@ export function registerWalrusUploadRoute(app: Express): void { const certifyDigest = await timedPhase( "certify_sponsor", () => - submitRebuildableWalletTransaction("certify_sponsor", () => flow.certify(), signer, undefined, { - traceId, - jobId: jobIdForLog, - keyIndex: keySlot, - }), + submitRebuildableWalletTransaction( + "certify_sponsor", + () => flow.certify(), + signer, + undefined, + { + traceId, + jobId: jobIdForLog, + keyIndex: keySlot, + }, + ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, + ), (digest) => ({ digest }) ); await timedPhase( @@ -311,17 +328,18 @@ export function registerWalrusUploadRoute(app: Express): void { "metadata_transfer", () => setMetadataAndTransferBlobs( - signer, + signer, [{ blobObjectId, namespace, sealFence }], - owner, - packageId, - agentId, + owner, + packageId, + agentId, { traceId, jobId: jobIdForLog, keyIndex: keySlot, - } - ), + }, + ADDRESS_BALANCE_WALLET_FALLBACK_POLICY, + ), (digest) => ({ digest, blobObjectId }) ); console.log( diff --git a/services/server/scripts/sidecar/wallet.ts b/services/server/scripts/sidecar/wallet.ts index f90363ed7..8cb5cf7e6 100644 --- a/services/server/scripts/sidecar/wallet.ts +++ b/services/server/scripts/sidecar/wallet.ts @@ -13,6 +13,7 @@ import { isWalrusObjectLockEquivocation, } from "../walrus-error-detection.js"; import { + ENOKI_FALLBACK_TO_DIRECT_SIGN, ENOKI_INVALIDATED_BASE_DELAY_MS, ENOKI_INVALIDATED_MAX_ATTEMPTS, ENOKI_INVALIDATED_MAX_DELAY_MS, @@ -34,6 +35,14 @@ export const DURABLE_WALLET_FALLBACK_POLICY: EnokiFallbackPolicy = { gasMode: "addressBalance", }; +// Legacy uploads are not journaled, so preserve their configured direct-sign +// fallback while ensuring every transaction uses address balances. +export const ADDRESS_BALANCE_WALLET_FALLBACK_POLICY: EnokiFallbackPolicy = { + directSignIfUnconfigured: ENOKI_FALLBACK_TO_DIRECT_SIGN, + directSignAfterSponsorFailure: ENOKI_FALLBACK_TO_DIRECT_SIGN, + gasMode: "addressBalance", +}; + export function assertFinalizedTransactionSuccess(result: any, label: string): any { if ( result?.$kind === "FailedTransaction" diff --git a/services/server/scripts/walrus-upload.ts b/services/server/scripts/walrus-upload.ts index b5cb98a8f..ce3dcaf07 100644 --- a/services/server/scripts/walrus-upload.ts +++ b/services/server/scripts/walrus-upload.ts @@ -24,6 +24,7 @@ import { WalrusClient } from "@mysten/walrus"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { decodeSuiPrivateKey } from "@mysten/sui/cryptography"; +import { enforceAddressBalanceCoinIntents } from "./sidecar/address-balance.js"; // ============================================================ // Parse CLI arguments @@ -136,6 +137,10 @@ async function main() { owner: signerAddress, deletable: true, }); + enforceAddressBalanceCoinIntents(registerTx); + // Resolve both gas and Walrus storage payment from the signer's address + // balance instead of selecting owned coin objects. + registerTx.setGasPayment([]); // Sign and execute the register transaction const registerResult = await suiClient.signAndExecuteTransaction({ @@ -154,6 +159,8 @@ async function main() { // Step 4: Certify blob on Sui → returns a Transaction const certifyTx = flow.certify(); + enforceAddressBalanceCoinIntents(certifyTx); + certifyTx.setGasPayment([]); // Sign and execute the certify transaction const certifyResult = await suiClient.signAndExecuteTransaction({ From 8849821a9aaf7ab0bab7b25795965e314256fe3e Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:13:11 +0700 Subject: [PATCH 2/4] fix(server): preserve async objects across upload retries --- .../__tests__/sidecar-query-helpers.test.ts | 22 +++++++++++++++++++ .../server/scripts/sidecar/address-balance.ts | 15 +++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/services/server/scripts/__tests__/sidecar-query-helpers.test.ts b/services/server/scripts/__tests__/sidecar-query-helpers.test.ts index d7d2ce1c4..b875ca0d6 100644 --- a/services/server/scripts/__tests__/sidecar-query-helpers.test.ts +++ b/services/server/scripts/__tests__/sidecar-query-helpers.test.ts @@ -275,6 +275,28 @@ test("address-balance uploads fail instead of falling back to owned coins", asyn ); }); +test("address-balance enforcement preserves async object inputs across rebuilds", async () => { + const signer = new Ed25519Keypair(); + const objectId = `0x${"3".repeat(64)}`; + const transaction = new Transaction(); + transaction.setSender(signer.toSuiAddress()); + transaction.add(async (tx) => { + await Promise.resolve(); + tx.transferObjects( + [tx.objectRef({ + objectId, + version: "1", + digest: "11111111111111111111111111111111", + })], + signer.toSuiAddress(), + ); + }); + enforceAddressBalanceCoinIntents(transaction); + + await assert.doesNotReject(transaction.build({ onlyTransactionKind: true })); + await assert.doesNotReject(transaction.build({ onlyTransactionKind: true })); +}); + test("migration source status trusts only verified nonexistent blobs", () => { assert.equal(isVerifiedNonexistentSource({ type: "nonexistent" }), true); assert.equal(isVerifiedNonexistentSource({ type: "invalid" }), false); diff --git a/services/server/scripts/sidecar/address-balance.ts b/services/server/scripts/sidecar/address-balance.ts index d6965725c..f57165414 100644 --- a/services/server/scripts/sidecar/address-balance.ts +++ b/services/server/scripts/sidecar/address-balance.ts @@ -9,14 +9,15 @@ const COIN_WITH_BALANCE_INTENT = "CoinWithBalance"; * when an address balance is insufficient. */ export function enforceAddressBalanceCoinIntents(transaction: Transaction): void { - const initialOwnedObjectIds = new Set( - (transaction.getData() as any).inputs - .map((input: any) => ( - input.Object?.ImmOrOwnedObject?.objectId ?? input.UnresolvedObject?.objectId - )) - .filter((objectId: unknown): objectId is string => typeof objectId === "string"), - ); + let initialOwnedObjectIds: Set | undefined; transaction.addSerializationPlugin(async (transactionData, options, next) => { + initialOwnedObjectIds ??= new Set( + transactionData.inputs + .map((input) => ( + input.Object?.ImmOrOwnedObject?.objectId ?? input.UnresolvedObject?.objectId + )) + .filter((objectId): objectId is string => typeof objectId === "string"), + ); const requiredByType = new Map(); for (const command of transactionData.commands) { if (command.$kind !== "$Intent" || command.$Intent.name !== COIN_WITH_BALANCE_INTENT) { From efc30428fed7278ec9131d79b162ebb4c24dc4b5 Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:20:02 +0700 Subject: [PATCH 3/4] fix(server): set certify sender before address balance checks --- services/server/scripts/walrus-upload.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/server/scripts/walrus-upload.ts b/services/server/scripts/walrus-upload.ts index ce3dcaf07..6065f6b0b 100644 --- a/services/server/scripts/walrus-upload.ts +++ b/services/server/scripts/walrus-upload.ts @@ -159,6 +159,7 @@ async function main() { // Step 4: Certify blob on Sui → returns a Transaction const certifyTx = flow.certify(); + certifyTx.setSenderIfNotSet(signerAddress); enforceAddressBalanceCoinIntents(certifyTx); certifyTx.setGasPayment([]); From 4bf0de1e38de2a17002cc1ec6c01af91132f3c6a Mon Sep 17 00:00:00 2001 From: ducnmm <165614309+ducnmm@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:25:31 +0700 Subject: [PATCH 4/4] fix(server): enforce address balance gas during build --- .../__tests__/sidecar-query-helpers.test.ts | 99 ++++++++++++++++++- .../server/scripts/sidecar/address-balance.ts | 34 ++++++- services/server/scripts/sidecar/enoki.ts | 3 +- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/services/server/scripts/__tests__/sidecar-query-helpers.test.ts b/services/server/scripts/__tests__/sidecar-query-helpers.test.ts index b875ca0d6..747ccf950 100644 --- a/services/server/scripts/__tests__/sidecar-query-helpers.test.ts +++ b/services/server/scripts/__tests__/sidecar-query-helpers.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; -import { Transaction, TransactionDataBuilder } from "@mysten/sui/transactions"; +import { Inputs, Transaction, TransactionDataBuilder } from "@mysten/sui/transactions"; import { assertCompletionBlobObject, assertCompletionBlobResponse, @@ -263,6 +263,14 @@ test("address-balance uploads fail instead of falling back to owned coins", asyn hasNextPage: false, }; }, + async getObjects({ objectIds }: { objectIds: string[] }) { + return { + objects: objectIds.map((objectId) => ({ + objectId, + type: `0x2::coin::Coin<${walType}>`, + })), + }; + }, }, }; await assert.rejects( @@ -275,6 +283,95 @@ test("address-balance uploads fail instead of falling back to owned coins", asyn ); }); +test("address-balance enforcement permits newly resolved non-coin objects", async () => { + const signer = new Ed25519Keypair(); + const walType = `0x${"2".repeat(64)}::wal::WAL`; + const objectId = `0x${"6".repeat(64)}`; + const transaction = new Transaction(); + transaction.setSender(signer.toSuiAddress()); + transaction.coin({ type: walType, balance: 1n, useGasCoin: false }); + enforceAddressBalanceCoinIntents(transaction); + transaction.addSerializationPlugin(async (transactionData, _options, next) => { + transactionData.addInput("object", Inputs.ObjectRef({ + objectId, + version: "1", + digest: "33333333333333333333333333333333", + })); + await next(); + }); + + const client = { + core: { + async getBalance() { + return { + balance: { + balance: "1", + addressBalance: "1", + coinBalance: "0", + }, + }; + }, + async listCoins() { + return { objects: [], cursor: null, hasNextPage: false }; + }, + async getObjects() { + return { + objects: [{ + objectId, + type: `${WALRUS_PACKAGE_ID}::blob::Blob`, + }], + }; + }, + }, + }; + + await assert.doesNotReject( + transaction.prepareForSerialization({ client: client as never }), + ); +}); + +test("address-balance uploads reject owned gas resolved during build", async () => { + const signer = new Ed25519Keypair(); + const gasObjectId = `0x${"4".repeat(64)}`; + const transaction = new Transaction(); + transaction.setSender(signer.toSuiAddress()); + transaction.setGasBudget(1n); + transaction.setGasPrice(1n); + transaction.transferObjects( + [transaction.objectRef({ + objectId: `0x${"5".repeat(64)}`, + version: "1", + digest: "11111111111111111111111111111111", + })], + signer.toSuiAddress(), + ); + enforceAddressBalanceCoinIntents(transaction); + + const client = { + core: { + resolveTransactionPlugin() { + return async ( + transactionData: TransactionDataBuilder, + _options: unknown, + next: () => Promise, + ) => { + transactionData.gasData.payment = [{ + objectId: gasObjectId, + version: "1", + digest: "22222222222222222222222222222222", + }]; + await next(); + }; + }, + }, + }; + + await assert.rejects( + transaction.build({ client: client as never }), + /Address-balance upload resolved gas from an owned coin/, + ); +}); + test("address-balance enforcement preserves async object inputs across rebuilds", async () => { const signer = new Ed25519Keypair(); const objectId = `0x${"3".repeat(64)}`; diff --git a/services/server/scripts/sidecar/address-balance.ts b/services/server/scripts/sidecar/address-balance.ts index f57165414..4fdfe0781 100644 --- a/services/server/scripts/sidecar/address-balance.ts +++ b/services/server/scripts/sidecar/address-balance.ts @@ -1,8 +1,9 @@ import type { Transaction } from "@mysten/sui/transactions"; -import { normalizeStructTag } from "@mysten/sui/utils"; +import { normalizeStructTag, normalizeSuiAddress, parseStructTag } from "@mysten/sui/utils"; import { SUI_TYPE } from "./config.js"; const COIN_WITH_BALANCE_INTENT = "CoinWithBalance"; +const SUI_FRAMEWORK_ADDRESS = normalizeSuiAddress("0x2"); /** * Prevent the SDK's CoinWithBalance resolver from falling back to owned coins @@ -10,6 +11,7 @@ const COIN_WITH_BALANCE_INTENT = "CoinWithBalance"; */ export function enforceAddressBalanceCoinIntents(transaction: Transaction): void { let initialOwnedObjectIds: Set | undefined; + let hasCoinWithBalanceIntents = false; transaction.addSerializationPlugin(async (transactionData, options, next) => { initialOwnedObjectIds ??= new Set( transactionData.inputs @@ -36,6 +38,7 @@ export function enforceAddressBalanceCoinIntents(transaction: Transaction): void } if (requiredByType.size > 0) { + hasCoinWithBalanceIntents = true; const client = options.client as any; if (!client?.core?.getBalance || !transactionData.sender) { throw new Error("Address-balance upload requires a sender and Sui client"); @@ -61,11 +64,36 @@ export function enforceAddressBalanceCoinIntents(transaction: Transaction): void .filter((objectId): objectId is string => ( typeof objectId === "string" && !initialOwnedObjectIds.has(objectId) )); - if (newlyResolvedOwnedObjects.length > 0) { + if (hasCoinWithBalanceIntents && newlyResolvedOwnedObjects.length > 0) { + const client = options.client as any; + if (!client?.core?.getObjects) { + throw new Error("Address-balance upload could not verify newly resolved objects"); + } + const response = await client.core.getObjects({ objectIds: newlyResolvedOwnedObjects }); + const coinObjectIds = response.objects.map((object: any) => { + if (object instanceof Error) { + throw new Error(`Address-balance upload could not verify resolved object: ${object.message}`); + } + if (typeof object?.type !== "string") { + throw new Error(`Address-balance upload could not verify resolved object ${object?.objectId ?? ""}`); + } + const type = parseStructTag(object.type); + return type.address === SUI_FRAMEWORK_ADDRESS + && type.module === "coin" + && type.name === "Coin" + ? object.objectId + : null; + }).filter((objectId: unknown): objectId is string => typeof objectId === "string"); + + if (coinObjectIds.length === 0) return; throw new Error( - `Address-balance upload resolved owned coin objects: ${newlyResolvedOwnedObjects.join(", ")}`, + `Address-balance upload resolved owned coin objects: ${coinObjectIds.join(", ")}`, ); } + }); + + transaction.addBuildPlugin(async (transactionData, _options, next) => { + await next(); if (transactionData.gasData.payment && transactionData.gasData.payment.length > 0) { throw new Error("Address-balance upload resolved gas from an owned coin"); } diff --git a/services/server/scripts/sidecar/enoki.ts b/services/server/scripts/sidecar/enoki.ts index 85324dd86..6d3b207c3 100644 --- a/services/server/scripts/sidecar/enoki.ts +++ b/services/server/scripts/sidecar/enoki.ts @@ -30,12 +30,13 @@ export type EnokiExecuteResponse = { digest: string }; export type EnokiFallbackPolicy = { directSignIfUnconfigured: boolean; directSignAfterSponsorFailure: boolean; - gasMode?: "auto" | "addressBalance"; + gasMode: "auto" | "addressBalance"; }; const DEFAULT_FALLBACK_POLICY: EnokiFallbackPolicy = { directSignIfUnconfigured: ENOKI_FALLBACK_TO_DIRECT_SIGN, directSignAfterSponsorFailure: ENOKI_FALLBACK_TO_DIRECT_SIGN, + gasMode: "addressBalance", }; // The migrator's controller lease reserves 60s for one Enoki call plus 10s