Security Audit
October 20, 2025
Version 1.0.0
Presented by 0xMacro
This document includes the results of the security audit for towns's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from October 7, 2025 to October 10, 2025.
The purpose of this audit is to review the source code of certain towns Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.
Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.
The following is an aggregation of issues found by the Macro Audit team:
| Severity | Count | Acknowledged | Won't Do | Addressed |
|---|---|---|---|---|
| High | 3 | - | - | 3 |
| Medium | 2 | - | - | 2 |
towns was quick to respond to these issues.
Our understanding of the specification was based on the following sources:
The SubscriptionModule is a smart contract module designed to be installed on a user's Modular Account (ERC-6900), enabling automated, recurring payments for spaces NFT based memberships. The system relies on trusted operators to trigger renewals, but is designed to ensure the operator cannot act without the user's pre-approved consent as defined by the subscription parameters.
nextRenewalTime but before GRACE_PERIOD ends) to prevent spamming and premature renewals.executeWithRuntimeValidation, ensuring the user's account, not the operator, is the msg.sender for the final renewMembership call.space contract and the tokenId, are immutable after the subscription is installed.The Protocol Owner is the entity that deployed and controls the SubscriptionModule diamond contract itself. This is a highly trusted role responsible for maintaining and upgrading the module's logic and the diamonds facets.
batchProcessRenewals.SubscriptionModule logic with a contract that drains funds from all subscribed users on the next renewal cycle. The primary mitigation is the security of the protocol's multisig process itself which governs the owner account.operator role)The Operator is a trusted, whitelisted off-chain entity responsible for monitoring all active subscriptions and triggering the on-chain renewal process. The contract is designed to limit its power, making it a transaction facilitator rather than a decision-maker.
batchProcessRenewals function for any subscription that is due.The space is the target contract that manages the actual membership. It is trusted by the SubscriptionModule to provide accurate information about the membership's status and terms.
renewMembership function.SubscriptionModule checks for the space to be a legitimate contract.SubscriptionModule includes internal logic to handle changes in membership terms. If the space modifies the price or duration, the module will automatically pause renewals and emit appropriate events to notify the user.ModularAccount owner)The User is the owner of the Modular Account and the ultimate principal in the system. They are trusted to make their own financial decisions by installing and configuring the module.
SubscriptionModule for a specific NFT membership, defining the entityId.space contract or unexpected changes in the membership's terms (price/duration), serving as the primary mitigation against operator error.The following source code was reviewed during the audit:
571d65e38e62dbbe5c5af68bd9965716991ace44
Specifically, we audited the following contracts within contracts/src/apps/modules repository directory:
| Source Code | SHA256 |
|---|---|
| /subscription/ISubscriptionModule.sol |
|
| /subscription/SubscriptionModuleFacet.sol |
|
| /subscription/SubscriptionModuleStorage.sol |
|
Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.
Click on an issue to jump to it, or scroll down to see them all.
space address authenticity
tokenId/space keys can be used with different entityIds
We quantify issues in three parts:
This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:
| Severity | Description |
|---|---|
|
(C-x) Critical |
We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost. |
|
(H-x) High |
We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec. |
|
(M-x) Medium |
We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner. |
|
(L-x) Low |
The risk is small, unlikely, or may not relevant to the project in a meaningful way. Whether or not the project wants to develop a fix is up to the goals and needs of the project. |
|
(Q-x) Code Quality |
The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design. |
|
(I-x) Informational |
Warnings and things to keep in mind when operating the protocol. No immediate action required. |
|
(G-x) Gas Optimizations |
The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it. |
The SubscriptionModuleFacet contract's logic for calculating the renewal buffer currently has issues related to short memberships or specific scenarios that would cause the buffer to be larger than the membership's duration. When this occurs, a safety check within the contract sets the next renewal time to block.timestamp, signaling that the subscription is immediately due again. This allows to loop renewals within a single transaction or across subsequent calls, which can unexpectedly drain a user's account balance for renewals they did not intend to authorize.
The vulnerability originates from two places: the static installTime used in _getRenewalBuffer and the lack of a minimum duration check. The flawed logic is handled by _calculateNextRenewalTime, which, instead of failing, resets the renewal time to block.timestamp, creating a hole where operators can, even non-maliciously, generate multiple renewals until the next expiresAt is no longer susceptible to this or the user’s balance is drained.
function _calculateNextRenewalTime(
uint256 expirationTime,
uint256 installTime
) internal view returns (uint40) {
if (expirationTime <= block.timestamp) return uint40(block.timestamp);
uint256 buffer = _getRenewalBuffer(expirationTime, installTime);
uint256 timeUntilExpiration = expirationTime - block.timestamp;
if (buffer >= timeUntilExpiration) return uint40(block.timestamp);
return uint40(expirationTime - buffer);
}
Reference: SubscriptionModuleFacet.sol#440-452
This design leads to three potential distinct scenarios:
BUFFER_IMMEDIATE is 2 minutes).nextRenewalTime is immediately set to block.timestamp, and the operator bot will continue to renew the membership back to back until the user’s balance is depleted or expiresAt membership becomes greater than the minim buffer.originalDuration is 5 hours, correctly selecting a 1-hour buffer. The renewal happens as expected.originalDuration is now ~10 hours. The module now incorrectly selects a 6-hour buffer**.**6 hours >= 5 hours is true.nextRenewalTime is set to block.timestamp, triggering the same loop described above._getRenewalBuffer function, still using the original installTime, calculates an originalDuration of many months and selects the BUFFER_LONG (12 hours). The safety check if (12 hours >= 10 hours) becomes true.nextRenewalTime is set to block.timestamp, triggering the same loop described above.activateSubscription. The module correctly sets nextRenewalTime to block.timestamp, making it immediately due.installTime, resulting in a calculated originalDuration of many months and selecting the BUFFER_LONG (12 hours).12 hours >= 10 hours) becomes true, setting nextRenewalTime back to block.timestamp and triggering the same loop described above.This vulnerability could lead to multiple unauthorized renewals processing in rapid succession, draining users' funds without respecting the actual membership expiration period. The impact is severe because users might unknowingly authorize continuous payments far exceeding their expectations. The situation becomes even more dangerous without price or duration configurations to protect users.
Remediations to Consider
IMembership interface should be updated with a getMembershipDuration(uint256 tokenId) view function. _getRenewalBuffer should use this explicit value, ensuring the buffer is always based on current, accurate terms. onInstall to revert if the space's membership duration is less than the BUFFER_IMMEDIATE, preventing the most immediate fund-drain scenario. And potentially enforce minimum duration directly on the membership contract.In the SubscriptionModuleFacet contract, the batchProcessRenewals function handles multiple cases where the subscriptions passed by the operator are indeed due to renewal. If not the logic performs different operations such as pausing the subscription and emits a proper event. However if a user manually renews their membership directly on the space contract while having an active automated subscription, the module will fail to recognize the manual renewal and will process another renewal when the next renewal time is due, causing the user to renew their subscription twice.
The vulnerability is caused by a sync logic, since it first confirms that a renewal is due based on its stale, stored nextRenewalTime. It then correctly syncs its state with the actual membership expiration, accounting for the new expiration time from the user's manual renewal but it fails to re-evaluate and proceeds to process the renewal.
uint256 expiresAt = membershipFacet.expiresAt(sub.tokenId);
uint40 correctNextRenewalTime = _calculateNextRenewalTime(expiresAt, sub.installTime);
if (sub.nextRenewalTime != correctNextRenewalTime) {
sub.nextRenewalTime = correctNextRenewalTime;
emit SubscriptionSynced(params[i].account, params[i].entityId, sub.nextRenewalTime);
}
_processRenewal(sub, params[i], membershipFacet, actualRenewalPrice);
Reference: SubscriptionModuleFacet.sol#L244-252
A user whose subscription is due might renew it manually. Shortly after, an operator bot includes that subscription in a batch. The module will see the subscription was due, sync its state to reflect the manual renewal, and then immediately charge the user a second time, effectively syncing but ignoring the newly synced correctNextRenewalTime.
Remediations to Consider
Consider evaluating the updated nextRenewalTime after syncing with the expiration and skipping the renewal if it's not due.
The SubscriptionModule design requires users to trust that their subscribed membership's price and duration (which together determine the total cost) will remain consistent. However, the renewal price is fetched dynamically at each renewal, with no mechanism for users to set a maximum acceptable price—the only check being whether the user's balance can cover the cost. Similarly, expiration and next renewal times are determined based on membership data at the time of renewal and installation.
The batchProcessRenewals function calls the external getMembershipRenewalPrice function on the space contract to determine the renewal cost. While the current membership implementation stores the membership price paid when joining a space, this logic could be changed through upgrades. There are no strict enforcements or state checks within the module's renewal cycle.
uint256 actualRenewalPrice = membershipFacet.getMembershipRenewalPrice(sub.tokenId);
if (params[i].account.balance < actualRenewalPrice) {
// ...
}
// ...
_processRenewal(sub, params[i], membershipFacet, actualRenewalPrice);
Reference: SubscriptionModuleFacet.sol#L232-252
This issue extends to duration and next renewal time calculations as well. Currently, the expiration is set during renewal, and the logic verifies the new expiration by calling the expiresAt function. However, the space owner can change the membership duration at any given moment:
// Get the actual new expiration time after successful renewal
uint256 newExpiresAt = membershipFacet.expiresAt(sub.tokenId);
sub.nextRenewalTime = _calculateNextRenewalTime(newExpiresAt, sub.installTime);
sub.lastRenewalTime = uint40(block.timestamp);
sub.spent += actualRenewalPrice;
Reference: SubscriptionModuleFacet.sol#L389-393
Ultimately, users who install this module cannot trust the immutability of price parameters after installation or a renewal is processed giving the module permission to manage their funds at the space’s will. They can still deactivate or uninstall the subscription, but this depends on their reaction time after configuration changes occur. Users might be unable to respond if changes happen in short spans of time.
Remediations to Consider
Consider implementing logic that allows users to set maximum price limits and minimum duration parameters when installing the module. All renewals should be required to comply with these values, and the subscription should automatically pause if these conditions are not met.
space address authenticity
The SubscriptionModule does not validate that a space address provided during installation corresponds to a legitimate, canonical space created by the Towns Protocol. This could allow user to be tricked into setting up automated payments to a malicious contract, leading to a loss of funds.
The onInstall function accepts any arbitrary space address. While it checks that the address is not zero through Validator.checkAddress(), it performs no verification to ensure the contract at that address is a genuine space.
function onInstall(bytes calldata data) external override nonReentrant {
(uint32 entityId, address space, uint256 tokenId) = abi.decode(
data,
(uint32, address, uint256)
);
Validator.checkAddress(space);
Reference: SubscriptionModuleFacet.sol#L70-76
An attacker can deploy a contract that mimics the IMembership/IERC721 interfaces and use social engineering (e.g., a phishing website) to convince a user to install the Towns subscription module. Additionally any external actor can spam onInstall() subscriptions and populate indexers and storage of arbitrary corrupted data potentially causing issues with batch renewals.
Consider ensuring the space address is a canonical Towns Space.
tokenId/space keys can be used with different entityIds
In the SubscriptionModuleFacet contract, users can accidentally create multiple subscriptions for the same space membership (space-tokenId) by using different entityIds for each installation. The contract doesn't enforce asset uniqueness, which can result in users installing and renewing multiple times the subscription for the same membership.
While the onInstall function ensures that each account-entityId pair is unique, it fails to verify if the underlying asset (space-tokenId) has already been subscribed to by the account under a different entityId.
Remediations to Consider
account -> space -> tokenId to ensure they can only be subscribed once.Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.
Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.
The scope of this report and review is limited to a review of only the code presented by the towns team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.