MetaMask / MetaMask/snap-7715-permissions

[Bug]: adjustable stream amounts can round down to a zero on-chain rate

Open
#420 0 comments 0 reactions 0 assignees View on GitHub

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 / month parses to 1 wei, then 1 / 2,592,000 = 0.
  • ERC20 stream with 6 decimals, such as USDC: 0.000001 USDC / month parses to 1 base unit, then 1 / 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.