MetaMask / MetaMask/snap-7715-permissions
[Bug]: adjustable stream amounts can round down to a zero on-chain rate
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 11
- Forks
- 9
- Avg merge
- 3d 13h
- Merged PRs (30d)
- 2
Description
Describe the bug
Adjustable native-token-stream and erc20-token-stream permissions can accept a positive user-entered "amount per period" that rounds down to a zero on-chain amountPerSecond.
In both stream context modules, applyContext() converts the UI amount by parsing it into base units and then dividing by the selected period length. The relevant lines are:
packages/gator-permissions-snap/src/permissions/nativeTokenStream/context.ts, lines 82-85:
amountPerSecond: bigIntToHex(
parseUnits({ formatted: permissionDetails.amountPerPeriod, decimals }) /
TIME_PERIOD_TO_SECONDS[permissionDetails.timePeriod],
),
packages/gator-permissions-snap/src/permissions/erc20TokenStream/context.ts, lines 81-86:
amountPerSecond: bigIntToHex(
parseUnits({
formatted: permissionDetails.amountPerPeriod,
decimals,
}) / TIME_PERIOD_TO_SECONDS[permissionDetails.timePeriod],
),
For small but positive amounts, integer division can produce 0. For example:
- Native stream:
0.000000000000000001 ETH / monthparses to1 wei, then1 / 2,592,000 = 0. - ERC20 stream with 6 decimals, such as USDC:
0.000001 USDC / monthparses to1base unit, then1 / 2,592,000 = 0.
deriveMetadata() does not treat this as invalid because validateAndParseAmount() only checks that the parsed amount per period is greater than zero.
packages/gator-permissions-snap/src/permissions/contextValidation.ts, lines 37-55:
const parsedAmount = parseUnits({ formatted: amount, decimals });
if (!allowZero && parsedAmount <= 0n) {
return {
amount: null,
error: t('errorAmountMustBeGreaterThanZero', [
toSentenceCase(fieldName),
]),
};
}
if (allowZero && parsedAmount < 0n) {
return {
amount: null,
error: t('errorAmountMustBeGreaterThanOrEqualToZero', [
toSentenceCase(fieldName),
]),
};
}
return { amount: parsedAmount, error: null };
calculateAmountPerSecond() then formats the rounded-down value as 0.
packages/gator-permissions-snap/src/permissions/contextValidation.ts, lines 137-146:
export function calculateAmountPerSecond(
amountPerPeriod: bigint,
timePeriod: TimePeriod,
decimals: number,
): string {
return formatUnits({
value: amountPerPeriod / TIME_PERIOD_TO_SECONDS[timePeriod],
decimals,
});
}
During confirmation, onBeforeGrant() only rejects the grant if metadata contains validation errors, so this zero-rate stream can pass the adjustable-context validation path.
packages/gator-permissions-snap/src/core/confirmation/ConfirmationSession.ts, lines 354-374:
const hasValidationErrors = (metadata: TMetadata): boolean => {
return Object.values(metadata?.validationErrors ?? {}).some(
(message) => typeof message === 'string',
);
};
const onBeforeGrant = async (): Promise<boolean> => {
const metadata = await lifecycleHandlers.deriveMetadata({
context: state.context,
});
return !hasValidationErrors(metadata);
};
This is inconsistent with the request validation path: both native and ERC20 stream validators call validateHexInteger(... allowZero: false) for amountPerSecond, so an incoming request with amountPerSecond: 0x0 is explicitly invalid.
packages/gator-permissions-snap/src/permissions/nativeTokenStream/validation.ts, lines 45-50, and packages/gator-permissions-snap/src/permissions/erc20TokenStream/validation.ts, lines 45-50:
validateHexInteger({
name: 'amountPerSecond',
value: amountPerSecond,
required: true,
allowZero: false,
});
Expected behavior
If the parsed amount per period is positive but smaller than one base unit per second for the selected time period, the snap should surface a validation error and prevent granting the adjusted permission.
A fix could validate the computed quotient before displaying/enabling grant, e.g. reject when:
parseUnits({ formatted: amountPerPeriod, decimals }) /
TIME_PERIOD_TO_SECONDS[timePeriod] === 0n
I would avoid rounding up automatically, since that would grant a larger stream rate than the user entered.
Steps to reproduce
I reproduced this on main at commit 60c8a92a9d26198963075484d7da369a3a086fa0.
Add the following focused regression checks to the existing context tests.
For packages/gator-permissions-snap/test/permissions/nativeTokenStream/context.test.ts:
it('allows a positive amount per period that rounds down to zero wei per second', async () => {
const now = Math.floor(Date.now() / 1000);
const contextWithTinyAmount: NativeTokenStreamContext = {
...alreadyPopulatedContext,
expiry: {
timestamp: now + 24 * 60 * 60,
isAdjustmentAllowed: true,
},
permissionDetails: {
...alreadyPopulatedContext.permissionDetails,
amountPerPeriod: '0.000000000000000001',
startTime: now,
},
};
const metadata = await deriveMetadata({
context: contextWithTinyAmount,
});
expect(metadata.validationErrors).toStrictEqual({});
expect(metadata.amountPerSecond).toBe('0');
const permissionRequest = await applyContext({
context: contextWithTinyAmount,
originalRequest: alreadyPopulatedPermissionRequest,
});
expect(permissionRequest.permission.data.amountPerSecond).toBe('0x0');
});
For packages/gator-permissions-snap/test/permissions/erc20TokenStream/context.test.ts:
it('allows a positive amount per period that rounds down to zero base units per second', async () => {
const now = Math.floor(Date.now() / 1000);
const contextWithTinyAmount: Erc20TokenStreamContext = {
...alreadyPopulatedContext,
expiry: {
timestamp: now + 24 * 60 * 60,
isAdjustmentAllowed: true,
},
permissionDetails: {
...alreadyPopulatedContext.permissionDetails,
amountPerPeriod: '0.000001',
startTime: now,
},
};
const metadata = await deriveMetadata({
context: contextWithTinyAmount,
});
expect(metadata.validationErrors).toStrictEqual({});
expect(metadata.amountPerSecond).toBe('0');
const permissionRequest = await applyContext({
context: contextWithTinyAmount,
originalRequest: alreadyPopulatedPermissionRequest,
});
expect(permissionRequest.permission.data.amountPerSecond).toBe('0x0');
});
Run:
yarn workspace @metamask/gator-permissions-snap test --runTestsByPath test/permissions/nativeTokenStream/context.test.ts test/permissions/erc20TokenStream/context.test.ts
The tests pass, demonstrating that the current validation path accepts these positive inputs and produces a zero-rate stream:
PASS test/permissions/erc20TokenStream/context.test.ts
PASS test/permissions/nativeTokenStream/context.test.ts
Test Suites: 2 passed, 2 total
Tests: 61 passed, 61 total
Error messages or log output
No runtime exception is thrown. The problem is that no validation error is produced before granting, while the resulting permission data contains amountPerSecond: 0x0.
Version
Repository main, commit 60c8a92a9d26198963075484d7da369a3a086fa0.
Build type
Flask
Browser
Other (please elaborate in the "Additional Context" section)
Operating system
MacOS
Additional context
I searched existing issues and PRs for amountPerSecond zero, zero-rate, round down stream, stream minimum, and amount per period, and did not find an existing report for this rounding-to-zero case.
This looks like a UI/context-to-permission semantic mismatch rather than a contract-level issue. It is especially easy to hit for low-decimal tokens and long stream periods.
Severity
Low to medium. This does not appear to overgrant funds, but it can silently create a permission whose actual stream rate is zero even though the user entered a positive rate. That can confuse users and dapps, and it bypasses the same zero-rate invariant enforced on incoming stream permission requests.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/gator-permissions-snap/src/permissions/contextValidation.ts and compare deriveMetadata/applyContext in the nativeTokenStream and erc20TokenStream modules. Run the focused context tests named in the issue, then add regression coverage showing that a positive amount which computes to zero amountPerSecond produces a validation error and cannot create a zero-rate permission.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authorization
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100