Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/components/transaction/transaction.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ <h5>{{ aggregatedDetails.totalFees.toFixed() | feeConverter : { protocol: protoc
[interactionData]="rawdata"
[hideNetwork]="true"
></airgap-from-to>
<a *ngIf="airGapTx.data" (click)="toggleData(airGapTx)">{{ rawData.has(airGapTx) ? 'Revert to Raw' : 'Tap to parse data' }}</a>
</ng-container>
</ng-container>
31 changes: 31 additions & 0 deletions src/app/components/transaction/transaction.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { IAirGapTransaction, ProtocolSymbols } from '@airgap/coinlib-core'
import { Component, Input, OnInit } from '@angular/core'
import { Observable } from 'rxjs'
import { ContactsService } from 'src/app/services/contacts/contacts.service'
import { parseERC20Data } from 'src/app/utils/erc20-data-parser'

import { AggregatedDetails, TransactionStore } from './transaction.store'

Expand All @@ -21,6 +22,7 @@ export class TransactionComponent implements OnInit {
public protocolIdentifier$: Observable<ProtocolSymbols | undefined>
public airGapTxs$: Observable<IAirGapTransaction[]>
public aggregatedDetails$: Observable<AggregatedDetails | undefined>
public readonly rawData: Map<IAirGapTransaction, [string, string]> = new Map()

constructor(private readonly store: TransactionStore, private readonly contactsService: ContactsService) {
this.protocolIdentifier$ = this.store.selectProtocolIdentifier()
Expand All @@ -40,6 +42,35 @@ export class TransactionComponent implements OnInit {
this.store.setAirGapTxs(this.airGapTxs)
}

public toggleData(tx: IAirGapTransaction): void {
const raw: [string, string] | undefined = this.rawData.get(tx)
if (raw !== undefined) {
const restoredTx: IAirGapTransaction = { ...tx, data: raw[0], to: [raw[1], ...tx.to.slice(1)] }
this.rawData.delete(tx)
this.replaceTransaction(tx, restoredTx)

return
}

const parsed: [string, string] | undefined = parseERC20Data(tx.data, tx.to[0])
if (parsed === undefined) {
return
}

const parsedTx: IAirGapTransaction = { ...tx, data: parsed[0], to: [parsed[1], ...tx.to.slice(1)] }
this.rawData.set(parsedTx, [tx.data, tx.to[0]])
this.replaceTransaction(tx, parsedTx)
}

private replaceTransaction(current: IAirGapTransaction, replacement: IAirGapTransaction): void {
if (this.airGapTxs === undefined) {
return
}

this.airGapTxs[this.airGapTxs.indexOf(current)] = replacement
this.store.setAirGapTxs([...this.airGapTxs])
}

private async setAddressNames() {
const isBookenabled = await this.contactsService.isBookEnabled()
if (isBookenabled) {
Expand Down
81 changes: 81 additions & 0 deletions src/app/utils/erc20-data-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { parseERC20Data } from './erc20-data-parser'

/* This file is just for unit-test. */
describe('ERC20 data parser', () => {
const address: string = '0000000000000000000000005f57ed965700eb8693c1ecf0b3682115939bb1fe'
const amount: string = '00000000000000000000000000000000000000000000000000000000000f4240'
const usdc: string = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'
const usdt: string = '0xdac17f958d2ee523a2206206994597c13d831ec7'

it('parses transfer data', () => {
expect(parseERC20Data(`0xa9059cbb${address}${amount}`, usdc)).toEqual([
'transfer(to: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: 1.000000 USDC)',
`USDC (${usdc})`
])
})

it('uses USDC decimals for Polygon USDC', () => {
const polygonUsdc: string = '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359'
expect(parseERC20Data(`0xa9059cbb${address}${amount}`, polygonUsdc)).toEqual([
'transfer(to: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: 1.000000 USDC (POL))',
`USDC (POL) (${polygonUsdc})`
])
})

it('parses approve data', () => {
expect(parseERC20Data(`095ea7b3${address}${amount}`, usdt)).toEqual([
'approve(spender: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: 1.000000 USDT)',
`USDT (${usdt})`
])
})

it('identifies effectively infinite amounts', () => {
expect(parseERC20Data(`095ea7b3${address}${'f'.repeat(64)}`, usdt)).toEqual([
'approve(spender: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: Infinite)',
`USDT (${usdt})`
])
expect(parseERC20Data(`a9059cbb${address}${'f'.repeat(64)}`, usdt)).toEqual([
'transfer(to: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: Infinite)',
`USDT (${usdt})`
])
})

it('parses transferFrom data', () => {
expect(parseERC20Data(`23b872dd${address}${address}${amount}`, usdc)).toEqual([
'transferFrom(from: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, to: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: 1.000000 USDC)',
`USDC (${usdc})`
])
})

it('parses known 18-decimal tokens', () => {
const weth: string = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'
const oneWeth: string = '0000000000000000000000000000000000000000000000000de0b6b3a7640000'
expect(parseERC20Data(`a9059cbb${address}${oneWeth}`, weth)).toEqual([
'transfer(to: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: 1.000000000000000000 WETH)',
`WETH (${weth})`
])
})

it('labels known inner destinations without hiding their address', () => {
const permit2: string = '000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3'
expect(parseERC20Data(`095ea7b3${permit2}${amount}`, usdc)).toEqual([
'approve(spender: Uniswap Permit2 (0x000000000022d473030f116ddee9f6b43ac78ba3), amount: 1.000000 USDC)',
`USDC (${usdc})`
])
})

it('does not parse unknown or malformed data', () => {
expect(parseERC20Data('0xdeadbeef', usdc)).toBeUndefined()
expect(parseERC20Data(`0xa9059cbb${address}${amount}`, '0xunknown')).toBeUndefined()
expect(parseERC20Data(`0xa9059cbb${address}${amount}00`, usdc)).toBeUndefined()
expect(parseERC20Data(`0xa9059cbb${'1'.repeat(64)}${amount}`, usdc)).toBeUndefined()
})

it('keeps exact units for an unknown token contract', () => {
const unknown: string = '0x3c0000000000000000000000000000000000d8cc'
expect(parseERC20Data(`0xa9059cbb${address}${amount}`, unknown)).toEqual([
'transfer(to: 0x5f57ed965700eb8693c1ecf0b3682115939bb1fe, amount: 1000000 raw units)',
`Unknown ERC-20 (${unknown})`
])
})
})
112 changes: 112 additions & 0 deletions src/app/utils/erc20-data-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import BigNumber from 'bignumber.js'

type ERC20ParameterType = 'address' | 'uint256'

interface ERC20Method {
name: string
parameters: { name: string; type: ERC20ParameterType }[]
}

const KNOWN_ERC20_FUNC: { [selector: string]: ERC20Method } = {
'095ea7b3': {
name: 'approve',
parameters: [
{ name: 'spender', type: 'address' },
{ name: 'amount', type: 'uint256' }
]
},
'23b872dd': {
name: 'transferFrom',
parameters: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' }
]
},
a9059cbb: {
name: 'transfer',
parameters: [
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' }
]
}
}
const KNOWN_DECIMAL: { [token: string]: number } = { USDC: 6, USDT: 6, USDT0: 6, WETH: 18, rETH: 18 }
const KNOWN_TO_ADDR: { [address: string]: string } = {
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 'USDC',
'0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': 'USDC (POL)',
'0xdac17f958d2ee523a2206206994597c13d831ec7': 'USDT',
'0xc2132d05d31c914a87c6611c10748aeb04b58e8f': 'USDT0 (POL)',
'0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 'WETH',
'0xae78736cd615f374d3085123a210448e74fc6393': 'rETH'
}
const KNOWN_INNER_DEST: { [address: string]: string } = {
'0x000000000022d473030f116ddee9f6b43ac78ba3': 'Uniswap Permit2 (ETH/POL)',
'0xe592427a0aece92de3edee1f18e0157c05861564': 'Uniswap Router V3 (ETH/POL)',
'0x66a9893cc07d91d95644aedd05d03f95e1dba8af': 'Uniswap Router V4 (ETH)',
'0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca': 'Uniswap Router V4 (ETH)'
}

export function parseERC20Data(rawData: string, rawTo: string): [string, string] | undefined {
const data: string = rawData.startsWith('0x') ? rawData.slice(2) : rawData
const method: ERC20Method | undefined = KNOWN_ERC20_FUNC[data.slice(0, 8).toLowerCase()]
const token: string | undefined = KNOWN_TO_ADDR[rawTo.toLowerCase()]
if (!/^[0-9a-fA-F]+$/.test(data) || !/^0x[0-9a-fA-F]{40}$/.test(rawTo) || method === undefined) {
return undefined
}

if (data.length !== 8 + method.parameters.length * 64) {
return undefined
}

const args: string[] = []
for (let index: number = 0; index < method.parameters.length; index++) {
const parameter: { name: string; type: ERC20ParameterType } = method.parameters[index]
const word: string = data.slice(8 + index * 64, 8 + (index + 1) * 64)
const parsed: string | undefined = parseOnce(word, parameter, token)
if (parsed === undefined) {
return undefined
}

args.push(parsed)
}

return [`${method.name}(${args.join(', ')})`, `${token ?? 'Unknown ERC-20'} (${rawTo})`]
}

function parseOnce(
word: string,
parameter: { name: string; type: ERC20ParameterType },
token?: string
): string | undefined {
const value: string | undefined = parameter.type === 'address' ? formatAddress(word) : formatAmount(word, token)

return value !== undefined ? `${parameter.name}: ${value}` : undefined
}

function formatAddress(word: string): string | undefined {
if (!/^0{24}[0-9a-fA-F]{40}$/.test(word)) {
return undefined
}

const address: string = `0x${word.slice(24)}`
const name: string | undefined = KNOWN_INNER_DEST[address.toLowerCase()]

return name !== undefined ? `${name} (${address})` : address
}

function formatAmount(word: string, token?: string): string | undefined {
if (!/^[0-9a-fA-F]{64}$/.test(word)) {
return undefined
}

const amount: BigNumber = new BigNumber(word, 16)
if (amount.gt(new BigNumber(2).pow(255))) {
return 'Infinite'
}

const decimal: number | undefined = token !== undefined ? KNOWN_DECIMAL[token.split(' ')[0]] : undefined
return token !== undefined && decimal !== undefined
? `${amount.shiftedBy(-decimal).toFixed(decimal)} ${token}`
: `${amount.toFixed()} raw units`
}