57333 sc high inconsistent handler address decoding prevents any message from being executed
Submitted on Oct 25th 2025 at 10:19:41 UTC by @uhudo for Audit Comp | Folks Finance: Wormhole NTT on Algorand
Report ID: #57333
Report Type: Smart Contract
Report severity: High
Target: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/main/ntt_contracts/transceiver/TransceiverManager.py
Impact: Permanent freezing of funds
Description
Brief / Intro
The problem is that handler_address of a received message is expected to encode message_handler as an application ID at one point and as an application address at another point. This inconsistency prevents any message from being executed.
Consequence: no tokens can be minted on the destination chain while they get permanently locked on the source chain.
Vulnerability Details
TransceiverManagerdefinesmessage_handleras an application ID: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/TransceiverManager.py#L62When receiving a message (
attestation_received()), it extractsmessage_handlerfromhandler_addressas if thehandler_addresswere an application ID: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/TransceiverManager.py#L194When performing the check for executing a message (
execute_message()inMessageHandlerofNttManager), it treatshandler_addressas an address: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/MessageHandler.py#L55Messages are identified via a hash of their contents (
calculate_message_digest()): https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/TransceiverManager.py#L230
Because the encoding/decoding expectations for handler_address differ (application ID vs address), the same logical message encoded differently yields different message digests. The inconsistency causes the check in execute_message() to fail for all received messages — effectively preventing execution.
Impact Details
No message can be executed due to the mismatch, therefore tokens cannot be minted on the destination chain while they remain permanently locked on the source chain.
References
Defining
message_handleras application ID inTransceiverManager: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/TransceiverManager.py#L62Extracting
message_handlerfromhandler_addressas if it were an application ID inattestation_received()ofTransceiverManager: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/TransceiverManager.py#L194Performing a check for executing a message as if
handler_addresswas an address inexecute_message()withinMessageHandlerofNttManager: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/MessageHandler.py#L55Unique message identifier: https://github.com/Folks-Finance/algorand-ntt-contracts/blob/912c92d5219efe94ae707389da85c93e17e7e36b/ntt_contracts/transceiver/TransceiverManager.py#L230
Proof of Concept
An end-to-end test of all components used in the complete transfer flow demonstrates the bug. Replacing the tests in handle message of NttManager.test.ts with the following shows that executing the delivered message fails with "Message handler address mismatch":
describe("handle message", () => {
test("impossible to execute", async () => {
// create a transceiver
const transceiverFactory = localnet.algorand.client.getTypedAppFactory(MockTransceiverFactory, {
defaultSender: creator,
defaultSigner: creator.signer,
});
const messageFee = 100_000n;
const { result } = await transceiverFactory.send.create.create({
sender: creator,
args: [transceiverManagerAppId, messageFee, SECONDS_IN_DAY],
});
const transceiverAppId = result.appId
const transceiver = await transceiverFactory.getAppClientById({ appId: transceiverAppId })
const MESSAGE_HANDLER_ADMIN_ROLE = (appId: number | bigint) =>
keccak_256(Uint8Array.from([...enc.encode("MESSAGE_HANDLER_ADMIN_"), ...convertNumberToBytes(appId, 8)])).slice(
0,
16,
);
// add transceiver to TransceiverManager
const transceiverManager = await transceiverManagerFactory.getAppClientById({ appId: transceiverManagerAppId })
const APP_MIN_BALANCE = (203_200).microAlgos();
const fundingTranscieverTxn = await localnet.algorand.createTransaction.payment({
sender: creator,
receiver: getApplicationAddress(transceiverManagerAppId),
amount: APP_MIN_BALANCE,
});
const _ = await transceiverManager.newGroup()
.addTransaction(fundingTranscieverTxn)
.addTransceiver({
sender: admin,
args: [appId, transceiverAppId],
boxReferences: [
getAddressRolesBoxKey(MESSAGE_HANDLER_ADMIN_ROLE(appId), admin.publicKey),
getHandlerTransceiversBoxKey(appId),
],
})
.send();
// check there is sufficient capacity
const inboundBucketId = getInboundBucketIdBytes(PEER_CHAIN_ID);
const untrimmedAmount = 10_000n;
expect(await client.hasCapacity({ args: [inboundBucketId, untrimmedAmount] })).toBeTruthy();
// prepare message
const amount = untrimmedAmount / 10_000n;
const payload = getNttPayload(PEER_DECIMALS, amount, getRandomBytes(32), user.publicKey, SOURCE_CHAIN_ID);
const handlerAddress = convertNumberToBytes(appId, 32); // getApplicationAddress(appId).publicKey
const messageReceived = getMessageReceived(
PEER_CHAIN_ID,
getRandomMessageToSend({
sourceAddress: PEER_CONTRACT,
destinationChainId: Number(SOURCE_CHAIN_ID),
handlerAddress,
payload,
}),
);
const messageDigest = calculateMessageDigest(messageReceived);
// deliver message
const resultDelivery = await transceiver.send.deliverMessage({
sender: user,
args: [messageReceived],
appReferences: [transceiverManagerAppId, appId],
boxReferences: [
getHandlerTransceiversBoxKey(appId),
getTransceiverAttestationsBoxKey(messageDigest, transceiverAppId),
getNumAttestationsBoxKey(messageDigest),
],
extraFee: (1000).microAlgos(),
});
// execute message - fails
const addBalance = (22_900).microAlgo();
const fundingTxn = await localnet.algorand.createTransaction.payment({
sender: relayer,
receiver: getApplicationAddress(appId),
amount: addBalance,
});
await expect(
client
.newGroup()
.addTransaction(fundingTxn)
.executeMessage({
sender: relayer,
args: [messageReceived],
appReferences: [transceiverManagerAppId],
boxReferences: [
getMessagesExecutedBoxKey(messageDigest),
getBucketBoxKey(inboundBucketId),
getInboundQueuedTransfersBoxKey(messageDigest),
],
extraFee: (3000).microAlgos(),
})
.send(),
).rejects.toThrow("Message handler address mismatch");
});
});(End of report)
Was this helpful?