Skip to content

Commit b643062

Browse files
authored
fix(svm-sdk): compiled tx export logic mistakenly including current signer in the ouput (#8524)
1 parent 8a86aab commit b643062

10 files changed

Lines changed: 254 additions & 15 deletions

File tree

.changeset/fix-svm-tx-fee-payer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperlane-xyz/sealevel-sdk": patch
3+
---
4+
5+
Fixed serialized transaction output using the local keypair as fee payer instead of the actual authority (e.g. Squads vault). Added explicit feePayer field to SvmTransaction and set it on all update paths. Refactored IGP instruction builders to accept Address instead of TransactionSigner so the on-chain owner is used in serialized transactions.

typescript/svm-sdk/src/clients/signer.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type Address,
23
type Base64EncodedWireTransaction,
34
type KeyPairSigner,
45
type ReadonlyUint8Array,
@@ -22,6 +23,7 @@ import {
2223

2324
import { type AltVM } from '@hyperlane-xyz/provider-sdk';
2425
import { assert, rootLogger, sleep, strip0x } from '@hyperlane-xyz/utils';
26+
import type { InstructionAccountMeta } from '../instructions/utils.js';
2527

2628
import { createRpc } from '../rpc.js';
2729
import {
@@ -38,6 +40,24 @@ import type {
3840
import { SvmProvider } from './provider.js';
3941
import { DEFAULT_COMPUTE_UNITS } from '../constants.js';
4042

43+
/** Transaction input for `send()` — feePayer is excluded since the signer provides it. */
44+
type SendableSvmTransaction = Omit<SvmTransaction, 'feePayer'>;
45+
46+
/** Shape returned by `transactionToPrintableJson`. */
47+
export interface PrintableSvmTransaction {
48+
annotation?: string;
49+
instructions: PrintableSvmInstruction[];
50+
computeUnits?: number;
51+
transaction_base58: string;
52+
message_base58: string;
53+
}
54+
55+
export interface PrintableSvmInstruction {
56+
programAddress: Address;
57+
accounts?: readonly InstructionAccountMeta[];
58+
data?: string;
59+
}
60+
4161
type SignatureStatusResponse = Awaited<
4262
ReturnType<GetSignatureStatusesApi['getSignatureStatuses']>
4363
>['value'][0];
@@ -166,10 +186,10 @@ export class SvmSigner
166186

167187
async transactionToPrintableJson(
168188
transaction: AnnotatedSvmTransaction,
169-
): Promise<object> {
189+
): Promise<PrintableSvmTransaction> {
170190
const { transactionBase58, messageBase58 } = serializeUnsignedTransaction(
171191
transaction.instructions,
172-
this.signer.address,
192+
transaction.feePayer ?? this.signer.address,
173193
);
174194

175195
return {
@@ -191,7 +211,7 @@ export class SvmSigner
191211
* load-balanced RPC node desync.
192212
*/
193213
private async signAndSend(
194-
tx: SvmTransaction,
214+
tx: SendableSvmTransaction,
195215
maxAttempts = 5,
196216
): Promise<{
197217
signature: Signature;
@@ -285,7 +305,7 @@ export class SvmSigner
285305
* Sends a transaction and polls for confirmation. On blockhash expiry,
286306
* checks transaction history before resubmitting to prevent double-execution.
287307
*/
288-
async send(tx: SvmTransaction): Promise<SvmReceipt> {
308+
async send(tx: SendableSvmTransaction): Promise<SvmReceipt> {
289309
const maxBlockhashAttempts = 3;
290310
const pollIntervalMs = 2000;
291311

@@ -448,13 +468,13 @@ export class SvmSigner
448468
}
449469

450470
async sendAndConfirmTransaction(
451-
transaction: SvmTransaction,
471+
transaction: SendableSvmTransaction,
452472
): Promise<SvmReceipt> {
453473
return this.send(transaction);
454474
}
455475

456476
async sendAndConfirmBatchTransactions(
457-
_transactions: SvmTransaction[],
477+
_transactions: SendableSvmTransaction[],
458478
): Promise<SvmReceipt> {
459479
throw new Error('Sealevel does not support transaction batching');
460480
}

typescript/svm-sdk/src/core/mailbox.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export class SvmMailboxWriter
182182
const expectedIsm = expected.defaultIsm.deployed.address;
183183
if (!eqAddressSol(currentIsm, expectedIsm)) {
184184
txs.push({
185+
feePayer: ownerAddress,
185186
instructions: [
186187
await buildSetDefaultIsmInstruction(
187188
programId,
@@ -201,6 +202,7 @@ export class SvmMailboxWriter
201202
? parseAddress(expected.owner)
202203
: null;
203204
txs.push({
205+
feePayer: ownerAddress,
204206
instructions: [
205207
await buildTransferMailboxOwnershipInstruction(
206208
programId,

typescript/svm-sdk/src/hook/igp-hook.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export class SvmIgpHookWriter
239239
if (oracleConfigs.length > 0) {
240240
const setOracleIx = await getSetGasOracleConfigsInstruction(
241241
programId,
242-
this.svmSigner.signer,
242+
this.svmSigner.signer.address,
243243
igpPda,
244244
oracleConfigs,
245245
);
@@ -273,7 +273,7 @@ export class SvmIgpHookWriter
273273

274274
const setOverheadIx = await getSetDestinationGasOverheadsInstruction(
275275
programId,
276-
this.svmSigner.signer,
276+
this.svmSigner.signer.address,
277277
overheadIgpPda,
278278
overheadConfigs,
279279
);
@@ -311,6 +311,9 @@ export class SvmIgpHookWriter
311311
throw new Error('IGP account not initialized');
312312
}
313313

314+
assert(currentIgp.owner, `IGP ${programId} has no owner`);
315+
const ownerAddress = currentIgp.owner;
316+
314317
const { address: igpPda } = await deriveIgpAccountPda(programId, this.salt);
315318

316319
const oracleConfigsToUpdate: GasOracleConfig[] = [];
@@ -358,12 +361,13 @@ export class SvmIgpHookWriter
358361
if (oracleConfigsToUpdate.length > 0) {
359362
const setOracleIx = await getSetGasOracleConfigsInstruction(
360363
programId,
361-
this.svmSigner.signer,
364+
ownerAddress,
362365
igpPda,
363366
oracleConfigsToUpdate,
364367
);
365368

366369
txs.push({
370+
feePayer: ownerAddress,
367371
instructions: [setOracleIx],
368372
annotation: `Update gas oracles for ${oracleConfigsToUpdate.length} domains`,
369373
});
@@ -404,12 +408,13 @@ export class SvmIgpHookWriter
404408

405409
const setOverheadIx = await getSetDestinationGasOverheadsInstruction(
406410
programId,
407-
this.svmSigner.signer,
411+
ownerAddress,
408412
overheadIgpPda,
409413
overheadConfigsToUpdate,
410414
);
411415

412416
txs.push({
417+
feePayer: ownerAddress,
413418
instructions: [setOverheadIx],
414419
annotation: `Update gas overheads for ${overheadConfigsToUpdate.length} domains`,
415420
});

typescript/svm-sdk/src/instructions/igp.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
readonlyAccount,
3535
writableAccount,
3636
writableSigner,
37+
writableSignerAddress,
3738
} from './utils.js';
3839

3940
export enum IgpInstructionKind {
@@ -283,7 +284,7 @@ function decodeInitOverheadIgp(data: Uint8Array): InitOverheadIgpData {
283284

284285
export async function getSetGasOracleConfigsInstruction(
285286
programAddress: Address,
286-
owner: TransactionSigner,
287+
owner: Address,
287288
igpAccount: Address,
288289
configs: GasOracleConfig[],
289290
): Promise<Instruction> {
@@ -292,15 +293,15 @@ export async function getSetGasOracleConfigsInstruction(
292293
[
293294
readonlyAccount(SYSTEM_PROGRAM_ADDRESS),
294295
writableAccount(igpAccount),
295-
writableSigner(owner),
296+
writableSignerAddress(owner),
296297
],
297298
encodeIgpProgramInstruction({ kind: 'setGasOracleConfigs', configs }),
298299
);
299300
}
300301

301302
export async function getSetDestinationGasOverheadsInstruction(
302303
programAddress: Address,
303-
owner: TransactionSigner,
304+
owner: Address,
304305
overheadIgpAccount: Address,
305306
configs: GasOverheadConfig[],
306307
): Promise<Instruction> {
@@ -309,7 +310,7 @@ export async function getSetDestinationGasOverheadsInstruction(
309310
[
310311
readonlyAccount(SYSTEM_PROGRAM_ADDRESS),
311312
writableAccount(overheadIgpAccount),
312-
writableSigner(owner),
313+
writableSignerAddress(owner),
313314
],
314315
encodeIgpProgramInstruction({
315316
kind: 'setDestinationGasOverheads',

typescript/svm-sdk/src/ism/multisig-ism.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export class SvmMessageIdMultisigIsmWriter
201201
});
202202

203203
return {
204+
feePayer: this.svmSigner.signer.address,
204205
instructions: [ix],
205206
annotation: `Set validators for domain ${domain}`,
206207
};

typescript/svm-sdk/src/tests/signer.unit-test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import {
33
SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND,
44
SolanaError,
55
} from '@solana/errors';
6-
import { blockhash, signature as toSignature } from '@solana/kit';
6+
import {
7+
AccountRole,
8+
address,
9+
blockhash,
10+
getBase58Encoder,
11+
getCompiledTransactionMessageDecoder,
12+
signature as toSignature,
13+
} from '@solana/kit';
714
import chai, { expect } from 'chai';
815
import chaiAsPromised from 'chai-as-promised';
916
import { afterEach, describe, it } from 'mocha';
@@ -776,4 +783,80 @@ describe('SvmSigner', () => {
776783
expect(sendTxCalls).to.equal(1);
777784
});
778785
});
786+
787+
// ---- Fee payer derivation ----
788+
789+
// Deterministic fake addresses (valid base58, 32 bytes)
790+
const OWNER_ADDRESS = address('11111111111111111111111111111112');
791+
const SQUADS_VAULT_ADDRESS = address(
792+
'zUeFx6cfxedG2JnFtMKkTXnxgPa5M44tyaF9RrPunCp',
793+
);
794+
const PROGRAM_ADDRESS = address('11111111111111111111111111111113');
795+
const TOKEN_PDA_ADDRESS = address('11111111111111111111111111111114');
796+
797+
const base58Encoder = getBase58Encoder();
798+
const messageDecoder = getCompiledTransactionMessageDecoder();
799+
800+
/** Decode a base58-encoded compiled message and return the fee payer (first static account). */
801+
function feePayerFromMessageBase58(messageBase58: string): string {
802+
const bytes = base58Encoder.encode(messageBase58);
803+
const decoded = messageDecoder.decode(bytes);
804+
return decoded.staticAccounts[0];
805+
}
806+
807+
describe('transactionToPrintableJson — fee payer derivation', () => {
808+
it('uses explicit feePayer instead of local signer', async () => {
809+
const rpc = createMockRpc();
810+
const signer = await createTestSigner(rpc);
811+
const signerAddress = signer.getSignerAddress();
812+
813+
// feePayer differs from both the local signer and instruction accounts
814+
expect(signerAddress).to.not.equal(SQUADS_VAULT_ADDRESS);
815+
expect(OWNER_ADDRESS).to.not.equal(SQUADS_VAULT_ADDRESS);
816+
817+
const tx: SvmTransaction = {
818+
feePayer: SQUADS_VAULT_ADDRESS,
819+
instructions: [
820+
{
821+
programAddress: PROGRAM_ADDRESS,
822+
accounts: [
823+
{ address: TOKEN_PDA_ADDRESS, role: AccountRole.WRITABLE },
824+
{ address: OWNER_ADDRESS, role: AccountRole.READONLY_SIGNER },
825+
],
826+
data: new Uint8Array([0]),
827+
},
828+
],
829+
};
830+
831+
const json = await signer.transactionToPrintableJson(tx);
832+
833+
expect(feePayerFromMessageBase58(json.message_base58)).to.equal(
834+
SQUADS_VAULT_ADDRESS,
835+
);
836+
});
837+
838+
it('falls back to local signer when instructions have no signers', async () => {
839+
const rpc = createMockRpc();
840+
const signer = await createTestSigner(rpc);
841+
const signerAddress = signer.getSignerAddress();
842+
843+
const tx: SvmTransaction = {
844+
instructions: [
845+
{
846+
programAddress: PROGRAM_ADDRESS,
847+
accounts: [
848+
{ address: TOKEN_PDA_ADDRESS, role: AccountRole.WRITABLE },
849+
],
850+
data: new Uint8Array([0]),
851+
},
852+
],
853+
};
854+
855+
const json = await signer.transactionToPrintableJson(tx);
856+
857+
expect(feePayerFromMessageBase58(json.message_base58)).to.equal(
858+
signerAddress,
859+
);
860+
});
861+
});
779862
});

0 commit comments

Comments
 (0)