Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions services/server/scripts/__tests__/sidecar-query-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -212,6 +229,74 @@ 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 () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test's title says "fail instead of falling back to owned coins", but it only calls transaction.prepareForSerialization(...) (lines 268-274), never .build(...). Gas-coin resolution only happens inside #prepareBuild, which prepareForSerialization never reaches — so this test can only exercise the coin-intent-resolved-to-owned-object case (which does correctly reject today), not the gas-payment fallback case the PR summary claims is covered.

Suggest adding a case that mocks gas resolution to return an owned coin and asserts transaction.build({ client }) rejects the same way. With the current code, that new test would fail — that's the actual bug this PR needs to fix.

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("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);
Expand Down
73 changes: 73 additions & 0 deletions services/server/scripts/sidecar/address-balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 {
let initialOwnedObjectIds: Set<string> | 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<string, bigint>();
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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check can't fire in practice — gasData.payment isn't resolved yet at this point. build() runs prepareForSerialization() (this plugin's phase) fully to completion before #prepareBuild() even starts, and gas-coin resolution (resolveTransactionPlugin) only happens in that second phase. See the top-level review comment for the SDK source citation.

Suggest moving this specific check into transaction.addBuildPlugin(...) so it runs after gas resolution instead.

throw new Error("Address-balance upload resolved gas from an owned coin");
}
});
}
2 changes: 2 additions & 0 deletions services/server/scripts/sidecar/enoki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = { data: T };
Expand Down Expand Up @@ -239,6 +240,7 @@ export async function executeWithEnokiSponsor(
onSubmissionStarted: () => void = () => {},
): Promise<string> {
if (fallbackPolicy.gasMode === "addressBalance") {
enforceAddressBalanceCoinIntents(tx);
tx.setGasPayment([]);
}

Expand Down
15 changes: 13 additions & 2 deletions services/server/scripts/sidecar/routes/walrus-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -550,6 +551,7 @@ export function registerWalrusUploadJournalRoute(app: Express): void {
memwal_migration_job: jobId,
},
});
enforceAddressBalanceCoinIntents(registerTx);
const registerTransaction = await prepareRegisterTransaction(
registerTx,
signer,
Expand Down
44 changes: 31 additions & 13 deletions services/server/scripts/sidecar/routes/walrus-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions services/server/scripts/sidecar/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions services/server/scripts/walrus-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -154,6 +159,9 @@ async function main() {

// Step 4: Certify blob on Sui → returns a Transaction
const certifyTx = flow.certify();
certifyTx.setSenderIfNotSet(signerAddress);
enforceAddressBalanceCoinIntents(certifyTx);
certifyTx.setGasPayment([]);
Comment thread
ducnmm marked this conversation as resolved.

// Sign and execute the certify transaction
const certifyResult = await suiClient.signAndExecuteTransaction({
Expand Down
Loading