Skip to content

Commit 55455e0

Browse files
fix(evm-decoder): correct token target, confidence and failure fallback
- ERC20 sub-protocols report the token recipient as `to`; resolve the token contract from the protocol instead, and leave the target unknown rather than naming the recipient as the contract. - transfer/approve are only 'high' confidence for a curated token contract; the selector alone gives 'medium'. - a decode that throws now shows the raw-calldata view with its warning instead of hiding the decoder panel. - drop stale results when ngOnInit/ngOnChanges decode runs overlap. - build the signature DB for the electron and no-sapling builds too.
1 parent a666e84 commit 55455e0

7 files changed

Lines changed: 84 additions & 23 deletions

File tree

‎package.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
"smoke:evm": "node scripts/smoke-decoder.mjs && node scripts/smoke-renderer.mjs",
1616
"build": "yarn build:signatures && ng build",
1717
"build:prod": "yarn build:signatures && ng build --configuration production",
18-
"build:no-sapling": "yarn remove-sapling && yarn prepare-prod-build && ng build --configuration production",
18+
"build:no-sapling": "yarn remove-sapling && yarn prepare-prod-build && yarn build:signatures && ng build --configuration production",
1919
"build:stats": "ng build --stats-json",
2020
"build:analyze": "yarn build:stats && webpack-bundle-analyzer ./www/stats.json",
21-
"build:electron": "ng build --base-href=./",
22-
"build:electron:prod": "ng build --base-href=./ --configuration production",
21+
"build:electron": "yarn build:signatures && ng build --base-href=./",
22+
"build:electron:prod": "yarn build:signatures && ng build --base-href=./ --configuration production",
2323
"memory": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --port 4201",
2424
"test": "ng test",
2525
"test-ci": "node --max_old_space_size=8192 ./node_modules/@angular/cli/bin/ng test --code-coverage --watch=false",

‎src/app/components/transaction/transaction.component.evm.e2e.spec.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@
77
* the runtime parser, binary search and renderer pipeline are all exercised
88
* end-to-end against rendered DOM.
99
*/
10-
import { ClipboardService } from '@airgap/angular-core'
10+
import { ClipboardService, ProtocolService } from '@airgap/angular-core'
1111
import { HttpClient } from '@angular/common/http'
1212
import { ComponentFixture, TestBed } from '@angular/core/testing'
1313
import { IonicModule } from '@ionic/angular'
1414
import { TranslateModule, TranslateService } from '@ngx-translate/core'
15-
import { IAirGapTransaction, MainProtocolSymbols } from '@airgap/coinlib-core'
15+
import { IAirGapTransaction, MainProtocolSymbols, SubProtocolSymbols } from '@airgap/coinlib-core'
1616
import { of } from 'rxjs'
1717

1818
import { ContactsService } from '../../services/contacts/contacts.service'
19+
import { EvmTransactionRendererService } from '../../services/evm/transaction-renderer.service'
1920
import { EvmTransactionDisplayComponent } from '../evm-transaction-display/evm-transaction-display.component'
2021
import { TransactionWarningComponent } from '../transaction-warning/transaction-warning.component'
2122
import enJson from '../../../assets/i18n/en.json'
@@ -114,6 +115,7 @@ describe('TransactionComponent — EVM decoder (e2e)', () => {
114115
let component: TransactionComponent
115116
let fixture: ComponentFixture<TransactionComponent>
116117
const contactsSpy = jasmine.createSpyObj('ContactsService', ['isBookEnabled', 'getContactName'])
118+
const protocolSpy = jasmine.createSpyObj('ProtocolService', ['getProtocol'])
117119

118120
beforeEach(async () => {
119121
contactsSpy.isBookEnabled.and.returnValue(Promise.resolve(false))
@@ -124,7 +126,8 @@ describe('TransactionComponent — EVM decoder (e2e)', () => {
124126
providers: [
125127
{ provide: HttpClient, useClass: FakeHttpClient },
126128
{ provide: ContactsService, useValue: contactsSpy },
127-
{ provide: ClipboardService, useValue: jasmine.createSpyObj('ClipboardService', ['copyAndShowToast']) }
129+
{ provide: ClipboardService, useValue: jasmine.createSpyObj('ClipboardService', ['copyAndShowToast']) },
130+
{ provide: ProtocolService, useValue: protocolSpy }
128131
]
129132
})
130133
// Schemas: the real TransactionComponent template uses pipes (amountConverter / feeConverter)
@@ -169,6 +172,34 @@ describe('TransactionComponent — EVM decoder (e2e)', () => {
169172
expect(text).toContain('0xd8da6bf26964af9d7eed9e03e53415d37aa96045')
170173
})
171174

175+
it('uses the token contract, not the recipient in `to`, for ERC-20 sub-protocols', async () => {
176+
protocolSpy.getProtocol.and.returnValue(
177+
Promise.resolve({ getContractAddress: () => Promise.resolve('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') })
178+
)
179+
await runWith([
180+
evmTx({
181+
// coinlib's ERC20 protocol reports the token recipient as `to`
182+
to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
183+
protocolIdentifier: SubProtocolSymbols.ETH_ERC20,
184+
data:
185+
'0xa9059cbb' +
186+
'000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045' +
187+
'00000000000000000000000000000000000000000000000000000000000f4240'
188+
})
189+
])
190+
const text = (fixture.nativeElement as HTMLElement).textContent || ''
191+
expect(text).toContain('1 USDC')
192+
expect(text).toContain('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')
193+
})
194+
195+
it('renders raw calldata with a warning when decoding throws', async () => {
196+
const renderer = TestBed.inject(EvmTransactionRendererService)
197+
spyOn(renderer, 'render').and.throwError('boom')
198+
await runWith([evmTx({ to: '0xabcabcabcabcabcabcabcabcabcabcabcabcabca', data: '0x12345678' })])
199+
const text = (fixture.nativeElement as HTMLElement).textContent || ''
200+
expect(text).toContain('Could not decode')
201+
})
202+
172203
it('renders an unlimited approval in warning style', async () => {
173204
await runWith([
174205
evmTx({

‎src/app/components/transaction/transaction.component.spec.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { SecureStorageService } from 'src/app/services/secure-storage/secure-sto
88
import { SecureStorageServiceMock } from 'src/app/services/secure-storage/secure-storage.mock'
99
import { InteractionService } from 'src/app/services/interaction/interaction.service'
1010
import { createAppSpy } from 'test-config/plugins-mocks'
11-
import { APP_PLUGIN, DeeplinkService } from '@airgap/angular-core'
11+
import { APP_PLUGIN, DeeplinkService, ProtocolService } from '@airgap/angular-core'
1212

1313
describe('UnsignedTransactionComponent', () => {
1414
let signedTransactionFixture: ComponentFixture<TransactionComponent>
@@ -27,7 +27,8 @@ describe('UnsignedTransactionComponent', () => {
2727
SecretsService,
2828
InteractionService,
2929
DeeplinkService,
30-
{ provide: APP_PLUGIN, useValue: appSpy }
30+
{ provide: APP_PLUGIN, useValue: appSpy },
31+
{ provide: ProtocolService, useValue: jasmine.createSpyObj('ProtocolService', ['getProtocol']) }
3132
]
3233
})
3334
)

‎src/app/components/transaction/transaction.component.ts‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { IAirGapTransaction, ProtocolSymbols } from '@airgap/coinlib-core'
1+
import { ProtocolService } from '@airgap/angular-core'
2+
import { IAirGapTransaction, ICoinSubProtocol, ProtocolSymbols } from '@airgap/coinlib-core'
23
import { Component, Input, OnInit } from '@angular/core'
34
import { BehaviorSubject, Observable } from 'rxjs'
45
import { ContactsService } from 'src/app/services/contacts/contacts.service'
56

6-
import { RenderResult } from '../../services/evm/abi-types'
7-
import { chainIdForProtocol, isEvmProtocol } from '../../services/evm/protocol-mapping'
7+
import { EvmTransactionInput, RenderResult } from '../../services/evm/abi-types'
8+
import { chainIdForProtocol, isEvmProtocol, isEvmSubProtocol } from '../../services/evm/protocol-mapping'
89
import { EvmTransactionRendererService } from '../../services/evm/transaction-renderer.service'
910

1011
import { AggregatedDetails, TransactionStore } from './transaction.store'
@@ -30,11 +31,13 @@ export class TransactionComponent implements OnInit {
3031
public readonly evmResults$ = this.evmResultsSubject.asObservable()
3132
private readonly dbDateSubject = new BehaviorSubject<string | undefined>(undefined)
3233
public readonly dbDate$ = this.dbDateSubject.asObservable()
34+
private decodeRun = 0
3335

3436
constructor(
3537
private readonly store: TransactionStore,
3638
private readonly contactsService: ContactsService,
37-
private readonly evmRenderer: EvmTransactionRendererService
39+
private readonly evmRenderer: EvmTransactionRendererService,
40+
private readonly protocolService: ProtocolService
3841
) {
3942
this.protocolIdentifier$ = this.store.selectProtocolIdentifier()
4043
this.airGapTxs$ = this.store.selectAirGapTxs()
@@ -56,6 +59,8 @@ export class TransactionComponent implements OnInit {
5659
}
5760

5861
private async decodeEvm(): Promise<void> {
62+
// ngOnInit and every ngOnChanges start a run; only the latest may publish.
63+
const run = ++this.decodeRun
5964
if (!this.airGapTxs) {
6065
this.evmResultsSubject.next([])
6166
return
@@ -65,24 +70,38 @@ export class TransactionComponent implements OnInit {
6570
if (!isEvmProtocol(tx.protocolIdentifier)) return null
6671
const data = tx.data
6772
if (!data || data === '0x' || data.length <= 2) return null
68-
const to = tx.to?.[0]
69-
if (!to) return null
70-
const input = { to, data, chainId: chainIdForProtocol(tx.protocolIdentifier) }
73+
let to: string | undefined
74+
if (isEvmSubProtocol(tx.protocolIdentifier)) {
75+
// Token protocols replace `to` with the token recipient; the called
76+
// contract is the protocol's own. Unknown stays unknown, never the recipient.
77+
to = await this.tokenContractAddress(tx)
78+
} else {
79+
to = tx.to?.[0]
80+
if (!to) return null
81+
}
82+
const input: EvmTransactionInput = { to, data, chainId: chainIdForProtocol(tx.protocolIdentifier) }
7183
try {
7284
await this.evmRenderer.prepare(input)
7385
return this.evmRenderer.render(input)
7486
} catch (e) {
7587
console.warn('EVM decode failed', e)
76-
return null
88+
return this.evmRenderer.renderRaw(input)
7789
}
7890
})
7991
)
92+
if (run !== this.decodeRun) return
8093
this.evmResultsSubject.next(results)
81-
if (results.some(r => r)) {
82-
const meta = await this.evmRenderer.getDbMetadata()
83-
this.dbDateSubject.next(meta?.sourcifyExportDate)
84-
} else {
85-
this.dbDateSubject.next(undefined)
94+
const meta = results.some(r => r) ? await this.evmRenderer.getDbMetadata() : undefined
95+
if (run !== this.decodeRun) return
96+
this.dbDateSubject.next(meta?.sourcifyExportDate)
97+
}
98+
99+
private async tokenContractAddress(tx: IAirGapTransaction): Promise<string | undefined> {
100+
try {
101+
const protocol = (await this.protocolService.getProtocol(tx.protocolIdentifier, tx.network, false)) as ICoinSubProtocol
102+
return (await protocol.getContractAddress?.()) || undefined
103+
} catch {
104+
return undefined
86105
}
87106
}
88107

‎src/app/renderers/evm/erc20.renderer.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export class Erc20Renderer implements TransactionRenderer {
3333
: { valueKey: 'evm-decoder.amount-raw-note', valueParams: { value: amountRaw.toString() }, value: amountRaw.toString(), rawValue: amountRaw.toString() }
3434
return {
3535
type: 'erc20-transfer',
36-
confidence: 'high',
36+
// The selector alone proves nothing about the contract: only a curated token is 'high'.
37+
confidence: token ? 'high' : 'medium',
3738
functionNameKey: 'evm-decoder.fn-token-transfer',
3839
rows: [
3940
{ labelKey: 'evm-decoder.function-label', valueKey: 'evm-decoder.fn-token-transfer', value: 'Token Transfer', type: 'text' },
@@ -60,7 +61,7 @@ export class Erc20Renderer implements TransactionRenderer {
6061
: { valueKey: 'evm-decoder.amount-raw-note', valueParams: { value: amountRaw.toString() }, value: amountRaw.toString(), rawValue: amountRaw.toString() }
6162
return {
6263
type: 'erc20-approve',
63-
confidence: 'high',
64+
confidence: token ? 'high' : 'medium',
6465
functionNameKey: 'evm-decoder.fn-token-approval',
6566
rows: [
6667
{ labelKey: 'evm-decoder.function-label', valueKey: 'evm-decoder.fn-token-approval', value: 'Token Approval', type: 'text' },

‎src/app/services/evm/protocol-mapping.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export function isEvmProtocol(id: ProtocolSymbols | string | undefined): boolean
3232
return EVM_MAIN.includes(id) || EVM_SUB.includes(id)
3333
}
3434

35+
export function isEvmSubProtocol(id: ProtocolSymbols | string | undefined): boolean {
36+
return !!id && EVM_SUB.includes(id)
37+
}
38+
3539
export function chainIdForProtocol(id: ProtocolSymbols | string | undefined): number | undefined {
3640
if (!id) return undefined
3741
return CHAIN_ID[id]

‎src/app/services/evm/transaction-renderer.service.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ export class EvmTransactionRendererService {
101101
return result
102102
}
103103

104+
/** Raw-calldata view with the "could not decode" warning, for when decoding itself fails. */
105+
public renderRaw(tx: EvmTransactionInput): RenderResult {
106+
return this.flagUnknownTarget(tx, this.rawHex.render(tx))
107+
}
108+
104109
/**
105110
* Attach curated well-known names (USDC, Uniswap V2: Router 2, …) to every
106111
* `address` row across the whole result tree. Single pass over all renderers'

0 commit comments

Comments
 (0)