Merge branch 'master' into natsoni/bootstrap-5

This commit is contained in:
natsoni 2026-03-30 04:30:42 +01:00 committed by GitHub
commit c8a69f4fe6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 117 additions and 299 deletions

View file

@ -229,15 +229,18 @@ describe('Mainnet', () => {
cy.get('.alert-mempool').should('exist');
cy.get('.poison-alert').its('length').should('equal', 2);
cy.get('.poison-alert').parent().find('.address-content').should('have.length', 2).then(($containers) => {
const prefixes = $containers.map((i, el) => Cypress.$(el).find('.prefix').text()).get();
const infixes = $containers.map((i, el) => Cypress.$(el).find('.infix').text()).get().sort();
cy.get('.prefix')
.should('have.length', 2)
.each(($el) => {
cy.wrap($el).should('have.text', prefix);
});
expect(prefixes).to.have.length(2);
prefixes.forEach((p) => expect(p).to.equal(prefix));
expect(infixes).to.deep.equal([infix1, infix2].sort());
});
cy.get('.infix')
.should('have.length', 2)
.then(($infixes) => {
cy.wrap($infixes[0]).should('have.text', infix1);
cy.wrap($infixes[1]).should('have.text', infix2);
});
});
it('highlights potential address poisoning attacks on inputs and outputs, prefix, infix and postfix', () => {
@ -253,17 +256,26 @@ describe('Mainnet', () => {
cy.get('.alert-mempool').should('exist');
cy.get('.poison-alert').its('length').should('equal', 2);
cy.get('.poison-alert').parent().find('.address-content').should('have.length', 2).then(($containers) => {
const prefixes = $containers.map((i, el) => Cypress.$(el).find('.prefix').text()).get();
const infixes = $containers.map((i, el) => Cypress.$(el).find('.infix').text()).get().sort();
const postfixes = $containers.map((i, el) => Cypress.$(el).find('.postfix').text()).get().sort();
cy.get('.prefix')
.should('have.length', 2)
.each(($el) => {
cy.wrap($el).should('have.text', prefix);
});
expect(prefixes).to.have.length(2);
prefixes.forEach((p) => expect(p).to.equal(prefix));
cy.get('.infix')
.should('have.length', 2)
.then(($infixes) => {
cy.wrap($infixes[0]).should('have.text', infix1);
cy.wrap($infixes[1]).should('have.text', infix2);
});
cy.get('.postfix')
.should('have.length', 2)
.then(($postfixes) => {
cy.wrap($postfixes[0]).should('include.text', postfix1);
cy.wrap($postfixes[1]).should('include.text', postfix2);
});
expect(infixes).to.deep.equal([infix1, infix2].sort());
expect(postfixes).to.deep.equal([postfix1, postfix2].sort());
});
});
});

View file

@ -2,7 +2,7 @@
<div class="title-address">
<h1 i18n="shared.address">Address</h1>
<div class="tx-link">
<app-chunked-address [address]="addressString" [link]="['/address/' | relativeUrl, addressString]">
<app-truncate [text]="addressString" [lastChars]="8" [link]="['/address/' | relativeUrl, addressString]">
<span class="qrSpan" (mouseover)="showQR = true" (mouseout)="showQR = false" (pointerdown)="showQR = true">
<fa-icon [icon]="['fas', 'qrcode']" [fixedWidth]="true" [style.font-size]="isMobile ? '18px' : '12px'"></fa-icon>
<div class="qr-wrapper" [hidden]="!showQR">
@ -10,7 +10,7 @@
</div>
</span>
<app-clipboard [text]="addressString" [size]="isMobile ? 'large' : 'normal'"></app-clipboard>
</app-chunked-address>
</app-truncate>
</div>
</div>

View file

@ -150,7 +150,7 @@ export class AddressComponent implements OnInit, OnDestroy {
private audioService: AudioService,
private apiService: ApiService,
private seoService: SeoService,
private formBuilder: UntypedFormBuilder
private formBuilder: UntypedFormBuilder,
) { }
ngOnInit(): void {

View file

@ -93,9 +93,8 @@
</ng-container>
<ng-container *ngSwitchCase="vin.prevout && vin.prevout.scriptpubkey_type === 'p2pk'">
<span>P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vin.prevout.scriptpubkey.slice(2, -2)]" title="{{ vin.prevout.scriptpubkey.slice(2, -2) }}">
<app-chunked-address [address]="vin.prevout.scriptpubkey.slice(2, -2)"></app-chunked-address>
</a>
<app-clipboard [text]="vin.prevout.scriptpubkey.slice(2, -2)" class="ml-2" size="normal"></app-clipboard></span>
<app-truncate [text]="vin.prevout.scriptpubkey.slice(2, -2)" [lastChars]="8"></app-truncate>
</a></span>
</ng-container>
<ng-container *ngSwitchDefault>
<ng-template [ngIf]="!vin.prevout" [ngIfElse]="defaultAddress">
@ -105,13 +104,11 @@
</ng-template>
</ng-template>
<ng-template #defaultAddress>
<app-chunked-address
<app-address-text
*ngIf="vin.prevout.scriptpubkey_address; else vinScriptPubkeyType"
[address]="vin.prevout.scriptpubkey_address"
[similarity]="similarityMatches.get(tx.txid)?.get(vin.prevout.scriptpubkey_address)"
[link]="['/address/' | relativeUrl, vin.prevout.scriptpubkey_address]"
[showClipboard]="true"
></app-chunked-address>
></app-address-text>
<ng-template #vinScriptPubkeyType>
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
</ng-template>
@ -328,19 +325,16 @@
'sigged': selectedSig && selectedSig.txIndex === i && sigHighlights.vout[vindex],
}">
<td class="address-cell">
<app-chunked-address
<app-address-text
*ngIf="vout.scriptpubkey_address; else pubkey_type"
[address]="vout.scriptpubkey_address"
[similarity]="similarityMatches.get(tx.txid)?.get(vout.scriptpubkey_address)"
[link]="['/address/' | relativeUrl, vout.scriptpubkey_address]"
[showClipboard]="true"
></app-chunked-address>
></app-address-text>
<ng-template #pubkey_type>
<ng-container *ngIf="vout.scriptpubkey_type === 'p2pk'; else scriptpubkey_type">
P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey.slice(2, -2)]" title="{{ vout.scriptpubkey.slice(2, -2) }}">
<app-chunked-address [address]="vout.scriptpubkey.slice(2, -2)"></app-chunked-address>
<app-truncate [text]="vout.scriptpubkey.slice(2, -2)" [lastChars]="8"></app-truncate>
</a>
<app-clipboard [text]="vout.scriptpubkey.slice(2, -2)" class="ml-2" size="normal"></app-clipboard>
</ng-container>
</ng-template>
<div>

View file

@ -24,7 +24,7 @@ import { SighashFlag } from '@app/shared/transaction.utils';
templateUrl: './transactions-list.component.html',
styleUrls: ['./transactions-list.component.scss'],
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
network = '';
@ -91,7 +91,7 @@ export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
private ref: ChangeDetectorRef,
private priceService: PriceService,
private storageService: StorageService,
private route: ActivatedRoute
private route: ActivatedRoute,
) {
this.signaturesMode = this.forceSignaturesMode || this.stateService.signaturesMode$.value;
}

View file

@ -0,0 +1,17 @@
@if (similarity) {
<div class="address-text">
<a class="address" style="display: contents;" [routerLink]="['/address/' | relativeUrl, address]" title="{{ address }}">
<span class="prefix">{{ similarity.match.prefix.slice(0, 16) }}</span>
<span class="infix" [ngStyle]="{'text-decoration-color': groupColors[similarity.group % (groupColors.length)]}">{{ address.slice(min(similarity.match.prefix.length, 16) || 0, -min(similarity.match.postfix.length, 16) || undefined) }}</span>
<span class="postfix"> {{ similarity.match.postfix.slice(-16) }}</span>
</a>
<span class="poison-alert" *ngIf="similarity" i18n-ngbTooltip="address-poisoning.warning-tooltip" ngbTooltip="This address is deceptively similar to another output. It may be part of an address poisoning attack.">
<fa-icon [icon]="['fas', 'exclamation-triangle']" [fixedWidth]="true"></fa-icon>
</span>
</div>
} @else {
<a class="address" [routerLink]="['/address/' | relativeUrl, address]" title="{{ address }}">
<app-truncate [text]="address" [lastChars]="8"></app-truncate>
</a>
}

View file

@ -0,0 +1,32 @@
.address-text {
text-overflow: unset;
display: flex;
flex-direction: row;
align-items: start;
position: relative;
font-family: monospace;
.infix {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
text-decoration: underline 2px;
}
.prefix, .postfix {
flex-shrink: 0;
flex-grow: 0;
user-select: none;
text-decoration: underline var(--red) 2px;
}
}
.poison-alert {
margin-left: .5em;
color: var(--yellow);
}

View file

@ -0,0 +1,23 @@
import { Component, Input } from '@angular/core';
import { AddressMatch, AddressTypeInfo } from '@app/shared/address-utils';
@Component({
selector: 'app-address-text',
templateUrl: './address-text.component.html',
styleUrls: ['./address-text.component.scss'],
standalone: false,
})
export class AddressTextComponent {
@Input() address: string;
@Input() info: AddressTypeInfo | null;
@Input() similarity: { score: number, match: AddressMatch, group: number } | null;
min = Math.min;
groupColors: string[] = [
'var(--primary)',
'var(--success)',
'var(--info)',
'white',
];
}

View file

@ -1,49 +0,0 @@
<div class="address" [style.max-width]="maxWidth ? maxWidth + 'px' : null" [style.justify-content]="textAlign" [class.inline]="inline">
<ng-container *ngIf="link">
<a [routerLink]="link" [queryParams]="queryParams" class="address-link" [target]="external ? '_blank' : '_self'" [class.disabled]="disabled">
<ng-container *ngTemplateOutlet="truncated"></ng-container>
</a>
</ng-container>
<ng-container *ngIf="!link">
<ng-container *ngTemplateOutlet="truncated"></ng-container>
</ng-container>
<app-clipboard
*ngIf="!similarity && showClipboard"
[text]="address" size="normal"
class="d-none d-sm-block"
></app-clipboard>
<span *ngIf="similarity" class="poison-alert" i18n-ngbTooltip="address-poisoning.warning-tooltip" ngbTooltip="This address is deceptively similar to another output. It may be part of an address poisoning attack.">
<fa-icon [icon]="['fas', 'exclamation-triangle']" [fixedWidth]="true"></fa-icon>
</span>
<ng-content></ng-content>
</div>
<ng-template #chunkTpl let-chunk>
<ng-container *ngIf="similarity; else plain">
<span class="prefix" *ngIf="chunk.prefixLength">{{ chunk.text.slice(0, chunk.prefixLength) }}</span><span class="infix" *ngIf="chunk.text.length - (chunk.prefixLength || 0) - (chunk.postfixLength || 0) > 0" [style.border-bottom-color]="matchColor">{{ chunk.text.slice(chunk.prefixLength || 0, chunk.text.length - (chunk.postfixLength || 0)) }}</span><span class="postfix" *ngIf="chunk.postfixLength">{{ chunk.text.slice(chunk.text.length - chunk.postfixLength) }}</span>
</ng-container>
<ng-template #plain>{{ chunk.text }}</ng-template>
</ng-template>
<ng-template #truncated>
<div class="address-content">
<span class="first">
<ng-container *ngIf="headChunks.length; else simpleHead">
<span *ngFor="let chunk of headChunks" [class.partial]="chunk.text.length < chunkSize"><ng-container *ngTemplateOutlet="chunkTpl; context: { $implicit: chunk }"></ng-container></span>
</ng-container>
<ng-template #simpleHead>{{ address.slice(0, -lastChars) }}</ng-template>
</span>
<span class="last">
<ng-container *ngIf="tailChunks.length; else simpleTail">
<span *ngIf="(address.length - lastChars) % (chunkSize * 2) >= chunkSize" class="hidden"></span>
<span *ngFor="let chunk of tailChunks"><ng-container *ngTemplateOutlet="chunkTpl; context: { $implicit: chunk }"></ng-container></span>
</ng-container>
<ng-template #simpleTail>{{ address.slice(-lastChars) }}</ng-template>
</span>
<div class="hidden-content">{{ address }}</div>
</div>
</ng-template>

View file

@ -1,117 +0,0 @@
.address {
text-overflow: unset;
display: flex;
flex-direction: row;
align-items: start;
position: relative;
font-family: monospace;
.address-link, .address-content {
direction: ltr;
display: flex;
flex-direction: row;
align-items: baseline;
flex-shrink: 1;
overflow: hidden;
min-width: 0;
}
.address-link:hover {
filter: brightness(0.85);
}
.first {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
.last {
flex-shrink: 0;
flex-grow: 0;
user-select: none;
}
.first, .last {
direction: ltr;
> span {
padding-inline-end: 2px;
&:nth-child(even) {
color: #18e2cf;
}
&.partial {
padding-inline-end: 0;
}
&.hidden {
visibility: hidden;
width: 0;
padding-inline-end: 0;
}
&:has(> .prefix, > .infix, > .postfix) {
padding-inline-end: 0;
> :last-child {
padding-inline-end: 2px;
}
&.partial > :last-child {
padding-inline-end: 0;
}
}
}
}
.last span:last-child {
padding-inline-end: 0;
}
&.inline {
display: inline-flex;
}
.hidden-content {
color: transparent;
position: absolute;
max-width: 300px;
overflow: hidden;
}
.disabled {
pointer-events: none;
opacity: 0.8;
}
.infix {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
border-bottom: 2px solid;
}
.prefix, .postfix {
flex-shrink: 0;
flex-grow: 0;
user-select: none;
border-bottom: 2px solid var(--red);
}
}
.poison-alert {
margin-left: .5em;
color: var(--yellow);
}
@media (max-width: 567px) {
.hidden-content {
max-width: 150px !important;
}
}

View file

@ -1,92 +0,0 @@
import { Component, Input, ChangeDetectionStrategy, OnChanges } from '@angular/core';
import { AddressMatch } from '@app/shared/address-utils';
interface AddressChunk {
text: string;
prefixLength?: number;
postfixLength?: number;
}
@Component({
selector: 'app-chunked-address',
templateUrl: './chunked-address.component.html',
styleUrls: ['./chunked-address.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
})
export class ChunkedAddressComponent implements OnChanges {
@Input() address: string;
@Input() similarity: { score: number, match: AddressMatch, group: number } | null;
@Input() link: any = null;
@Input() external: boolean = false;
@Input() queryParams: any = undefined;
@Input() lastChars: number = 8;
@Input() maxWidth: number = null;
@Input() inline: boolean = false;
@Input() textAlign: 'start' | 'end' = 'start';
@Input() disabled: boolean = false;
@Input() showClipboard: boolean = false;
@Input() chunkSize = 4;
headChunks: AddressChunk[] = [];
tailChunks: AddressChunk[] = [];
groupColors: string[] = [
'var(--primary)',
'var(--success)',
'var(--info)',
'white',
];
matchColor: string = 'var(--primary)';
ngOnChanges(): void {
if (this.similarity) {
this.matchColor = this.groupColors[this.similarity.group % (this.groupColors.length)];
}
this.updateView();
}
private updateView(): void {
if (!this.address) {
return;
}
const split = this.address.length - this.lastChars;
const prefixLen = this.similarity?.match?.prefix?.length || 0;
const postfixStart = this.address.length - (this.similarity?.match?.postfix?.length || 0);
this.headChunks = [];
this.tailChunks = [];
for (let i = 0; i < this.address.length; i += this.chunkSize) {
const text = this.address.slice(i, i + this.chunkSize);
const chunk = {
text,
prefixLength: prefixLen > i ? Math.min(text.length, prefixLen - i) : undefined,
postfixLength: postfixStart < i + text.length ? Math.min(text.length, i + text.length - postfixStart) : undefined,
};
if (i + this.chunkSize <= split) {
this.headChunks.push(chunk);
} else if (i >= split) {
this.tailChunks.push(chunk);
} else {
// split chunk across head and tail
const [ headChunk, tailChunk ] = this.splitChunk(chunk, split - i);
this.headChunks.push(headChunk);
this.tailChunks.push(tailChunk);
}
}
}
private splitChunk(chunk: AddressChunk, at: number): [AddressChunk, AddressChunk] {
return [{
text: chunk.text.slice(0, at),
prefixLength: chunk.prefixLength ? Math.min(chunk.prefixLength, at) : undefined,
postfixLength: chunk.postfixLength ? Math.max(0, at - chunk.postfixLength) : undefined,
}, {
text: chunk.text.slice(at),
prefixLength: chunk.prefixLength ? Math.max(0, at - chunk.prefixLength) : undefined,
postfixLength: chunk.postfixLength ? Math.min(chunk.postfixLength, chunk.text.length - at) : undefined,
}];
}
}

View file

@ -26,4 +26,4 @@ export class TruncateComponent {
this.rtl = true;
}
}
}
}

View file

@ -97,6 +97,7 @@ import { SatsComponent } from '@app/shared/components/sats/sats.component';
import { BtcComponent } from '@app/shared/components/btc/btc.component';
import { FeeRateComponent } from '@app/shared/components/fee-rate/fee-rate.component';
import { AddressTypeComponent } from '@app/shared/components/address-type/address-type.component';
import { AddressTextComponent } from '@app/shared/components/address-text/address-text.component';
import { TruncateComponent } from '@app/shared/components/truncate/truncate.component';
import { SearchResultsComponent } from '@components/search-form/search-results/search-results.component';
import { TimestampComponent } from '@app/shared/components/timestamp/timestamp.component';
@ -132,8 +133,6 @@ import { BitcoinInvoiceComponent } from '@components/bitcoin-invoice/bitcoin-inv
import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/weight-directives/weight-directives';
import { GithubLogin } from '@components/github-login.component/github-login.component';
import { ChunkedAddressComponent } from '@app/shared/components/chunked-address/chunked-address.component';
@NgModule({
declarations: [
ClipboardComponent,
@ -223,6 +222,7 @@ import { ChunkedAddressComponent } from '@app/shared/components/chunked-address/
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
AddressTextComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,
@ -256,7 +256,6 @@ import { ChunkedAddressComponent } from '@app/shared/components/chunked-address/
TwitterLogin,
GithubLogin,
BitcoinInvoiceComponent,
ChunkedAddressComponent
],
imports: [
CommonModule,
@ -376,6 +375,7 @@ import { ChunkedAddressComponent } from '@app/shared/components/chunked-address/
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
AddressTextComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,
@ -407,8 +407,6 @@ import { ChunkedAddressComponent } from '@app/shared/components/chunked-address/
OnlyVsizeDirective,
OnlyWeightDirective,
ChunkedAddressComponent
]
})
export class SharedModule {