Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/fix-fee-payer-signer-mismatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@swig-wallet/lib": minor
---

Fix fee payer is_signer mismatch in message hash computation for secp256r1/secp256k1 authorities. When the fee payer appeared as a transfer destination in inner instructions, the client computed the hash with is_signer=false while the on-chain program saw is_signer=true, causing error 0xbd2 (PermissionDeniedSecp256r1InvalidMessageHash). The payer is now correctly marked as a signer in the accounts list before hash computation.
10 changes: 9 additions & 1 deletion packages/lib/src/authority/instructions/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
type TransferAssetsV1InstructionAccounts,
type UpdateAuthorityV1InstructionAccounts,
} from '../../instructions';
import type { SolInstruction, SwigInstructionContext } from '../../solana';
import type {
SolInstruction,
SolPublicKeyData,
SwigInstructionContext,
} from '../../solana';

/**
* Authority Instruction Interface
Expand Down Expand Up @@ -184,4 +188,8 @@ export type InstructionDataOptions = {
odometer?: number;
preInstructions?: SolInstruction[];
postInstructions?: SolInstruction[];
/** Transaction fee payer. Used by secp256r1/secp256k1 authorities to ensure
* the payer account is marked as a signer before computing the message hash,
* matching the Solana runtime behavior. See https://github.com/anagrambuild/swig-ts/issues/107 */
payer?: SolPublicKeyData;
};
12 changes: 12 additions & 0 deletions packages/lib/src/authority/instructions/secp256k1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ export const Secp256k1Instruction: AuthorityInstruction = {
[accounts.swigSystemAddress],
);

// The Solana runtime always marks the fee payer as is_signer=true on all
// AccountInfos. If the payer appears in metas (e.g. as a transfer destination),
// it must be marked as signer here so the client-side hash matches the on-chain
// hash. See: https://github.com/anagrambuild/swig-ts/issues/107
if (options.payer) {
const payerKey = new SolPublicKey(options.payer).toBase58();
const payerMeta = metas.find((m) => m.publicKey.toBase58() === payerKey);
if (payerMeta) {
payerMeta.setSigner(true);
}
}

const encodedCompactInstructions = getArrayEncoder(
getCompactInstructionEncoder(),
{
Expand Down
12 changes: 12 additions & 0 deletions packages/lib/src/authority/instructions/secp256r1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,18 @@ export const Secp256r1Instruction: AuthorityInstruction = {
[accounts.swigSystemAddress],
);

// The Solana runtime always marks the fee payer as is_signer=true on all
// AccountInfos. If the payer appears in metas (e.g. as a transfer destination),
// it must be marked as signer here so the client-side hash matches the on-chain
// hash. See: https://github.com/anagrambuild/swig-ts/issues/107
if (options.payer) {
const payerKey = new SolPublicKey(options.payer).toBase58();
const payerMeta = metas.find((m) => m.publicKey.toBase58() === payerKey);
if (payerMeta) {
payerMeta.setSigner(true);
}
}

const encodedCompactInstructions = getArrayEncoder(
getCompactInstructionEncoder(),
{
Expand Down
129 changes: 129 additions & 0 deletions packages/lib/tests/instructions/sign.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,67 @@ describe('Sign Instruction', () => {
expect(svm.getBalance(recipient1.publicKey)).toBe(amount1);
expect(svm.getBalance(recipient2.publicKey)).toBe(amount2);
});

test('signs multi-transfer with fee payer as destination (issue #107)', async () => {
const svm = getSvm();
const [payer] = getFundedKeys(svm, 1);
const recipient = Keypair.generate();
const swigId = randomBytes(32);
const authority = createTestSecp256k1Authority();

const [swigAddress] = await findSwigPdaRaw(swigId);

const createIx = await getCreateSwigInstructionContext({
authorityInfo: authority.authorityInfo,
id: swigId,
payer: payer.publicKey,
actions: Actions.set().all().get(),
});
sendSwigSVMTransaction(svm, createIx, payer);

const swig = fetchSwig(svm, swigAddress);
const walletAddress = toPublicKey(await getSwigWalletAddressRaw(swig));
const role = swig.roles[0];

svm.airdrop(walletAddress, SOL);

const slot = svm.getClock().slot;
const payerBalanceBefore = svm.getBalance(payer.publicKey)!;
const amount1 = SOL / 10n;
const amount2 = SOL / 20n;

const transfer1 = getTransferSolInstruction({
source: address(walletAddress.toBase58()),
destination: address(recipient.publicKey.toBase58()),
amount: amount1,
});

const transfer2 = getTransferSolInstruction({
source: address(walletAddress.toBase58()),
destination: address(payer.publicKey.toBase58()),
amount: amount2,
});

const signIx = await getSignInstructionContext(
swig,
role.id,
[transfer1, transfer2].map(SolInstruction.from),
false,
{
payer: payer.publicKey,
currentSlot: slot,
signingFn: authority.signingFn ?? undefined,
},
);

sendSwigSVMTransaction(svm, signIx, payer);

expect(svm.getBalance(recipient.publicKey)).toBe(amount1);
const payerBalanceAfter = svm.getBalance(payer.publicKey)!;
expect(payerBalanceAfter).toBeGreaterThan(
payerBalanceBefore - SOL / 100n,
);
});
});

// ============================================================================
Expand Down Expand Up @@ -371,6 +432,74 @@ describe('Sign Instruction', () => {
expect(svm.getBalance(recipient1.publicKey)).toBe(amount1);
expect(svm.getBalance(recipient2.publicKey)).toBe(amount2);
});

test('signs multi-transfer with fee payer as destination (issue #107)', async () => {
// This test reproduces the scenario from https://github.com/anagrambuild/swig-ts/issues/107
// When the fee payer is also a transfer destination, the on-chain program sees
// is_signer=true for the payer account, but the client was computing the hash
// with is_signer=false, causing a message hash mismatch (error 0xbd2).
const svm = getSvm();
const [payer] = getFundedKeys(svm, 1);
const recipient = Keypair.generate();
const swigId = randomBytes(32);
const authority = createTestSecp256r1Authority();

const [swigAddress] = await findSwigPdaRaw(swigId);

const createIx = await getCreateSwigInstructionContext({
authorityInfo: authority.authorityInfo,
id: swigId,
payer: payer.publicKey,
actions: Actions.set().all().get(),
});
sendSwigSVMTransaction(svm, createIx, payer);

const swig = fetchSwig(svm, swigAddress);
const walletAddress = toPublicKey(await getSwigWalletAddressRaw(swig));
const role = swig.roles[0];

svm.airdrop(walletAddress, SOL);

const slot = svm.getClock().slot;
const payerBalanceBefore = svm.getBalance(payer.publicKey)!;
const amount1 = SOL / 10n;
const amount2 = SOL / 20n;

// Transfer 1: swig wallet -> random recipient
const transfer1 = getTransferSolInstruction({
source: address(walletAddress.toBase58()),
destination: address(recipient.publicKey.toBase58()),
amount: amount1,
});

// Transfer 2: swig wallet -> fee payer (this is the problematic case)
const transfer2 = getTransferSolInstruction({
source: address(walletAddress.toBase58()),
destination: address(payer.publicKey.toBase58()),
amount: amount2,
});

const signIx = await getSignInstructionContext(
swig,
role.id,
[transfer1, transfer2].map(SolInstruction.from),
false,
{
payer: payer.publicKey,
currentSlot: slot,
signingFn: authority.signingFn ?? undefined,
},
);

sendSwigSVMTransaction(svm, signIx, payer);

expect(svm.getBalance(recipient.publicKey)).toBe(amount1);
// Payer should have received amount2 (minus tx fees)
const payerBalanceAfter = svm.getBalance(payer.publicKey)!;
expect(payerBalanceAfter).toBeGreaterThan(
payerBalanceBefore - SOL / 100n,
);
});
});

// ============================================================================
Expand Down
Loading