From 15fae7ec0a8ec8120552ad611bd14e97cc072b35 Mon Sep 17 00:00:00 2001 From: vladi-coti Date: Thu, 18 Jun 2026 17:00:14 +0300 Subject: [PATCH 1/4] feat: implement decrypt-balance functionality and normalize balance payloads - Added a new RPC method 'decrypt-balance' to handle decryption of balance payloads. - Introduced utility functions for normalizing 64-bit and 256-bit balance formats. - Enhanced error handling for malformed balance entries and invalid variants. - Updated tests to cover new functionality and ensure robustness of the decryption process. --- packages/snap/snap.manifest.json | 2 +- packages/snap/src/index.tsx | 124 ++++++++++++++++ packages/snap/src/test/onRpcRequest.test.tsx | 147 +++++++++++++++++++ 3 files changed, 272 insertions(+), 1 deletion(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 5b072ced..67897cb6 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/coti-io/coti-snap.git" }, "source": { - "shasum": "frm3bxp1Wf6YRW+fNZo8MaF/DkpV2bAjyB3qwHZN6/Y=", + "shasum": "TV/5LLGXKeZ0BQ68mj1WDyephBVQ9ZHtKZUJiIy4ff0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/index.tsx b/packages/snap/src/index.tsx index ccc7c95c..bf457fb5 100644 --- a/packages/snap/src/index.tsx +++ b/packages/snap/src/index.tsx @@ -47,6 +47,7 @@ import { checkIfERC20Unique, checkIfERC721Unique, checkERC721Ownership, + decryptBalance, } from './utils/token'; import { buildItUint256, deriveSnapWallet } from './utils/itUint'; @@ -95,6 +96,37 @@ type EncryptedPayload = { r: Record; }; +type SerializableBalance = + | string + | number + | bigint + | { + ciphertextHigh?: string | number | bigint; + ciphertextLow?: string | number | bigint; + high?: { + high?: string | number | bigint; + low?: string | number | bigint; + }; + low?: { + high?: string | number | bigint; + low?: string | number | bigint; + }; + }; + +type NormalizedBalance = + | bigint + | { ciphertextHigh: bigint; ciphertextLow: bigint } + | { high: { high: bigint; low: bigint }; low: { high: bigint; low: bigint } }; + +type DecryptBalanceParams = { + balances?: { + balance?: SerializableBalance; + variant?: 64 | 256; + decimals?: string | number | null; + }[]; + chainId?: string; +}; + const isByteRecord = (value: unknown): value is Record => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; @@ -127,6 +159,55 @@ const parseEncryptedPayload = (encryptedValue: string): EncryptedPayload => { } }; +const toBigInt = (value: string | number | bigint | undefined): bigint => { + if (value === undefined || value === null) { + throw new Error('Missing bigint value.'); + } + return BigInt(value); +}; + +const isBalanceVariant = (value: unknown): value is 64 | 256 => + value === 64 || value === 256; + +/** + * Normalizes a JSON-RPC-safe balance payload into the bigint shapes expected by + * decryptBalance. Exported so the parser can be tested without the Snap sandbox. + */ +export const normalizeBalancePayload = ( + balance: SerializableBalance, + variant: 64 | 256, +): NormalizedBalance => { + if (variant === 64) { + return toBigInt(balance as string | number | bigint); + } + + if (!balance || typeof balance !== 'object') { + throw new Error('Invalid 256-bit balance payload.'); + } + + if ('high' in balance && 'low' in balance && balance.high && balance.low) { + return { + high: { + high: toBigInt(balance.high.high), + low: toBigInt(balance.high.low), + }, + low: { + high: toBigInt(balance.low.high), + low: toBigInt(balance.low.low), + }, + }; + } + + if ('ciphertextHigh' in balance && 'ciphertextLow' in balance) { + return { + ciphertextHigh: toBigInt(balance.ciphertextHigh), + ciphertextLow: toBigInt(balance.ciphertextLow), + }; + } + + throw new Error('Invalid 256-bit balance payload.'); +}; + const ALLOWED_RAW_SIGN_FUNCTION_SELECTORS = new Set([ ethers.id('transfer(address,(uint256,bytes))').slice(0, 10).toLowerCase(), ethers @@ -645,6 +726,49 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ } return null; + case 'decrypt-balance': { + const params = request.params as DecryptBalanceParams | undefined; + if (!params?.balances || !Array.isArray(params.balances)) { + return null; + } + + const balanceState = + params.chainId === undefined + ? getState + : await getStateByChainIdAndAddress(params.chainId); + + if (!balanceState.aesKey) { + return params.balances.map(() => null); + } + + return params.balances.map((entry) => { + try { + if ( + entry?.balance === undefined || + entry.balance === null || + !isBalanceVariant(entry.variant) + ) { + return null; + } + + const normalizedBalance = normalizeBalancePayload( + entry.balance, + entry.variant, + ); + const decrypted = decryptBalance( + normalizedBalance, + balanceState.aesKey as string, + entry.variant, + entry.decimals, + ); + + return decrypted === null ? null : decrypted.toString(); + } catch { + return null; + } + }); + } + case 'build-it-uint256': { try { const params = request.params as diff --git a/packages/snap/src/test/onRpcRequest.test.tsx b/packages/snap/src/test/onRpcRequest.test.tsx index 9dff818e..a0576589 100644 --- a/packages/snap/src/test/onRpcRequest.test.tsx +++ b/packages/snap/src/test/onRpcRequest.test.tsx @@ -7,9 +7,24 @@ import { import { installSnap } from '@metamask/snaps-jest'; import type { SnapConfirmationInterface } from '@metamask/snaps-jest'; import { Box, Text, Heading } from '@metamask/snaps-sdk/jsx'; +import { normalizeBalancePayload } from '../index'; const AUTHORIZED_SET_KEY_ORIGIN = 'https://metamask.coti.io'; const TEST_AES_KEY = '00112233445566778899aabbccddeeff'; +const TESTNET_DECRYPT_BALANCE_FIXTURE = { + aesKey: process.env.TESTNET_DECRYPT_BALANCE_AES_KEY, + expected: process.env.TESTNET_DECRYPT_BALANCE_EXPECTED, + ciphertextHigh: process.env.TESTNET_DECRYPT_BALANCE_HIGH, + ciphertextLow: process.env.TESTNET_DECRYPT_BALANCE_LOW, + decimals: process.env.TESTNET_DECRYPT_BALANCE_DECIMALS ?? '18', + chainId: process.env.TESTNET_DECRYPT_BALANCE_CHAIN_ID, +}; +const hasTestnetDecryptBalanceFixture = Boolean( + TESTNET_DECRYPT_BALANCE_FIXTURE.aesKey && + TESTNET_DECRYPT_BALANCE_FIXTURE.expected && + TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextHigh && + TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextLow, +); const setAesKeyWithConfirmation = async (request: any, aesKey = TEST_AES_KEY) => { const response = request({ @@ -25,6 +40,138 @@ const setAesKeyWithConfirmation = async (request: any, aesKey = TEST_AES_KEY) => }; describe('onRpcRequest', () => { + describe('decrypt-balance', () => { + it('normalizes 64-bit, flat 256-bit, and nested 256-bit payloads', () => { + expect(normalizeBalancePayload('123', 64)).toBe(123n); + + expect( + normalizeBalancePayload( + { ciphertextHigh: '1', ciphertextLow: '2' }, + 256, + ), + ).toEqual({ ciphertextHigh: 1n, ciphertextLow: 2n }); + + expect( + normalizeBalancePayload( + { + high: { high: '1', low: '2' }, + low: { high: '3', low: '4' }, + }, + 256, + ), + ).toEqual({ + high: { high: 1n, low: 2n }, + low: { high: 3n, low: 4n }, + }); + }); + + it('rejects malformed 256-bit payloads during normalization', () => { + expect(() => normalizeBalancePayload({} as any, 256)).toThrow( + 'Invalid 256-bit balance payload.', + ); + }); + + it('returns null for each balance when AES key is missing', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'decrypt-balance', + params: { + balances: [ + { + balance: { ciphertextHigh: '1', ciphertextLow: '2' }, + variant: 256, + decimals: 18, + }, + { + balance: '3', + variant: 64, + decimals: 18, + }, + ], + }, + }); + + expect(response).toRespondWith([null, null]); + }); + + it('returns null for malformed entries without prompting', async () => { + const { request } = await installSnap(); + await setAesKeyWithConfirmation(request); + + const response = await request({ + method: 'decrypt-balance', + params: { + balances: [ + { + balance: { invalid: 'shape' }, + variant: 256, + decimals: 18, + }, + ], + }, + }); + + expect(response).toRespondWith([null]); + }); + + it('returns null for invalid balance variants', async () => { + const { request } = await installSnap(); + await setAesKeyWithConfirmation(request); + + const response = await request({ + method: 'decrypt-balance', + params: { + balances: [ + { + balance: '1', + variant: 128, + decimals: 18, + }, + ], + }, + }); + + expect(response).toRespondWith([null]); + }); + + (hasTestnetDecryptBalanceFixture ? it : it.skip)( + 'decrypts a captured COTI testnet private balance fixture', + async () => { + const { request } = await installSnap(); + await setAesKeyWithConfirmation( + request, + TESTNET_DECRYPT_BALANCE_FIXTURE.aesKey as string, + ); + + const response = await request({ + method: 'decrypt-balance', + params: { + ...(TESTNET_DECRYPT_BALANCE_FIXTURE.chainId + ? { chainId: TESTNET_DECRYPT_BALANCE_FIXTURE.chainId } + : {}), + balances: [ + { + balance: { + ciphertextHigh: + TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextHigh as string, + ciphertextLow: + TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextLow as string, + }, + variant: 256, + decimals: TESTNET_DECRYPT_BALANCE_FIXTURE.decimals, + }, + ], + }, + }); + + expect(response).toRespondWith([ + TESTNET_DECRYPT_BALANCE_FIXTURE.expected, + ]); + }, + ); + }); + it('handles encryption with a valid AES key', async () => { const { request } = await installSnap(); const aesKey = TEST_AES_KEY; From 3f5bad110a8487cfc8a0616447461fe8ac8d3c81 Mon Sep 17 00:00:00 2001 From: vladi-coti Date: Thu, 18 Jun 2026 18:16:07 +0300 Subject: [PATCH 2/4] feat(snap): support typed uint crypto Route typed uint64/uint256 encryption and ctUint64/ctUint256 decryption through the existing RPCs so the AES key stays in Snap state. Remove the temporary decrypt-balance RPC. --- packages/snap/snap.manifest.json | 2 +- packages/snap/src/index.tsx | 287 ++++++++++++------- packages/snap/src/test/onRpcRequest.test.tsx | 205 +++++++------ 3 files changed, 310 insertions(+), 184 deletions(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 67897cb6..c0636d6b 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/coti-io/coti-snap.git" }, "source": { - "shasum": "TV/5LLGXKeZ0BQ68mj1WDyephBVQ9ZHtKZUJiIy4ff0=", + "shasum": "Nf+wy7VlZ/cUZuOxS9MvbAqyGBcPj/eZ4G1WyRVaRPs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/index.tsx b/packages/snap/src/index.tsx index bf457fb5..9a3744d6 100644 --- a/packages/snap/src/index.tsx +++ b/packages/snap/src/index.tsx @@ -3,10 +3,14 @@ /* eslint-disable @typescript-eslint/prefer-optional-chain */ /* eslint-disable @typescript-eslint/switch-exhaustiveness-check */ import { + decodeUint, + decrypt, + decryptUint, + decryptUint256, + encodeKey, encodeString, + encodeUint, encrypt, - encodeKey, - decrypt, } from '@coti-io/coti-sdk-typescript'; import { UserInputEventType } from '@metamask/snaps-sdk'; import type { @@ -47,7 +51,6 @@ import { checkIfERC20Unique, checkIfERC721Unique, checkERC721Ownership, - decryptBalance, } from './utils/token'; import { buildItUint256, deriveSnapWallet } from './utils/itUint'; @@ -96,34 +99,30 @@ type EncryptedPayload = { r: Record; }; -type SerializableBalance = +type SerializableCt = | string | number | bigint | { ciphertextHigh?: string | number | bigint; ciphertextLow?: string | number | bigint; - high?: { - high?: string | number | bigint; - low?: string | number | bigint; - }; - low?: { - high?: string | number | bigint; - low?: string | number | bigint; - }; }; -type NormalizedBalance = +type NormalizedCt = | bigint - | { ciphertextHigh: bigint; ciphertextLow: bigint } - | { high: { high: bigint; low: bigint }; low: { high: bigint; low: bigint } }; - -type DecryptBalanceParams = { - balances?: { - balance?: SerializableBalance; - variant?: 64 | 256; - decimals?: string | number | null; - }[]; + | { ciphertextHigh: bigint; ciphertextLow: bigint }; + +type TypedDecryptParams = { + type?: 'ctUint64' | 'ctUint256'; + value?: SerializableCt; + values?: SerializableCt[]; + chainId?: string; +}; + +type TypedEncryptParams = { + type?: 'uint64' | 'uint256'; + value?: string | number | bigint; + values?: Array; chainId?: string; }; @@ -166,46 +165,158 @@ const toBigInt = (value: string | number | bigint | undefined): bigint => { return BigInt(value); }; -const isBalanceVariant = (value: unknown): value is 64 | 256 => - value === 64 || value === 256; - /** - * Normalizes a JSON-RPC-safe balance payload into the bigint shapes expected by - * decryptBalance. Exported so the parser can be tested without the Snap sandbox. + * Normalizes a JSON-RPC-safe confidential uint payload into the bigint shapes expected by + * SDK decrypt helpers. Exported so the parser can be tested without the Snap sandbox. */ -export const normalizeBalancePayload = ( - balance: SerializableBalance, - variant: 64 | 256, -): NormalizedBalance => { - if (variant === 64) { - return toBigInt(balance as string | number | bigint); +export const normalizeCtPayload = ( + value: SerializableCt, + type: 'ctUint64' | 'ctUint256', +): NormalizedCt => { + if (type === 'ctUint64') { + return toBigInt(value as string | number | bigint); } - if (!balance || typeof balance !== 'object') { - throw new Error('Invalid 256-bit balance payload.'); + if (!value || typeof value !== 'object') { + throw new Error('Invalid ctUint256 payload.'); } - if ('high' in balance && 'low' in balance && balance.high && balance.low) { + if ('ciphertextHigh' in value && 'ciphertextLow' in value) { return { - high: { - high: toBigInt(balance.high.high), - low: toBigInt(balance.high.low), - }, - low: { - high: toBigInt(balance.low.high), - low: toBigInt(balance.low.low), - }, + ciphertextHigh: toBigInt(value.ciphertextHigh), + ciphertextLow: toBigInt(value.ciphertextLow), }; } - if ('ciphertextHigh' in balance && 'ciphertextLow' in balance) { - return { - ciphertextHigh: toBigInt(balance.ciphertextHigh), - ciphertextLow: toBigInt(balance.ciphertextLow), - }; + throw new Error('Invalid ctUint256 payload.'); +}; + +const getTypedValues = ( + params: { value?: Value; values?: Value[] }, +): Value[] | null => { + if (Array.isArray(params.values)) { + return params.values; + } + if (params.value !== undefined && params.value !== null) { + return [params.value]; + } + return null; +}; + +const isArrayRequest = (params: { values?: unknown[] }): boolean => + Array.isArray(params.values); + +const encryptUint64Value = ( + plaintext: string | number | bigint, + aesKey: string, +): string => { + const plaintextBigInt = BigInt(plaintext); + if (plaintextBigInt >= 2n ** 64n) { + throw new RangeError('Plaintext size must be 64 bits or smaller.'); + } + const { ciphertext, r } = encrypt( + encodeKey(aesKey), + encodeUint(plaintextBigInt), + ); + return decodeUint(new Uint8Array([...ciphertext, ...r])).toString(); +}; + +const encryptUint256Value = ( + plaintext: string | number | bigint, + aesKey: string, +): { ciphertextHigh: string; ciphertextLow: string } => { + const plaintextBigInt = BigInt(plaintext); + if (plaintextBigInt >= 2n ** 256n) { + throw new RangeError('Plaintext size must be 256 bits or smaller.'); + } + + const mask128 = (1n << 128n) - 1n; + const highPlaintext = plaintextBigInt >> 128n; + const lowPlaintext = plaintextBigInt & mask128; + const keyBytes = encodeKey(aesKey); + const high = encrypt(keyBytes, encodeUint(highPlaintext)); + const low = encrypt(keyBytes, encodeUint(lowPlaintext)); + + return { + ciphertextHigh: decodeUint( + new Uint8Array([...high.ciphertext, ...high.r]), + ).toString(), + ciphertextLow: decodeUint( + new Uint8Array([...low.ciphertext, ...low.r]), + ).toString(), + }; +}; + +const encryptTypedValues = ( + params: TypedEncryptParams, + aesKey: string | null | undefined, +): + | string + | { ciphertextHigh: string; ciphertextLow: string } + | Array + | null => { + if (params.type !== 'uint64' && params.type !== 'uint256') { + return null; + } + + if (!aesKey) { + const values = getTypedValues(params); + return values && isArrayRequest(params) ? values.map(() => null) : null; + } + + const values = getTypedValues(params); + if (!values) { + return null; + } + + const encrypted = values.map((value) => { + try { + return params.type === 'uint64' + ? encryptUint64Value(value, aesKey) + : encryptUint256Value(value, aesKey); + } catch { + return null; + } + }); + + return isArrayRequest(params) ? encrypted : encrypted[0] ?? null; +}; + +const decryptTypedValues = ( + params: TypedDecryptParams, + aesKey: string | null | undefined, +): string | null | (string | null)[] => { + if (params.type !== 'ctUint64' && params.type !== 'ctUint256') { + return null; + } + const decryptType = params.type; + + const values = getTypedValues(params); + if (!values) { + return null; + } + + if (!aesKey) { + return isArrayRequest(params) ? values.map(() => null) : null; } - throw new Error('Invalid 256-bit balance payload.'); + const decrypted = values.map((value) => { + try { + const normalized = normalizeCtPayload(value, decryptType); + const result = + decryptType === 'ctUint64' + ? decryptUint(normalized as bigint, aesKey) + : decryptUint256( + normalized as { ciphertextHigh: bigint; ciphertextLow: bigint }, + aesKey, + ); + return result.toString(); + } catch { + return null; + } + }); + + return isArrayRequest(params) ? decrypted : decrypted[0] ?? null; }; const ALLOWED_RAW_SIGN_FUNCTION_SELECTORS = new Set([ @@ -626,7 +737,18 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ return null; } - const { value: textToEncrypt } = request.params as Record; + const encryptParams = request.params as + | (Record & TypedEncryptParams) + | undefined; + if ( + encryptParams?.type === 'uint64' || + encryptParams?.type === 'uint256' + ) { + const state = await getStateByChainIdAndAddress(encryptParams.chainId); + return encryptTypedValues(encryptParams, state.aesKey); + } + + const { value: textToEncrypt } = encryptParams as Record; if (!textToEncrypt) { return null; } @@ -675,10 +797,22 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ return null; } - const { value: encryptedValue } = request.params as Record< - string, - string - >; + const decryptParams = request.params as + | (Record & TypedDecryptParams) + | undefined; + + if ( + decryptParams?.type === 'ctUint64' || + decryptParams?.type === 'ctUint256' + ) { + const decryptState = + decryptParams.chainId === undefined + ? getState + : await getStateByChainIdAndAddress(decryptParams.chainId); + return decryptTypedValues(decryptParams, decryptState.aesKey); + } + + const { value: encryptedValue } = decryptParams as Record; if (!encryptedValue) { return null; @@ -726,49 +860,6 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ } return null; - case 'decrypt-balance': { - const params = request.params as DecryptBalanceParams | undefined; - if (!params?.balances || !Array.isArray(params.balances)) { - return null; - } - - const balanceState = - params.chainId === undefined - ? getState - : await getStateByChainIdAndAddress(params.chainId); - - if (!balanceState.aesKey) { - return params.balances.map(() => null); - } - - return params.balances.map((entry) => { - try { - if ( - entry?.balance === undefined || - entry.balance === null || - !isBalanceVariant(entry.variant) - ) { - return null; - } - - const normalizedBalance = normalizeBalancePayload( - entry.balance, - entry.variant, - ); - const decrypted = decryptBalance( - normalizedBalance, - balanceState.aesKey as string, - entry.variant, - entry.decimals, - ); - - return decrypted === null ? null : decrypted.toString(); - } catch { - return null; - } - }); - } - case 'build-it-uint256': { try { const params = request.params as diff --git a/packages/snap/src/test/onRpcRequest.test.tsx b/packages/snap/src/test/onRpcRequest.test.tsx index a0576589..e5328ebb 100644 --- a/packages/snap/src/test/onRpcRequest.test.tsx +++ b/packages/snap/src/test/onRpcRequest.test.tsx @@ -1,5 +1,6 @@ import { decrypt, + decryptUint256, encodeKey, encrypt, encodeString, @@ -7,23 +8,22 @@ import { import { installSnap } from '@metamask/snaps-jest'; import type { SnapConfirmationInterface } from '@metamask/snaps-jest'; import { Box, Text, Heading } from '@metamask/snaps-sdk/jsx'; -import { normalizeBalancePayload } from '../index'; +import { normalizeCtPayload } from '../index'; const AUTHORIZED_SET_KEY_ORIGIN = 'https://metamask.coti.io'; const TEST_AES_KEY = '00112233445566778899aabbccddeeff'; -const TESTNET_DECRYPT_BALANCE_FIXTURE = { - aesKey: process.env.TESTNET_DECRYPT_BALANCE_AES_KEY, - expected: process.env.TESTNET_DECRYPT_BALANCE_EXPECTED, - ciphertextHigh: process.env.TESTNET_DECRYPT_BALANCE_HIGH, - ciphertextLow: process.env.TESTNET_DECRYPT_BALANCE_LOW, - decimals: process.env.TESTNET_DECRYPT_BALANCE_DECIMALS ?? '18', - chainId: process.env.TESTNET_DECRYPT_BALANCE_CHAIN_ID, +const TESTNET_CTUINT256_FIXTURE = { + aesKey: process.env.TESTNET_CTUINT256_AES_KEY, + expected: process.env.TESTNET_CTUINT256_EXPECTED, + ciphertextHigh: process.env.TESTNET_CTUINT256_HIGH, + ciphertextLow: process.env.TESTNET_CTUINT256_LOW, + chainId: process.env.TESTNET_CTUINT256_CHAIN_ID, }; -const hasTestnetDecryptBalanceFixture = Boolean( - TESTNET_DECRYPT_BALANCE_FIXTURE.aesKey && - TESTNET_DECRYPT_BALANCE_FIXTURE.expected && - TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextHigh && - TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextLow, +const hasTestnetCtUint256Fixture = Boolean( + TESTNET_CTUINT256_FIXTURE.aesKey && + TESTNET_CTUINT256_FIXTURE.expected && + TESTNET_CTUINT256_FIXTURE.ciphertextHigh && + TESTNET_CTUINT256_FIXTURE.ciphertextLow, ); const setAesKeyWithConfirmation = async (request: any, aesKey = TEST_AES_KEY) => { @@ -40,54 +40,34 @@ const setAesKeyWithConfirmation = async (request: any, aesKey = TEST_AES_KEY) => }; describe('onRpcRequest', () => { - describe('decrypt-balance', () => { - it('normalizes 64-bit, flat 256-bit, and nested 256-bit payloads', () => { - expect(normalizeBalancePayload('123', 64)).toBe(123n); + describe('typed encrypt/decrypt', () => { + it('normalizes ctUint64 and ctUint256 payloads', () => { + expect(normalizeCtPayload('123', 'ctUint64')).toBe(123n); expect( - normalizeBalancePayload( + normalizeCtPayload( { ciphertextHigh: '1', ciphertextLow: '2' }, - 256, + 'ctUint256', ), ).toEqual({ ciphertextHigh: 1n, ciphertextLow: 2n }); - - expect( - normalizeBalancePayload( - { - high: { high: '1', low: '2' }, - low: { high: '3', low: '4' }, - }, - 256, - ), - ).toEqual({ - high: { high: 1n, low: 2n }, - low: { high: 3n, low: 4n }, - }); }); - it('rejects malformed 256-bit payloads during normalization', () => { - expect(() => normalizeBalancePayload({} as any, 256)).toThrow( - 'Invalid 256-bit balance payload.', + it('rejects malformed ctUint256 payloads during normalization', () => { + expect(() => normalizeCtPayload({} as any, 'ctUint256')).toThrow( + 'Invalid ctUint256 payload.', ); }); - it('returns null for each balance when AES key is missing', async () => { + it('returns null for each typed decrypt value when AES key is missing', async () => { const { request } = await installSnap(); const response = await request({ - method: 'decrypt-balance', + method: 'decrypt', params: { - balances: [ - { - balance: { ciphertextHigh: '1', ciphertextLow: '2' }, - variant: 256, - decimals: 18, - }, - { - balance: '3', - variant: 64, - decimals: 18, - }, + type: 'ctUint256', + values: [ + { ciphertextHigh: '1', ciphertextLow: '2' }, + { ciphertextHigh: '3', ciphertextLow: '4' }, ], }, }); @@ -95,79 +75,134 @@ describe('onRpcRequest', () => { expect(response).toRespondWith([null, null]); }); - it('returns null for malformed entries without prompting', async () => { + it('returns null for malformed typed decrypt entries without prompting', async () => { const { request } = await installSnap(); await setAesKeyWithConfirmation(request); const response = await request({ - method: 'decrypt-balance', + method: 'decrypt', params: { - balances: [ - { - balance: { invalid: 'shape' }, - variant: 256, - decimals: 18, - }, - ], + type: 'ctUint256', + values: [{ invalid: 'shape' }], }, }); expect(response).toRespondWith([null]); }); - it('returns null for invalid balance variants', async () => { + it('encrypts a typed uint256 value without exposing the AES key', async () => { const { request } = await installSnap(); - await setAesKeyWithConfirmation(request); + await setAesKeyWithConfirmation(request, TEST_AES_KEY); const response = await request({ - method: 'decrypt-balance', + method: 'encrypt', params: { - balances: [ - { - balance: '1', - variant: 128, - decimals: 18, - }, - ], + type: 'uint256', + value: '100000000', }, }); - expect(response).toRespondWith([null]); + const { result } = response.response as { + result: { + ciphertextHigh: string; + ciphertextLow: string; + }; + }; + + expect(result.ciphertextHigh).toEqual(expect.any(String)); + expect(result.ciphertextLow).toEqual(expect.any(String)); + expect( + decryptUint256( + { + ciphertextHigh: BigInt(result.ciphertextHigh), + ciphertextLow: BigInt(result.ciphertextLow), + }, + TEST_AES_KEY, + ), + ).toBe(100000000n); }); - (hasTestnetDecryptBalanceFixture ? it : it.skip)( - 'decrypts a captured COTI testnet private balance fixture', + it('encrypts typed uint256 arrays', async () => { + const { request } = await installSnap(); + await setAesKeyWithConfirmation(request, TEST_AES_KEY); + + const response = await request({ + method: 'encrypt', + params: { + type: 'uint256', + values: ['1', '2'], + }, + }); + + const { result } = response.response as { + result: Array<{ + ciphertextHigh: string; + ciphertextLow: string; + }>; + }; + + expect(result).toHaveLength(2); + expect( + decryptUint256( + { + ciphertextHigh: BigInt(result[0]!.ciphertextHigh), + ciphertextLow: BigInt(result[0]!.ciphertextLow), + }, + TEST_AES_KEY, + ), + ).toBe(1n); + expect( + decryptUint256( + { + ciphertextHigh: BigInt(result[1]!.ciphertextHigh), + ciphertextLow: BigInt(result[1]!.ciphertextLow), + }, + TEST_AES_KEY, + ), + ).toBe(2n); + }); + + (hasTestnetCtUint256Fixture ? it : it.skip)( + 'decrypts a captured COTI testnet ctUint256 fixture', async () => { const { request } = await installSnap(); await setAesKeyWithConfirmation( request, - TESTNET_DECRYPT_BALANCE_FIXTURE.aesKey as string, + TESTNET_CTUINT256_FIXTURE.aesKey as string, ); const response = await request({ - method: 'decrypt-balance', + method: 'decrypt', params: { - ...(TESTNET_DECRYPT_BALANCE_FIXTURE.chainId - ? { chainId: TESTNET_DECRYPT_BALANCE_FIXTURE.chainId } + ...(TESTNET_CTUINT256_FIXTURE.chainId + ? { chainId: TESTNET_CTUINT256_FIXTURE.chainId } : {}), - balances: [ + type: 'ctUint256', + values: [ { - balance: { - ciphertextHigh: - TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextHigh as string, - ciphertextLow: - TESTNET_DECRYPT_BALANCE_FIXTURE.ciphertextLow as string, - }, - variant: 256, - decimals: TESTNET_DECRYPT_BALANCE_FIXTURE.decimals, + ciphertextHigh: + TESTNET_CTUINT256_FIXTURE.ciphertextHigh as string, + ciphertextLow: + TESTNET_CTUINT256_FIXTURE.ciphertextLow as string, }, ], }, }); - expect(response).toRespondWith([ - TESTNET_DECRYPT_BALANCE_FIXTURE.expected, - ]); + expect(response).toRespondWith([TESTNET_CTUINT256_FIXTURE.expected]); + + const decryptResponse = await request({ + method: 'decrypt', + params: { + type: 'ctUint256', + value: { + ciphertextHigh: TESTNET_CTUINT256_FIXTURE.ciphertextHigh as string, + ciphertextLow: TESTNET_CTUINT256_FIXTURE.ciphertextLow as string, + }, + }, + }); + + expect(decryptResponse).toRespondWith(TESTNET_CTUINT256_FIXTURE.expected); }, ); }); From e8393a081e0d4274e64b2ba84d00b9de453cef90 Mon Sep 17 00:00:00 2001 From: vladi-coti Date: Thu, 18 Jun 2026 20:00:44 +0300 Subject: [PATCH 3/4] added more tests --- packages/snap/src/test/onRpcRequest.test.tsx | 107 +++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/snap/src/test/onRpcRequest.test.tsx b/packages/snap/src/test/onRpcRequest.test.tsx index e5328ebb..05fa1a10 100644 --- a/packages/snap/src/test/onRpcRequest.test.tsx +++ b/packages/snap/src/test/onRpcRequest.test.tsx @@ -1,5 +1,6 @@ import { decrypt, + decryptUint, decryptUint256, encodeKey, encrypt, @@ -122,6 +123,47 @@ describe('onRpcRequest', () => { ).toBe(100000000n); }); + it('encrypts and decrypts typed uint64 values', async () => { + const { request } = await installSnap(); + await setAesKeyWithConfirmation(request, TEST_AES_KEY); + + const encryptResponse = await request({ + method: 'encrypt', + params: { + type: 'uint64', + value: '42', + }, + }); + + const { result: encrypted } = encryptResponse.response as { result: string }; + expect(encrypted).toEqual(expect.any(String)); + expect(decryptUint(BigInt(encrypted), TEST_AES_KEY)).toBe(42n); + + const decryptResponse = await request({ + method: 'decrypt', + params: { + type: 'ctUint64', + value: encrypted, + }, + }); + + expect(decryptResponse).toRespondWith('42'); + }); + + it('returns null for typed encrypt when AES key is missing', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'encrypt', + params: { + type: 'uint256', + values: ['1', '2'], + }, + }); + + expect(response).toRespondWith([null, null]); + }); + it('encrypts typed uint256 arrays', async () => { const { request } = await installSnap(); await setAesKeyWithConfirmation(request, TEST_AES_KEY); @@ -380,6 +422,71 @@ describe('onRpcRequest', () => { expect(responseWithKey).toRespondWith(true); }); + it('rejects set-aes-key from unauthorized origins', async () => { + const { request } = await installSnap(); + + const response = request({ + method: 'set-aes-key', + params: { newUserAesKey: TEST_AES_KEY }, + origin: 'http://localhost:8000', + }); + + const ui = (await response.getInterface()) as SnapConfirmationInterface; + expect(ui.type).toBe('alert'); + expect(ui).toRender( + + Request Not Allowed + This app is not authorized to update your AES key. + + Request origin: {'http://localhost:8000'} + + , + ); + + await ui.ok(); + const rpcResponse = await response; + expect(rpcResponse).toRespondWith(null); + }); + + it('builds itUint256 after confirmation', async () => { + const { request } = await installSnap(); + await setAesKeyWithConfirmation(request, TEST_AES_KEY); + + const response = request({ + method: 'build-it-uint256', + params: { + value: '100000000', + tokenAddress: '0xcEF137E96eDF68EE99D4CdEa7085f154d74895cD', + functionSelector: '0xa9059cbb', + }, + origin: 'https://example-dapp.io', + }); + + const ui = (await response.getInterface()) as SnapConfirmationInterface; + expect(ui.type).toBe('confirmation'); + await ui.ok(); + + const rpcResponse = await response; + const { result } = rpcResponse.response as { + result: { + value: { + ciphertext: { + high: { high: string; low: string }; + low: { high: string; low: string }; + }; + signature: [[string, string], [string, string]]; + }; + }; + }; + + expect(result.value.ciphertext.high.high).toEqual(expect.any(String)); + expect(result.value.ciphertext.high.low).toEqual(expect.any(String)); + expect(result.value.ciphertext.low.high).toEqual(expect.any(String)); + expect(result.value.ciphertext.low.low).toEqual(expect.any(String)); + expect(result.value.signature[0][0]).toMatch(/^0x[0-9a-f]+$/u); + expect(result.value.signature[1][1]).toMatch(/^0x[0-9a-f]+$/u); + }); + it('deletes AES key with user confirmation', async () => { const { request } = await installSnap(); const aesKey = TEST_AES_KEY; From e2a3bbaa7098aff1ba16e17f9409e75ac99756e7 Mon Sep 17 00:00:00 2001 From: vladi-coti Date: Wed, 1 Jul 2026 17:37:42 +0300 Subject: [PATCH 4/4] fix(snap): use SDK 1.0.8 typed crypto --- packages/site/package.json | 2 +- packages/site/src/config/snap.ts | 6 +- packages/site/src/hooks/useRequestSnap.ts | 2 +- packages/site/src/hooks/useTokenOperations.ts | 368 +----------------- packages/site/src/utils/snap.ts | 8 +- packages/site/vite.config.ts | 13 +- packages/snap/package.json | 2 +- packages/snap/snap.manifest.json | 2 +- packages/snap/src/index.tsx | 123 ++---- packages/snap/src/test/onRpcRequest.test.tsx | 29 +- packages/snap/src/test/tokenUtils.test.tsx | 6 + packages/snap/src/utils/itUint.ts | 97 +---- packages/snap/src/utils/token.ts | 138 +------ yarn.lock | 12 +- 14 files changed, 126 insertions(+), 682 deletions(-) diff --git a/packages/site/package.json b/packages/site/package.json index 0a651233..29c6f7dc 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@coti-io/coti-ethers": "^1.0.3", - "@coti-io/coti-sdk-typescript": "^1.0.6", + "@coti-io/coti-sdk-typescript": "^1.0.8", "@metamask/providers": "^19.0.0", "@tanstack/react-query": "^5.65.1", "@types/react-router-dom": "^5.3.3", diff --git a/packages/site/src/config/snap.ts b/packages/site/src/config/snap.ts index 90dda7c2..2b3af9ff 100644 --- a/packages/site/src/config/snap.ts +++ b/packages/site/src/config/snap.ts @@ -1,5 +1,5 @@ const isSnapLocal = (): boolean => { - return import.meta.env.VITE_SNAP_ENV === 'local'; + return process.env.VITE_SNAP_ENV === 'local'; }; const DEFAULT_SNAP_ORIGIN = 'npm:@coti-io/coti-snap'; @@ -8,11 +8,11 @@ const DEFAULT_LOCAL_SNAP_URL = 'http://localhost:8080'; const buildSnapOrigin = (): string => { if (isSnapLocal()) { const localUrl = - import.meta.env.VITE_SNAP_LOCAL_URL ?? DEFAULT_LOCAL_SNAP_URL; + process.env.VITE_SNAP_LOCAL_URL ?? DEFAULT_LOCAL_SNAP_URL; return `local:${localUrl}`; } - return import.meta.env.VITE_SNAP_ORIGIN ?? DEFAULT_SNAP_ORIGIN; + return process.env.VITE_SNAP_ORIGIN ?? DEFAULT_SNAP_ORIGIN; }; export const defaultSnapOrigin = buildSnapOrigin(); diff --git a/packages/site/src/hooks/useRequestSnap.ts b/packages/site/src/hooks/useRequestSnap.ts index 21b03e3e..1c1c2a56 100644 --- a/packages/site/src/hooks/useRequestSnap.ts +++ b/packages/site/src/hooks/useRequestSnap.ts @@ -29,7 +29,7 @@ export const useRequestSnap = ( const useLocalFallback = shouldPreferLocalSnap() && snapId === defaultSnapOrigin; const localSnapId = useLocalFallback - ? `local:${import.meta.env.VITE_SNAP_LOCAL_URL ?? 'http://localhost:8080'}` + ? `local:${process.env.VITE_SNAP_LOCAL_URL || 'http://localhost:8080'}` : null; let snaps: Record | null = null; diff --git a/packages/site/src/hooks/useTokenOperations.ts b/packages/site/src/hooks/useTokenOperations.ts index d3253cc8..c274c6a4 100644 --- a/packages/site/src/hooks/useTokenOperations.ts +++ b/packages/site/src/hooks/useTokenOperations.ts @@ -23,47 +23,24 @@ import { fetchImageAsDataUri, fetchJsonWithIpfsFallback, } from '../utils/nftMetadata'; -import { useInvokeSnap } from './useInvokeSnap'; -const { decryptUint, decryptString, encodeKey, encrypt } = CotiSDK; -const decryptUint256 = (CotiSDK as { decryptUint256?: unknown }).decryptUint256 as - | ((ciphertext: unknown, userKey: string) => bigint) - | undefined; +const { + buildItUint256WithSigner, + decryptCtUint256, + decryptString, + decryptUint, + isCtUint256Shape, + isZeroCtUint256, + normalizeAesKey, +} = CotiSDK; const INSANE_BALANCE_BASE = 1000000000000n; -const BLOCK_SIZE = 16; -const CT_SIZE = 32; -const MAX_UINT256 = (1n << 256n) - 1n; - const PRIVATE_ERC20_TRANSFER_64 = 'transfer(address,(uint256,bytes))'; const PRIVATE_ERC20_TRANSFER_256 = 'transfer(address,((uint256,uint256),bytes))'; /** Fallback gas limit when estimation fails; MPC precompile chain can exceed default estimates. */ const CONFIDENTIAL_TRANSFER_GAS_LIMIT = 2_000_000n; /** Safety buffer on estimated gas (e.g. 105 = 5% extra). */ const GAS_ESTIMATE_BUFFER_PERCENT = 105n; -const ETH_SIGN_DISABLED_MESSAGE = - 'Raw signing is unavailable for this wallet. Use the COTI Snap or another signer that supports raw signatures.'; -const SNAP_RAW_SIGN_MESSAGE = - 'Raw signing requires the COTI Snap. Install/enable the Snap and approve the signing permission.'; - -type ItUint256 = { - ciphertext: { - ciphertextHigh: bigint; - ciphertextLow: bigint; - }; - signature: string; -}; - -const normalizeAesKey = (aesKey?: string): string | undefined => { - if (!aesKey) { - return undefined; - } - const trimmed = aesKey.startsWith('0x') ? aesKey.slice(2) : aesKey; - if (!/^[0-9a-fA-F]{32}$/.test(trimmed)) { - throw new Error('AES key must be a 32-hex-character string.'); - } - return trimmed.toLowerCase(); -}; const toBigInt = (value: unknown): bigint => { if (typeof value === 'bigint') { @@ -84,120 +61,6 @@ const toBigInt = (value: unknown): bigint => { return 0n; }; -const isEthSignUnavailable = (error: unknown): boolean => { - if (!error || typeof error !== 'object') { - return false; - } - const err = error as { code?: number; message?: string; data?: unknown }; - const code = - err.code ?? - (err.data && typeof err.data === 'object' - ? (err.data as { code?: number }).code - : undefined); - const message = err.message ?? ''; - return ( - code === -32601 && - typeof message === 'string' && - message.toLowerCase().includes('eth_sign') - ); -}; - -const toFixedBytes = (value: bigint, length: number): Uint8Array => { - const bytes = new Uint8Array(length); - let remaining = value; - for (let i = length - 1; i >= 0; i -= 1) { - bytes[i] = Number(remaining & 0xffn); - remaining >>= 8n; - } - return bytes; -}; - -const bytesToBigInt = (bytes: Uint8Array): bigint => { - let hex = ''; - for (const byte of bytes) { - hex += byte.toString(16).padStart(2, '0'); - } - return BigInt(`0x${hex}`); -}; - -const buildCiphertext128 = (plaintext: bigint, aesKey: string): Uint8Array => { - const keyBytes = encodeKey(aesKey); - const plaintextBytes = toFixedBytes(plaintext, BLOCK_SIZE); - const zeroBytes = new Uint8Array(BLOCK_SIZE); - const high = encrypt(keyBytes, zeroBytes); - const low = encrypt(keyBytes, plaintextBytes); - return new Uint8Array([ - ...high.ciphertext, - ...high.r, - ...low.ciphertext, - ...low.r, - ]); -}; - -const buildCiphertext256 = (plaintext: bigint, aesKey: string): Uint8Array => { - const keyBytes = encodeKey(aesKey); - const plaintextBytes = toFixedBytes(plaintext, CT_SIZE); - const high = encrypt(keyBytes, plaintextBytes.slice(0, BLOCK_SIZE)); - const low = encrypt(keyBytes, plaintextBytes.slice(BLOCK_SIZE)); - return new Uint8Array([ - ...high.ciphertext, - ...high.r, - ...low.ciphertext, - ...low.r, - ]); -}; - -const normalizeSignature = (signature: string): string => { - const sig = ethers.Signature.from(signature); - const vByte = sig.v === 27 ? 0 : sig.v === 28 ? 1 : sig.v; - return ethers.hexlify( - ethers.concat([sig.r, sig.s, new Uint8Array([vByte])]), - ); -}; - -const buildItUint256 = async ({ - value, - aesKey, - tokenAddress, - selector, - signerAddress, - signMessage, -}: { - value: bigint; - aesKey: string; - tokenAddress: string; - selector: string; - signerAddress: string; - signMessage: (message: Uint8Array) => Promise; -}): Promise => { - if (value < 0n || value > MAX_UINT256) { - throw new RangeError('Amount must fit within 256 bits.'); - } - const bitSize = value === 0n ? 0 : value.toString(2).length; - const ciphertextBytes = - bitSize <= 128 - ? buildCiphertext128(value, aesKey) - : buildCiphertext256(value, aesKey); - const ciphertextHigh = bytesToBigInt(ciphertextBytes.slice(0, CT_SIZE)); - const ciphertextLow = bytesToBigInt(ciphertextBytes.slice(CT_SIZE)); - - // Build raw message matching COTI SDK pattern: solidityPacked(address, address, bytes4, ciphertext) - // The MPC precompile expects signMessage() style signatures (with Ethereum prefix) - const message = ethers.solidityPacked( - ['address', 'address', 'bytes4', 'uint256', 'uint256'], - [signerAddress, tokenAddress, selector, ciphertextHigh, ciphertextLow], - ); - const messageBytes = ethers.getBytes(message); - - // Sign with personal_sign via signer.signMessage() - same as COTI SDK - const signature = await signMessage(messageBytes); - - return { - ciphertext: { ciphertextHigh, ciphertextLow }, - signature, - }; -}; - const normalizeDecimals = (decimals?: number): number => { if (decimals === undefined || decimals === null) { return 18; @@ -246,140 +109,12 @@ const ERC165_ABI = [ const PRIVATE_ERC20_64_INTERFACE_ID = '0x8409a9cf'; const PRIVATE_ERC20_256_INTERFACE_ID = '0xdfeb393e'; -const splitCt128 = (value: unknown): { high: bigint; low: bigint } | unknown => { - if ( - typeof value === 'object' && - value !== null && - 'high' in value && - 'low' in value - ) { - return value as { high: bigint; low: bigint }; - } - if (typeof value === 'bigint') { - const mask = (1n << 64n) - 1n; - return { high: value >> 64n, low: value & mask }; - } - return value; -}; - -const normalizeItUint256ForAbi = (value: any): any => { - const cipher = value?.ciphertext ?? value; - if ( - cipher?.high?.high !== undefined && - cipher?.high?.low !== undefined && - cipher?.low?.high !== undefined && - cipher?.low?.low !== undefined - ) { - return value; - } - - const high = splitCt128(cipher?.ciphertextHigh ?? cipher?.high); - const low = splitCt128(cipher?.ciphertextLow ?? cipher?.low); - - if ( - high && - low && - typeof high === 'object' && - typeof low === 'object' && - 'high' in high && - 'low' in high && - 'high' in low && - 'low' in low - ) { - return { - ciphertext: { high, low }, - signature: value?.signature ?? cipher?.signature, - }; - } - - return value; -}; - -const decryptCtUint256 = (ciphertext: any, aesKey: string): bigint => { - if ( - ciphertext?.high?.high !== undefined && - ciphertext?.high?.low !== undefined && - ciphertext?.low?.high !== undefined && - ciphertext?.low?.low !== undefined - ) { - const d1 = decryptUint(toBigInt(ciphertext.high.high), aesKey); - const d2 = decryptUint(toBigInt(ciphertext.high.low), aesKey); - const d3 = decryptUint(toBigInt(ciphertext.low.high), aesKey); - const d4 = decryptUint(toBigInt(ciphertext.low.low), aesKey); - return (d1 << 192n) + (d2 << 128n) + (d3 << 64n) + d4; - } - - // Support named properties (ciphertextHigh/ciphertextLow) or - // positional access ([0]/[1]) from ethers.js Result tuples - const high = ciphertext?.ciphertextHigh ?? ciphertext?.[0]; - const low = ciphertext?.ciphertextLow ?? ciphertext?.[1]; - - if (decryptUint256 && high !== undefined && low !== undefined) { - return decryptUint256( - { - ciphertextHigh: toBigInt(high), - ciphertextLow: toBigInt(low), - }, - aesKey, - ); - } - - return 0n; -}; - -const isZeroCtUint256 = (ciphertext: any): boolean => { - if (!ciphertext) { - return false; - } - if (isZeroValue(ciphertext)) { - return true; - } - if ( - ciphertext?.high?.high !== undefined && - ciphertext?.high?.low !== undefined && - ciphertext?.low?.high !== undefined && - ciphertext?.low?.low !== undefined - ) { - return ( - isZeroValue(ciphertext.high.high) && - isZeroValue(ciphertext.high.low) && - isZeroValue(ciphertext.low.high) && - isZeroValue(ciphertext.low.low) - ); - } - - // Support named properties or positional access from ethers.js Result tuples - const high = ciphertext?.ciphertextHigh ?? ciphertext?.[0]; - const low = ciphertext?.ciphertextLow ?? ciphertext?.[1]; - - if (high !== undefined && low !== undefined) { - return isZeroValue(high) && isZeroValue(low); - } - - return false; -}; - const isInsaneDecryptedValue = (value: bigint, decimals?: number): boolean => { const safeDecimals = normalizeDecimals(decimals); const threshold = INSANE_BALANCE_BASE * 10n ** BigInt(safeDecimals); return value > threshold; }; -const isCtUint256Shape = (value: any): boolean => { - if (!value || typeof value !== 'object') { - return false; - } - const hasNested = - value?.high?.high !== undefined && - value?.high?.low !== undefined && - value?.low?.high !== undefined && - value?.low?.low !== undefined; - const hasFlat = - value?.ciphertextHigh !== undefined && - value?.ciphertextLow !== undefined; - return hasNested || hasFlat; -}; - const probeConfidentialVersion256 = async ( tokenAddress: string, provider: any, @@ -662,7 +397,6 @@ export class TokenOperationError extends Error { * @returns An object containing loading state, error state, and functions for token operations. */ export const useTokenOperations = (provider: BrowserProvider) => { - const invokeSnap = useInvokeSnap(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -712,76 +446,6 @@ export const useTokenOperations = (provider: BrowserProvider) => { [getBrowserProvider], ); - const signDigestWithSnap = useCallback( - async (digest: string, signerAddress: string): Promise => { - try { - const result = await invokeSnap({ - method: 'sign-raw-256', - params: { - messageHex: digest, - signerAddress, - }, - }); - if (result === null) { - throw new Error('User rejected signature'); - } - if (typeof result === 'string') { - return result; - } - if (result && typeof result === 'object' && 'signature' in result) { - const signature = (result as { signature?: string }).signature; - return typeof signature === 'string' ? signature : null; - } - return null; - } catch (error) { - if ( - error instanceof Error && - /user rejected|action_rejected|denied/i.test(error.message) - ) { - throw error; - } - throw new Error(SNAP_RAW_SIGN_MESSAGE); - } - }, - [invokeSnap], - ); - - const getRawSignature = useCallback( - async ({ - digest, - signerAddress, - provider: browserProvider, - }: { - digest: string; - signerAddress: string; - provider: BrowserProvider; - }): Promise => { - const isMetaMask = Boolean( - (window.ethereum as any)?.isMetaMask ?? false, - ); - if (isMetaMask) { - const snapSignature = await signDigestWithSnap( - digest, - signerAddress, - ); - if (!snapSignature) { - throw new Error(SNAP_RAW_SIGN_MESSAGE); - } - return snapSignature; - } - - try { - return await browserProvider.send('eth_sign', [signerAddress, digest]); - } catch (error) { - if (isEthSignUnavailable(error)) { - throw new Error(ETH_SIGN_DISABLED_MESSAGE); - } - throw error; - } - }, - [signDigestWithSnap], - ); - const getTokenConfidentialStatus = useCallback( async ( tokenAddress: string, @@ -926,7 +590,9 @@ export const useTokenOperations = (provider: BrowserProvider) => { ethers.toBigInt(amount), ); } else if (confidential) { - const normalizedAesKey = normalizeAesKey(aesKey); + const normalizedAesKey = aesKey + ? normalizeAesKey(aesKey) + : undefined; if (!normalizedAesKey) { throw new Error('AES key is required for private ERC20 transfer'); } @@ -946,11 +612,11 @@ export const useTokenOperations = (provider: BrowserProvider) => { if (confidentialVersion === 256) { const selectorHex = getSelector(PRIVATE_ERC20_TRANSFER_256); const amountBigInt = ethers.toBigInt(amount); - const itUint256 = await buildItUint256({ + const itUint256 = await buildItUint256WithSigner({ value: amountBigInt, aesKey: normalizedAesKey, - tokenAddress, - selector: selectorHex, + contractAddress: tokenAddress, + functionSelector: selectorHex, signerAddress, signMessage: (message) => signer.signMessage(message), }); @@ -1063,7 +729,9 @@ export const useTokenOperations = (provider: BrowserProvider) => { const browserProvider = getBrowserProvider(); const signer = await browserProvider.getSigner(); const signerAddress = await signer.getAddress(); - const normalizedAesKey = normalizeAesKey(aesKey); + const normalizedAesKey = aesKey + ? normalizeAesKey(aesKey) + : undefined; console.log(`[decryptERC20Balance] signer=${signerAddress}, hasNormalizedAesKey=${!!normalizedAesKey}`); if (confidential) { diff --git a/packages/site/src/utils/snap.ts b/packages/site/src/utils/snap.ts index cc0b9dcd..8db6aa93 100644 --- a/packages/site/src/utils/snap.ts +++ b/packages/site/src/utils/snap.ts @@ -5,6 +5,7 @@ * @returns True if it's a local Snap, or false otherwise. */ import type { GetSnapsResponse } from '../types'; +import { defaultSnapOrigin } from '../config/snap'; export const isLocalSnap = (snapId: string) => snapId.startsWith('local:'); @@ -17,11 +18,8 @@ const isLocalHost = (): boolean => { }; export const shouldPreferLocalSnap = (): boolean => { - if (typeof import.meta !== 'undefined') { - const env = import.meta.env as { VITE_SNAP_ENV?: string } | undefined; - if (env?.VITE_SNAP_ENV === 'local') { - return true; - } + if (defaultSnapOrigin.startsWith('local:')) { + return true; } return isLocalHost(); }; diff --git a/packages/site/vite.config.ts b/packages/site/vite.config.ts index a68a914e..ede02a76 100644 --- a/packages/site/vite.config.ts +++ b/packages/site/vite.config.ts @@ -2,7 +2,7 @@ import react from '@vitejs/plugin-react'; import { execSync } from 'child_process'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import svgr from 'vite-plugin-svgr'; const getGitCommitHash = () => { @@ -43,7 +43,10 @@ const getVersions = () => { }; // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ''); + + return { build: { minify: false, sourcemap: true, @@ -51,6 +54,11 @@ export default defineConfig({ define: { 'process.env.VITE_GIT_COMMIT': JSON.stringify(getVersions().gitCommit), 'process.env.VITE_SNAP_VERSION': JSON.stringify(getVersions().snapVersion), + 'process.env.VITE_SNAP_ENV': JSON.stringify(env.VITE_SNAP_ENV ?? ''), + 'process.env.VITE_SNAP_ORIGIN': JSON.stringify(env.VITE_SNAP_ORIGIN ?? ''), + 'process.env.VITE_SNAP_LOCAL_URL': JSON.stringify( + env.VITE_SNAP_LOCAL_URL ?? '', + ), }, server: { port: 8000, @@ -111,4 +119,5 @@ export default defineConfig({ include: '**/*.svg', }), ], + }; }); diff --git a/packages/snap/package.json b/packages/snap/package.json index 355d9ea3..9201bc04 100644 --- a/packages/snap/package.json +++ b/packages/snap/package.json @@ -44,7 +44,7 @@ "test": "jest" }, "dependencies": { - "@coti-io/coti-sdk-typescript": "^1.0.6", + "@coti-io/coti-sdk-typescript": "^1.0.8", "@metamask/snaps-sdk": "^6.13.0", "ethers": "^6.13.4", "jest-transform-stub": "^2.0.0" diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index c0636d6b..adb103dc 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/coti-io/coti-snap.git" }, "source": { - "shasum": "Nf+wy7VlZ/cUZuOxS9MvbAqyGBcPj/eZ4G1WyRVaRPs=", + "shasum": "CsE9tecsEIVm4Ac5Q+oVxO3NMzbzaZeCZrzfSrP5OI0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/index.tsx b/packages/snap/src/index.tsx index 9a3744d6..a756253a 100644 --- a/packages/snap/src/index.tsx +++ b/packages/snap/src/index.tsx @@ -3,14 +3,19 @@ /* eslint-disable @typescript-eslint/prefer-optional-chain */ /* eslint-disable @typescript-eslint/switch-exhaustiveness-check */ import { - decodeUint, decrypt, decryptUint, decryptUint256, encodeKey, - encodeString, - encodeUint, encrypt, + encryptUint, + encryptUint256, + normalizeAesKey, + normalizeCtPayload, +} from '@coti-io/coti-sdk-typescript'; +import type { + SerializableCtUint, + SerializableCtUint256, } from '@coti-io/coti-sdk-typescript'; import { UserInputEventType } from '@metamask/snaps-sdk'; import type { @@ -79,9 +84,6 @@ const isHex32 = (value: string): boolean => const isBytes4Hex = (value: string): boolean => /^0x[0-9a-fA-F]{8}$/.test(value); -const isAes128HexKey = (value: string): boolean => - /^[0-9a-fA-F]{32}$/.test(value); - const SET_AES_KEY_ALLOWED_ORIGINS = new Set([ 'https://metamask.coti.io', 'https://dev.metamask.coti.io', @@ -99,18 +101,7 @@ type EncryptedPayload = { r: Record; }; -type SerializableCt = - | string - | number - | bigint - | { - ciphertextHigh?: string | number | bigint; - ciphertextLow?: string | number | bigint; - }; - -type NormalizedCt = - | bigint - | { ciphertextHigh: bigint; ciphertextLow: bigint }; +type SerializableCt = SerializableCtUint | SerializableCtUint256; type TypedDecryptParams = { type?: 'ctUint64' | 'ctUint256'; @@ -158,39 +149,6 @@ const parseEncryptedPayload = (encryptedValue: string): EncryptedPayload => { } }; -const toBigInt = (value: string | number | bigint | undefined): bigint => { - if (value === undefined || value === null) { - throw new Error('Missing bigint value.'); - } - return BigInt(value); -}; - -/** - * Normalizes a JSON-RPC-safe confidential uint payload into the bigint shapes expected by - * SDK decrypt helpers. Exported so the parser can be tested without the Snap sandbox. - */ -export const normalizeCtPayload = ( - value: SerializableCt, - type: 'ctUint64' | 'ctUint256', -): NormalizedCt => { - if (type === 'ctUint64') { - return toBigInt(value as string | number | bigint); - } - - if (!value || typeof value !== 'object') { - throw new Error('Invalid ctUint256 payload.'); - } - - if ('ciphertextHigh' in value && 'ciphertextLow' in value) { - return { - ciphertextHigh: toBigInt(value.ciphertextHigh), - ciphertextLow: toBigInt(value.ciphertextLow), - }; - } - - throw new Error('Invalid ctUint256 payload.'); -}; - const getTypedValues = ( params: { value?: Value; values?: Value[] }, ): Value[] | null => { @@ -206,46 +164,16 @@ const getTypedValues = ( const isArrayRequest = (params: { values?: unknown[] }): boolean => Array.isArray(params.values); -const encryptUint64Value = ( - plaintext: string | number | bigint, - aesKey: string, -): string => { - const plaintextBigInt = BigInt(plaintext); - if (plaintextBigInt >= 2n ** 64n) { - throw new RangeError('Plaintext size must be 64 bits or smaller.'); - } - const { ciphertext, r } = encrypt( - encodeKey(aesKey), - encodeUint(plaintextBigInt), - ); - return decodeUint(new Uint8Array([...ciphertext, ...r])).toString(); -}; - -const encryptUint256Value = ( - plaintext: string | number | bigint, - aesKey: string, -): { ciphertextHigh: string; ciphertextLow: string } => { - const plaintextBigInt = BigInt(plaintext); - if (plaintextBigInt >= 2n ** 256n) { - throw new RangeError('Plaintext size must be 256 bits or smaller.'); - } - - const mask128 = (1n << 128n) - 1n; - const highPlaintext = plaintextBigInt >> 128n; - const lowPlaintext = plaintextBigInt & mask128; - const keyBytes = encodeKey(aesKey); - const high = encrypt(keyBytes, encodeUint(highPlaintext)); - const low = encrypt(keyBytes, encodeUint(lowPlaintext)); - - return { - ciphertextHigh: decodeUint( - new Uint8Array([...high.ciphertext, ...high.r]), - ).toString(), - ciphertextLow: decodeUint( - new Uint8Array([...low.ciphertext, ...low.r]), - ).toString(), - }; -}; +const serializeCtUint256 = ({ + ciphertextHigh, + ciphertextLow, +}: { + ciphertextHigh: bigint; + ciphertextLow: bigint; +}): { ciphertextHigh: string; ciphertextLow: string } => ({ + ciphertextHigh: ciphertextHigh.toString(), + ciphertextLow: ciphertextLow.toString(), +}); const encryptTypedValues = ( params: TypedEncryptParams, @@ -272,8 +200,8 @@ const encryptTypedValues = ( const encrypted = values.map((value) => { try { return params.type === 'uint64' - ? encryptUint64Value(value, aesKey) - : encryptUint256Value(value, aesKey); + ? encryptUint(BigInt(value), aesKey).toString() + : serializeCtUint256(encryptUint256(BigInt(value), aesKey)); } catch { return null; } @@ -786,7 +714,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ if (encryptResult) { return JSON.stringify( - encrypt(encodeKey(getState.aesKey), encodeString(textToEncrypt)), + encrypt(encodeKey(getState.aesKey), new TextEncoder().encode(textToEncrypt)), ); } @@ -1171,7 +1099,10 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ return null; } - if (!isAes128HexKey(newUserAesKey)) { + let normalizedNewUserAesKey: string; + try { + normalizedNewUserAesKey = normalizeAesKey(newUserAesKey); + } catch { await snap.request({ method: 'snap_dialog', params: { @@ -1219,7 +1150,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ await setStateByChainIdAndAddress( { ...currentStateForSet, - aesKey: newUserAesKey, + aesKey: normalizedNewUserAesKey, }, setChainId, ); diff --git a/packages/snap/src/test/onRpcRequest.test.tsx b/packages/snap/src/test/onRpcRequest.test.tsx index 05fa1a10..9205ec30 100644 --- a/packages/snap/src/test/onRpcRequest.test.tsx +++ b/packages/snap/src/test/onRpcRequest.test.tsx @@ -4,12 +4,11 @@ import { decryptUint256, encodeKey, encrypt, - encodeString, + normalizeCtPayload, } from '@coti-io/coti-sdk-typescript'; import { installSnap } from '@metamask/snaps-jest'; import type { SnapConfirmationInterface } from '@metamask/snaps-jest'; import { Box, Text, Heading } from '@metamask/snaps-sdk/jsx'; -import { normalizeCtPayload } from '../index'; const AUTHORIZED_SET_KEY_ORIGIN = 'https://metamask.coti.io'; const TEST_AES_KEY = '00112233445566778899aabbccddeeff'; @@ -298,7 +297,7 @@ describe('onRpcRequest', () => { const origin = 'https://example-dapp.io'; const encryptedValue = JSON.stringify( - encrypt(encodeKey(aesKey), encodeString('Hello, encrypt!')), + encrypt(encodeKey(aesKey), new TextEncoder().encode('Hello, encrypt!')), ); await setAesKeyWithConfirmation(request, aesKey); @@ -471,20 +470,26 @@ describe('onRpcRequest', () => { result: { value: { ciphertext: { - high: { high: string; low: string }; - low: { high: string; low: string }; + ciphertextHigh: string; + ciphertextLow: string; }; - signature: [[string, string], [string, string]]; + signature: string; }; }; }; - expect(result.value.ciphertext.high.high).toEqual(expect.any(String)); - expect(result.value.ciphertext.high.low).toEqual(expect.any(String)); - expect(result.value.ciphertext.low.high).toEqual(expect.any(String)); - expect(result.value.ciphertext.low.low).toEqual(expect.any(String)); - expect(result.value.signature[0][0]).toMatch(/^0x[0-9a-f]+$/u); - expect(result.value.signature[1][1]).toMatch(/^0x[0-9a-f]+$/u); + expect(result.value.ciphertext.ciphertextHigh).toEqual(expect.any(String)); + expect(result.value.ciphertext.ciphertextLow).toEqual(expect.any(String)); + expect(result.value.signature).toMatch(/^0x[0-9a-f]{130}$/u); + expect( + decryptUint256( + { + ciphertextHigh: BigInt(result.value.ciphertext.ciphertextHigh), + ciphertextLow: BigInt(result.value.ciphertext.ciphertextLow), + }, + TEST_AES_KEY, + ), + ).toBe(100000000n); }); it('deletes AES key with user confirmation', async () => { diff --git a/packages/snap/src/test/tokenUtils.test.tsx b/packages/snap/src/test/tokenUtils.test.tsx index 1ca6491f..9ce5aeb4 100644 --- a/packages/snap/src/test/tokenUtils.test.tsx +++ b/packages/snap/src/test/tokenUtils.test.tsx @@ -7,8 +7,14 @@ import * as tokenUtils from '../utils/token'; jest.mock('ethers'); jest.mock('@coti-io/coti-sdk-typescript', () => ({ + decryptCtUint256: jest.fn(), decryptString: jest.fn(), decryptUint: jest.fn(), + isCtUint256Shape: jest.fn(), + isZeroCtUint256: jest.fn(() => false), + normalizeAesKey: jest.fn((aesKey: string) => + aesKey.startsWith('0x') ? aesKey.slice(2).toLowerCase() : aesKey.toLowerCase(), + ), })); const { getStateByChainIdAndAddress, diff --git a/packages/snap/src/utils/itUint.ts b/packages/snap/src/utils/itUint.ts index b356975b..8fc706b1 100644 --- a/packages/snap/src/utils/itUint.ts +++ b/packages/snap/src/utils/itUint.ts @@ -1,95 +1,36 @@ -import { - decodeUint, - encodeKey, - encodeUint, - encrypt, -} from '@coti-io/coti-sdk-typescript'; -import { ethers } from 'ethers'; - -type ItUint64 = { - ciphertext: string; - signature: string; -}; +import { prepareIT256 } from '@coti-io/coti-sdk-typescript'; +import { BaseWallet, Wallet, hexlify, solidityPackedKeccak256 } from 'ethers'; export type ItUint256 = { ciphertext: { - high: { high: string; low: string }; - low: { high: string; low: string }; + ciphertextHigh: string; + ciphertextLow: string; }; - signature: [[string, string], [string, string]]; -}; - -const buildSignature = ( - signerAddress: string, - contractAddress: string, - selector: string, - ciphertext: bigint, - privateKey: string, -): string => { - const digest = ethers.solidityPackedKeccak256( - ['address', 'address', 'bytes4', 'uint256'], - [signerAddress, contractAddress, selector, ciphertext], - ); - const signingKey = new ethers.SigningKey(privateKey); - const sig = signingKey.sign(digest); - const vByte = sig.v === 27 ? '0x00' : '0x01'; - return ethers.hexlify(ethers.concat([sig.r, sig.s, vByte])); + signature: string; }; -const buildItUint64 = ( +export const buildItUint256 = ( plaintext: bigint, aesKey: string, - wallet: ethers.Wallet, + wallet: BaseWallet, contractAddress: string, selector: string, -): ItUint64 => { - if (plaintext >= 2n ** 64n) { - throw new RangeError('Plaintext size must be 64 bits or smaller.'); - } - - const plaintextBytes = encodeUint(plaintext); - const keyBytes = encodeKey(aesKey); - const { ciphertext, r } = encrypt(keyBytes, plaintextBytes); - const ct = new Uint8Array([...ciphertext, ...r]); - const ctInt = decodeUint(ct); - const signature = buildSignature( - wallet.address, +): ItUint256 => { + const it = prepareIT256( + plaintext, + { wallet, userKey: aesKey } as unknown as Parameters< + typeof prepareIT256 + >[1], contractAddress, selector, - ctInt, - wallet.privateKey, ); - return { ciphertext: ctInt.toString(), signature }; -}; - -export const buildItUint256 = ( - plaintext: bigint, - aesKey: string, - wallet: ethers.Wallet, - contractAddress: string, - selector: string, -): ItUint256 => { - const mask64 = (1n << 64n) - 1n; - const d1 = (plaintext >> 192n) & mask64; - const d2 = (plaintext >> 128n) & mask64; - const d3 = (plaintext >> 64n) & mask64; - const d4 = plaintext & mask64; - - const it1 = buildItUint64(d1, aesKey, wallet, contractAddress, selector); - const it2 = buildItUint64(d2, aesKey, wallet, contractAddress, selector); - const it3 = buildItUint64(d3, aesKey, wallet, contractAddress, selector); - const it4 = buildItUint64(d4, aesKey, wallet, contractAddress, selector); - return { ciphertext: { - high: { high: it1.ciphertext, low: it2.ciphertext }, - low: { high: it3.ciphertext, low: it4.ciphertext }, + ciphertextHigh: it.ciphertext.ciphertextHigh.toString(), + ciphertextLow: it.ciphertext.ciphertextLow.toString(), }, - signature: [ - [it1.signature, it2.signature], - [it3.signature, it4.signature], - ], + signature: hexlify(it.signature), }; }; @@ -97,10 +38,10 @@ export const deriveSnapWallet = ( aesKey: string, account: string, chainId: string, -): ethers.Wallet => { - const seed = ethers.solidityPackedKeccak256( +): BaseWallet => { + const seed = solidityPackedKeccak256( ['string', 'address', 'string', 'string'], ['coti-snap-encryption', account, chainId, aesKey], ); - return new ethers.Wallet(seed); + return new Wallet(seed); }; diff --git a/packages/snap/src/utils/token.ts b/packages/snap/src/utils/token.ts index db6925b5..b224ce20 100644 --- a/packages/snap/src/utils/token.ts +++ b/packages/snap/src/utils/token.ts @@ -1,5 +1,11 @@ import type { ctUint } from '@coti-io/coti-sdk-typescript'; import * as CotiSDK from '@coti-io/coti-sdk-typescript'; +import { + decryptCtUint256, + isCtUint256Shape, + isZeroCtUint256, + normalizeAesKey, +} from '@coti-io/coti-sdk-typescript'; import { Contract, ethers, formatUnits, ZeroAddress } from 'ethers'; import { @@ -21,15 +27,6 @@ import erc721ConfidentialAbi from '../abis/ERC721Confidential.json'; import type { Tokens } from '../types'; import { TokenViewSelector } from '../types'; -const decryptUint256 = (CotiSDK as { decryptUint256?: unknown }).decryptUint256 as - | ((ciphertext: unknown, userKey: string) => bigint) - | undefined; - -const normalizeAesKey = (aesKey: string): string => { - const trimmed = aesKey.startsWith('0x') ? aesKey.slice(2) : aesKey; - return trimmed.toLowerCase(); -}; - const ERC165_ABI = [ 'function supportsInterface(bytes4 interfaceId) external view returns (bool)', ]; @@ -457,42 +454,6 @@ const isZeroValue = (value: unknown): boolean => { return false; }; -type CtUint256Like = Record & Record; - -const isZeroCtUint256 = (ciphertext: unknown): boolean => { - if (!ciphertext) { - return false; - } - if (isZeroValue(ciphertext)) { - return true; - } - const c = ciphertext as CtUint256Like; - const highObj = c?.high as Record | undefined; - const lowObj = c?.low as Record | undefined; - if ( - highObj?.high !== undefined && - highObj?.low !== undefined && - lowObj?.high !== undefined && - lowObj?.low !== undefined - ) { - return ( - isZeroValue(highObj.high) && - isZeroValue(highObj.low) && - isZeroValue(lowObj.high) && - isZeroValue(lowObj.low) - ); - } - - const high = c?.ciphertextHigh ?? c?.[0]; - const low = c?.ciphertextLow ?? c?.[1]; - - if (high !== undefined && low !== undefined) { - return isZeroValue(high) && isZeroValue(low); - } - - return false; -}; - const isInsaneDecryptedValue = ( value: bigint, decimals?: string | number | null, @@ -502,28 +463,6 @@ const isInsaneDecryptedValue = ( return value > threshold; }; -const isCtUint256Shape = (value: unknown): boolean => { - if (!value || typeof value !== 'object') { - return false; - } - const v = value as Record & Record; - const hasNested = - v?.high !== undefined && - v?.low !== undefined && - typeof v.high === 'object' && - v.high !== null && - typeof v.low === 'object' && - v.low !== null && - (v.high as Record)?.high !== undefined && - (v.high as Record)?.low !== undefined && - (v.low as Record)?.high !== undefined && - (v.low as Record)?.low !== undefined; - const hasFlat = - v?.ciphertextHigh !== undefined && v?.ciphertextLow !== undefined; - const hasPositional = v?.[0] !== undefined && v?.[1] !== undefined; - return hasNested || hasFlat || hasPositional; -}; - /** * Probes whether the token contract supports 256-bit confidential balance. * @param address - Token contract address. @@ -570,67 +509,14 @@ export const decryptBalance = ( const normalizedKey = normalizeAesKey(aesKey); try { if (variant === 256) { - const nested = balance as { - high?: { high?: bigint; low?: bigint }; - low?: { high?: bigint; low?: bigint }; - }; - if ( - nested?.high?.high !== undefined && - nested?.high?.low !== undefined && - nested?.low?.high !== undefined && - nested?.low?.low !== undefined - ) { - if (isZeroCtUint256(balance)) { - return 0n; - } - const d1 = CotiSDK.decryptUint(nested.high.high, normalizedKey); - const d2 = CotiSDK.decryptUint(nested.high.low, normalizedKey); - const d3 = CotiSDK.decryptUint(nested.low.high, normalizedKey); - const d4 = CotiSDK.decryptUint(nested.low.low, normalizedKey); - const decrypted = (d1 << 192n) + (d2 << 128n) + (d3 << 64n) + d4; - if (isInsaneDecryptedValue(decrypted, decimals)) { - return null; - } - return decrypted; + if (isZeroCtUint256(balance)) { + return 0n; } - - // Support named properties (ciphertextHigh/ciphertextLow) or - // positional access ([0]/[1]) from ethers.js Result tuples - const high = (balance as any)?.ciphertextHigh ?? (balance as any)?.[0]; - const low = (balance as any)?.ciphertextLow ?? (balance as any)?.[1]; - - if (high !== undefined && low !== undefined) { - if (isZeroCtUint256(balance)) { - return 0n; - } - if (decryptUint256) { - const decrypted = decryptUint256( - { - ciphertextHigh: - typeof high === 'bigint' ? high : BigInt(high), - ciphertextLow: - typeof low === 'bigint' ? low : BigInt(low), - }, - normalizedKey, - ); - if (isInsaneDecryptedValue(decrypted, decimals)) { - return null; - } - return decrypted; - } - } - - if (decryptUint256) { - if (isZeroCtUint256(balance)) { - return 0n; - } - const decrypted = decryptUint256(balance, normalizedKey); - if (isInsaneDecryptedValue(decrypted, decimals)) { - return null; - } - return decrypted; + const decrypted = decryptCtUint256(balance, normalizedKey); + if (isInsaneDecryptedValue(decrypted, decimals)) { + return null; } - return null; + return decrypted; } if (isZeroValue(balance)) { return 0n; diff --git a/yarn.lock b/yarn.lock index 33545b90..67010c22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1436,12 +1436,12 @@ __metadata: languageName: node linkType: hard -"@coti-io/coti-sdk-typescript@npm:^1.0.6": - version: 1.0.6 - resolution: "@coti-io/coti-sdk-typescript@npm:1.0.6" +"@coti-io/coti-sdk-typescript@npm:^1.0.8": + version: 1.0.8 + resolution: "@coti-io/coti-sdk-typescript@npm:1.0.8" dependencies: node-forge: ^1.3.1 - checksum: 90468e62c63516b987cba0aa41380a764c0a105d345ecf73f47eaf1a338ba18f3a84457e688979f65765cd2f48965cccfda44971156a9a9984800096a66520c7 + checksum: 3bf6bcbd6c971f0f2f91bae31b251b4b472ef34f4f6c494904a84616fca8a0465369ad566d734b2ecb271b5e5f9f5bc2fcd166acd9f366da81f878bb8040bbe3 languageName: node linkType: hard @@ -1449,7 +1449,7 @@ __metadata: version: 0.0.0-use.local resolution: "@coti-io/coti-snap@workspace:packages/snap" dependencies: - "@coti-io/coti-sdk-typescript": ^1.0.6 + "@coti-io/coti-sdk-typescript": ^1.0.8 "@jest/globals": ^29.5.0 "@metamask/auto-changelog": ^3.4.4 "@metamask/eslint-config": ^13.0.0 @@ -14118,7 +14118,7 @@ __metadata: resolution: "site@workspace:packages/site" dependencies: "@coti-io/coti-ethers": ^1.0.3 - "@coti-io/coti-sdk-typescript": ^1.0.6 + "@coti-io/coti-sdk-typescript": ^1.0.8 "@eslint/js": ^9.17.0 "@metamask/providers": ^19.0.0 "@tanstack/react-query": ^5.65.1