Skip to content

Commit adcf3b5

Browse files
authored
fix(tempo): canonicalize transactions before broadcast (#818)
1 parent a74b20b commit adcf3b5

3 files changed

Lines changed: 119 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
Normalized Tempo transactions before broadcast so accepted recovery ID encodings matched the node's canonical transaction hash.

src/tempo/server/Charge.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ function sponsoredFeeExposure(credential: string) {
6363
return transaction.gas * transaction.maxFeePerGas
6464
}
6565

66+
function toRawRecoveryId(serializedTransaction: Hex.Hex) {
67+
const transaction = TxEnvelopeTempo.deserialize(
68+
serializedTransaction as TxEnvelopeTempo.Serialized,
69+
)
70+
if (transaction.signature?.type !== 'secp256k1')
71+
throw new Error('expected a secp256k1 transaction signature')
72+
const { yParity } = transaction.signature.signature
73+
const canonicalRecoveryId = yParity === 0 ? '1b' : '1c'
74+
if (!serializedTransaction.endsWith(canonicalRecoveryId))
75+
throw new Error('expected an Electrum-encoded recovery ID')
76+
return `${serializedTransaction.slice(0, -2)}0${yParity}` as Hex.Hex
77+
}
78+
6679
function tokenTransferCall(parameters: viem_token.transfer.Args) {
6780
return Actions.token.transfer.call(client, parameters)
6881
}
@@ -1092,6 +1105,102 @@ describe('tempo', () => {
10921105
})
10931106

10941107
describe('intent: charge; type: transaction; via Mppx', () => {
1108+
test.each([
1109+
{
1110+
name: 'confirmed',
1111+
rpcMethod: 'eth_sendRawTransactionSync',
1112+
waitForConfirmation: true,
1113+
},
1114+
{
1115+
name: 'optimistic',
1116+
rpcMethod: 'eth_sendRawTransaction',
1117+
waitForConfirmation: false,
1118+
},
1119+
] as const)(
1120+
'behavior: canonicalizes raw recovery IDs for $name broadcasts',
1121+
async ({ rpcMethod, waitForConfirmation }) => {
1122+
const broadcastTransactions: Hex.Hex[] = []
1123+
const interceptingClient = createClient({
1124+
account: accounts[0],
1125+
chain: client.chain,
1126+
transport: custom({
1127+
async request(request) {
1128+
if (request.method === rpcMethod)
1129+
broadcastTransactions.push(request.params[0] as Hex.Hex)
1130+
return client.transport.request(request)
1131+
},
1132+
}),
1133+
})
1134+
const canonicalServer = Mppx_server.create({
1135+
methods: [
1136+
tempo_server.charge({
1137+
account: accounts[0],
1138+
currency: asset,
1139+
getClient: () => interceptingClient,
1140+
waitForConfirmation,
1141+
}),
1142+
],
1143+
realm,
1144+
secretKey,
1145+
})
1146+
const mppx = Mppx_client.create({
1147+
polyfill: false,
1148+
methods: [
1149+
tempo_client({
1150+
account: accounts[1],
1151+
getClient: () => client,
1152+
}),
1153+
],
1154+
})
1155+
const httpServer = await Http.createServer(async (req, res) => {
1156+
const result = await Mppx_server.toNodeListener(
1157+
canonicalServer.charge({
1158+
amount: '1',
1159+
currency: asset,
1160+
recipient: accounts[0].address,
1161+
}),
1162+
)(req, res)
1163+
if (result.status === 402) return
1164+
res.end('OK')
1165+
})
1166+
1167+
try {
1168+
const challengeResponse = await fetch(httpServer.url)
1169+
const canonicalCredential = await mppx.createCredential(challengeResponse)
1170+
const credential = Credential.deserialize<{
1171+
signature: Hex.Hex
1172+
type: 'transaction'
1173+
}>(canonicalCredential)
1174+
const canonicalTransaction = credential.payload.signature
1175+
const submittedTransaction = toRawRecoveryId(canonicalTransaction)
1176+
const rawCredential = Credential.serialize({
1177+
...credential,
1178+
payload: { ...credential.payload, signature: submittedTransaction },
1179+
})
1180+
1181+
expect(['00', '01']).toContain(submittedTransaction.slice(-2))
1182+
expect(keccak256(submittedTransaction)).not.toBe(keccak256(canonicalTransaction))
1183+
1184+
const response = await fetch(httpServer.url, {
1185+
headers: { Authorization: rawCredential },
1186+
})
1187+
expect(response.status).toBe(200)
1188+
expect(broadcastTransactions).toEqual([canonicalTransaction])
1189+
1190+
const receipt = Receipt.fromResponse(response)
1191+
expect(receipt.reference).toBe(keccak256(canonicalTransaction))
1192+
1193+
const replay = await fetch(httpServer.url, {
1194+
headers: { Authorization: canonicalCredential },
1195+
})
1196+
expect(replay.status).toBe(402)
1197+
expect(broadcastTransactions).toHaveLength(1)
1198+
} finally {
1199+
httpServer.close()
1200+
}
1201+
},
1202+
)
1203+
10951204
test('behavior: accepts a pushed machine-token settlement', async () => {
10961205
const hash = `0x${'12'.repeat(32)}` as Hex.Hex
10971206
const memo = `0x${'ab'.repeat(32)}` as Hex.Hex

src/tempo/server/Charge.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,11 @@ export function charge<const parameters extends charge.Parameters>(
639639
}
640640
return { serializedTransaction, sponsor: undefined }
641641
})()
642-
const serializedTransaction_final = completedTransaction.serializedTransaction
642+
// Nodes normalize accepted signature encodings before deriving transaction hashes.
643+
// Broadcast the same canonical envelope so replay keys match the node reference.
644+
const serializedTransaction_final = await Transaction.serialize(
645+
Transaction.deserialize(completedTransaction.serializedTransaction),
646+
)
643647
finalHash = keccak256(serializedTransaction_final)
644648

645649
if (

0 commit comments

Comments
 (0)