ShopinX Info
The global retail industry is undergoing a significant transformation as customer acquisition and retention costs reach historic highs. Brands continue allocating billions of dollars to loyalty initiatives in an effort to retain customers within their ecosystems. However, a substantial portion of consumers ultimately abandon these programs due to the limited flexibility and utility of closed-loop reward structures. At the core of this challenge lies the traditional database architecture underpinning most loyalty systems, which prevents true user ownership and restricts the free movement of rewards and value across independent networks.
TrustNet Score
The TrustNet Score evaluates crypto projects based on audit results, security, KYC verification, and social media presence. This score offers a quick, transparent view of a project's credibility, helping users make informed decisions in the Web3 space.
Real-Time Threat Detection
Real-time threat detection, powered by Cyvers.io,
is currently not
activated
for this project.
This advanced feature provides continuous monitoring and instant alerts to safeguard your assets from potential security threats. Real-time detection enhances your project's security by proactively identifying and mitigating risks.
For more information, click here.
Security Assessments
Summary and Final Words
No crucial issues found
The contract does not contain issues of high or medium criticality. This means that no known vulnerabilities were found in the source code.
Contract owner cannot mint
It is not possible to mint new tokens.
Contract owner can blacklist addresses
It is possible to lock user funds by blacklisting addresses.
Contract owner cannot set high fees
The fees, if applicable, can be a maximum of 25% or lower. The contract can therefore not be locked. Please take a look in the comment section for more details.
Token transfer can be locked
Owner can lock user funds with owner functions.
Token can be burned
There is a function to burn tokens in the contract without any allowances.
Ownership is not renounced
The owner retains significant control, which could potentially be used to modify key contract parameters.
Contract is not upgradeable
The contract does not use proxy patterns or other mechanisms to allow future upgrades. Its behavior is locked in its current state.
Scope of Work
This audit encompasses the evaluation of the files listed below, each verified with a SHA-1 Hash. The team referenced above has provided the necessary files for assessment.
The auditing process consists of the following systematic steps:
- Specification Review: Analyze the provided specifications, source code, and instructions to fully understand the smart contract's size, scope, and functionality.
- Manual Code Examination: Conduct a thorough line-by-line review of the source code to identify potential vulnerabilities and areas for improvement.
- Specification Alignment: Ensure that the code accurately implements the provided specifications and intended functionalities.
- Test Coverage Assessment: Evaluate the extent and effectiveness of test cases in covering the codebase, identifying any gaps in testing.
- Symbolic Execution: Analyze the smart contract to determine how various inputs affect execution paths, identifying potential edge cases and vulnerabilities.
- Best Practices Evaluation: Assess the smart contracts against established industry and academic best practices to enhance efficiency, maintainability, and security.
- Actionable Recommendations: Provide detailed, specific, and actionable steps to secure and optimize the smart contracts.
A file with a different Hash has been intentionally or otherwise modified after the security review. A different Hash may indicate a changed condition or potential vulnerability that was not within the scope of this review.
Final Words
The following provides a concise summary of the audit report, accompanied by insightful comments from the auditor. This overview captures the key findings and observations, offering valuable context and clarity.
Smart Contract Analysis Statement
Contract Analysis
The ShopinX Token contract implements an ERC20 token with a fixed, non-mintable supply minted once at deployment, burnable balances, EIP-2612 permit approvals for signature-based allowances, owner-controlled time locks, and a built-in cliff-plus-step vesting mechanism. The token is built on top of well-established OpenZeppelin components, and the project-specific logic is concentrated in the transfer gate that enforces the locks and the vesting schedules.
Ownership Privileges
The ownership of the contract has been retained under a two-step ownership-transfer process, with the ability to renounce ownership deliberately disabled so the contract cannot be left without an administrator. We recommend that ownership be assigned to a multi-signature wallet, ideally fronted by a timelock, given the operational powers described below. The owner retains full privileges including:
- Freezing the entire balance of any address for a bounded period through the full-address lock.
- Distributing tokens together with an amount-based lock that applies only to the transferred tranche.
- Creating a cliff-plus-step vesting schedule for any address.
- Removing a previously applied full-address freeze to correct mistakes.
- The owner cannot create new tokens after deployment, as the total supply is fixed at the initial mint and there is no minting function.
- The owner cannot move or seize tokens already held by other addresses, since administrative transfers are funded only from the owner's own balance.
- The owner cannot lock an address indefinitely, as the maximum lock duration is capped at four years.
- The owner cannot alter or cancel a vesting schedule once it has been created.
Security Features
The contract implements several positive security features:
- It builds on widely used, community-reviewed OpenZeppelin token, ownership, and signature components rather than reimplementing this functionality from scratch.
- The compiler version is pinned to an exact release, and the Solidity 0.8 series provides built-in protection against arithmetic overflow and underflow.
- Ownership uses a two-step handover and the renounce path is disabled, which reduces the risk of accidental or irreversible loss of administrative control.
- Administrative locks are bounded in time and reversible, every privileged function validates its inputs, and the public availability view is kept consistent with the rules enforced on actual transfers.
Review Summary
This contract was reviewed across successive iterations, and the current version represents a substantial improvement over the earlier ones. The higher-severity issues raised previously - the ability to freeze an address permanently, and a transfer lock that applied to a recipient's entire balance rather than only the tokens being distributed - have been resolved: locks are now time-bounded and reversible, and a transfer lock now affects only the amount actually sent. The medium-severity items have also been addressed, partly through code changes and partly through explicit design decisions that are now documented in the source. The items that remain open are low-severity or informational, and are largely product and operational decisions, such as confirming the intended vesting release pattern, deciding whether vesting should be revocable, and publishing the administrative addresses, together with one minor correctness note regarding repeated use of the amount-based lock on the same address. None of the remaining items prevent a safe deployment, provided the operational recommendations are followed.
Note - This Audit report consists of a security analysis of the ShopinX Token smart contract. This analysis did not include economic analysis of the contract's tokenomics. Moreover, we only audited the main contract for the ShopinX Token team. Other contracts associated with the project were not audited by our team. We recommend investors do their own research before investing.
Files and details
Findings and Audit result
high Issues | 2 findings
Resolved
#1 high Issue
Owner can irreversibly freeze any holder via setLock
setLock accepts any unlockTime strictly greater than block.timestamp, with no upper bound. Combined with the invariant that a lock can only ever be extended, never reduced, this allows the owner to call setLock(victim, type(uint256).max) and freeze every token the victim currently holds as well as every token they ever receive afterwards, because _update uses lockUntil[from] as the authoritative gate on every outgoing transfer. The freeze cannot be reduced or removed by any party afterwards, not even the owner, because of the monotonic-only rule.
Resolved
#2 high Issue
transferWithLock freezes the recipient's entire pre-existing and future balance
transferWithLock stores the lock as a timestamp on the recipient's address (lockUntil[to] = unlockTime). Because _update checks that timestamp for every outgoing transfer, the lock extends to the recipient's whole balance at that moment and to any tokens they later receive from third parties, not just to the amount transferred in the call. A single wei of SPX sent to a market-maker, exchange hot wallet or retail holder with a far-future unlockTime freezes that address entirely for the duration of the lock.
medium Issues | 4 findings
Resolved
#1 medium Issue
availableToTransfer view is inconsistent with the transfer rules enforced in _update
When an address carries both a time-lock (lockUntil in the future) and a vesting schedule, availableToTransfer evaluates only the vesting branch and can return a non-zero value, while _update still reverts the actual transfer because it checks block.timestamp >= lockUntil[from] first. User interfaces, routers, bots and accounting tools relying on this view will mislead users about what they can actually move. This also means transferWithVesting never inspects lockUntil[to] when writing a new schedule, which is a silent way to land an address in the inconsistent state above.
Resolved
#2 medium Issue
Single-step Ownable with broad, irreversible operational powers
The contract uses a single-key, one-step Ownable for all administrative functions (lock creation, lock extension, vesting creation). A mistyped transferOwnership sends ownership to an unusable address in a single transaction, and there is no on-chain delay or co-signer requirement between a privileged call being initiated and it taking effect. Given the impact of setLock, transferWithLock and transferWithVesting, this access-control model is thinner than best practice for a production token.
Resolved
#3 medium Issue
Vesting lien is enforced as a fungible reserve against the entire balance rather than a segregated escrow
The _update hook enforces balanceOf(from) >= value + (v.totalAmount - vestedAmount(from)). The result is a fungible reserve: any tokens in the account can satisfy the locked amount, and granted tokens are not distinguished from pre-existing or later-received tokens. This does not freeze unrelated balance, but it does couple the accounting of the grant to the beneficiary's wider balance, which can confuse off-chain tooling that expects vesting grants to be held in a separate escrow. It also means burning, slashing or clawing back granted tokens in isolation is impossible.
Resolved
#4 medium Issue
Locked and undervested holders cannot burn their tokens
_update applies the lock and vesting checks to every non-mint transition, including _burn (which routes through _update with to == address(0)). A holder under an active lock or with an undervested grant therefore cannot exercise the burn / burnFrom interface inherited from ERC20Burnable. This contradicts the common user expectation that holders can always destroy tokens they own, and makes the advertised 'burnable' behaviour conditional in a way that is not documented.
low Issues | 4 findings
Resolved
#1 low Issue
Floating Solidity pragma and default PUSH0 target
The contract uses a floating pragma ^0.8.27, allowing compilation with any compatible 0.8.27+ compiler. Combined with the default Shanghai target of modern solc, the emitted bytecode uses PUSH0, which is not universally supported across EVM-compatible chains. Two rebuilds of the same source can therefore produce different artifacts, and deployment can silently fail on chains that do not implement PUSH0.
Resolved
#2 low Issue
Missing input validation on privileged functions
The privileged entrypoints do not validate inputs that have operational impact. setLock accepts address(0) because it never calls _transfer. transferWithLock and transferWithVesting do not require amount > 0, so a zero-amount call silently consumes a vesting slot forever (combined with the write-once rule on vesting). transferWithVesting also lacks upper bounds on cliffDays and stepDays, allowing pathological schedule parameters.
Resolved
#3 low Issue
renounceOwnership is exposed and would be destructive
OpenZeppelin's Ownable exposes renounceOwnership, which sets the owner to the zero address. Given the design of this contract, a renounced ownership makes every existing lock permanent and every existing vesting schedule frozen in place, with no path to correct a mistake. There is no operational reason to keep this function active in a production deployment of this token.
Pending
#4 low Issue
transferWithLock overwrites an existing amount-lock and there is no way to clear it
transferWithLock assigns lockedAmount[to] = amount and lockedUntil[to] = unlockTime rather than accumulating, so calling it twice on the same address replaces the earlier lock; because the first tranche is already in the recipient balance, the previously locked tokens stop being reserved. removeLock only clears the full-address freeze, not the amount-based lock, so there is no direct way to undo an amount-lock except overwriting it with another transfer or waiting for expiry.
optimization Issues | 4 findings
Resolved
#1 optimization Issue
Mark public administrative and view functions as external
Several administrative and view functions are declared public but are never called from within the contract. public adds a small gas overhead because the arguments are copied from calldata into memory. external is the appropriate visibility whenever internal consumption is not needed, and it also communicates more clearly that these functions are part of the contract's external surface only.
Resolved
#2 optimization Issue
Replace repeated numeric literals with named constants
The literal 100 is repeated in percent validation and in the vesting maths, and the initial supply is written as an inline expression in the constructor. Promoting these to named constants improves readability and reduces bytecode duplication, while also making the cap part of the contract's public API.
Resolved
#3 optimization Issue
Prefer multiplication before division to reduce precision loss
vestedAmount performs a division (afterCliff / stepSeconds) before multiplying by stepPercent, and computes currentStepAmount by dividing totalAmount by 100 before multiplying by currentStepElapsed. Reordering to divide last keeps precision maximal and prevents rounding errors from accumulating across steps.
Resolved
#4 optimization Issue
Pack VestingInfo fields to reduce storage writes
VestingInfo declares five independent uint256 fields. The values stored there (token amounts bounded by the total supply, day counts and a percentage) all fit in smaller integer types, so the struct can be packed into fewer storage slots without loss of precision.
informational Issues | 10 findings
Pending
#1 informational Issue
Vesting schedules are write-once and cannot be cancelled or amended
The check require(vesting[to].totalAmount == 0) prevents any subsequent update to a vesting schedule once it has been created, and the contract exposes no function to cancel or amend one. Whether this is acceptable depends on the product requirements around vesting and is therefore flagged as a product and operations decision rather than a bug.
Pending
#2 informational Issue
Vesting releases tokens continuously within each step
After the cliff ends, vestedAmount releases tokens continuously each second inside the current step. Classic cliff-plus-step schedules typically unlock tokens discretely at step boundaries. Whether the continuous behaviour is correct depends on what the project has advertised; it is not itself a security bug, but it is a meaningful product decision worth explicitly documenting.
Pending
#3 informational Issue
permit allowances can still be signed while an account is locked
A locked holder can still produce valid EIP-2612 permit signatures because permit only updates allowance. A subsequent transferFrom will revert in _update because of the lock. This matches the standard semantics of permit, which never promises that a downstream transfer will succeed, and is therefore documented here as informational rather than as a vulnerability.
Pending
#4 informational Issue
Reliance on block.timestamp for time comparisons
All lock checks, vesting progress checks and availability checks use block.timestamp. Validators have a limited ability to influence this value, which is irrelevant at the day-level resolution used by the vesting and lock logic. Listed for completeness because it is an industry-standard disclosure.
Resolved
#5 informational Issue
Modulo used for interpolation flagged as weak randomness
Static analysis reports afterCliff % stepSeconds as a weak pseudo-random number generator. The modulo here is purely a deterministic interpolation inside a vesting step and not a random value. Listed for documentation only.
Resolved
#6 informational Issue
Strict equality on totalAmount == 0 flagged as dangerous
Static analysis flags v.totalAmount == 0 as a dangerous strict equality. Here zero is the default value for an uninitialised VestingInfo, so the comparison is the standard way to check whether a schedule has been set. Listed for documentation only.
Pending
#7 informational Issue
Verify EIP-712 domain separator after deployment
ERC20Permit is initialised with the name 'ShopinX Token', which matches the ERC20 name. A routine post-deployment check should confirm that the domain separator computed off-chain matches the one returned by the deployed contract, to rule out metadata or constructor-argument drift.
Pending
#8 informational Issue
Recipient and initial owner addresses should be publicly documented
The constructor receives the initial recipient of the full supply and the administrative owner as two arguments, with no on-chain labelling of what each address represents. Clear documentation of these addresses is essential for downstream trust evaluation given the broad administrative powers described elsewhere in this report.
Pending
#9 informational Issue
Initial supply changed from the originally audited figure
The constructor mints the full supply once at deployment. The figure increased from the originally audited 300,000,000 to 1,000,000,000 SPX, which is a tokenomics decision rather than a remediation. It is recorded here so that the supply change is explicitly reviewed and tracked.
Pending
#10 informational Issue
Unchecked downcasts when writing the vesting struct
transferWithVesting narrows amount to uint128 and block.timestamp, cliffDays, stepPercent and stepDays to uint32 with unchecked casts. With the current fixed supply and the enforced bounds these casts cannot overflow today (the amount stays below the uint128 range and the day and percent values are bounded well below the uint32 range), but the casts are unchecked and the forge linter flags them as unsafe; startTime stored as uint32 also truncates after the year 2106.