Merge branch 'master' into hunicus/memerator-r

This commit is contained in:
wiz 2025-03-29 17:33:11 +09:00 committed by GitHub
commit a427aa2058
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 482 additions and 52 deletions

View File

@ -105,7 +105,6 @@ jobs:
--cache-to "type=local,dest=/tmp/.buildx-cache,mode=max" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \
--build-context rustgbt=./rust \
--build-context backend=./backend \
--output "type=registry,push=true" \

View File

@ -406,8 +406,8 @@ class BitcoinRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString());
res.json(block);
} catch (e) {
handleError(req, res, 500, 'Failed to get block');
} catch (e: any) {
handleError(req, res, e?.response?.status === 404 ? 404 : 500, 'Failed to get block');
}
}

View File

@ -30,6 +30,7 @@ const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
class WalletApi {
private wallets: Record<string, Wallet> = {};
private syncing = false;
private lastSync = 0;
constructor() {
this.wallets = config.WALLETS.ENABLED ? (config.WALLETS.WALLETS as string[]).reduce((acc, wallet) => {
@ -47,7 +48,38 @@ class WalletApi {
if (!config.WALLETS.ENABLED || this.syncing) {
return;
}
this.syncing = true;
if (config.WALLETS.AUTO && (Date.now() - this.lastSync) > POLL_FREQUENCY) {
try {
// update list of active wallets
this.lastSync = Date.now();
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/wallets`);
const walletList: string[] = response.data;
if (walletList) {
// create a quick lookup dictionary of active wallets
const newWallets: Record<string, boolean> = Object.fromEntries(
walletList.map(wallet => [wallet, true])
);
for (const wallet of walletList) {
// don't overwrite existing wallets
if (!this.wallets[wallet]) {
this.wallets[wallet] = { name: wallet, addresses: {}, lastPoll: 0 };
}
}
// remove wallets that are no longer active
for (const wallet of Object.keys(this.wallets)) {
if (!newWallets[wallet]) {
delete this.wallets[wallet];
}
}
}
} catch (e) {
logger.err(`Error updating active wallets: ${(e instanceof Error ? e.message : e)}`);
}
}
for (const walletKey of Object.keys(this.wallets)) {
const wallet = this.wallets[walletKey];
if (wallet.lastPoll < (Date.now() - POLL_FREQUENCY)) {
@ -72,6 +104,7 @@ class WalletApi {
}
}
}
this.syncing = false;
}

View File

@ -164,6 +164,7 @@ interface IConfig {
},
WALLETS: {
ENABLED: boolean;
AUTO: boolean;
WALLETS: string[];
},
STRATUM: {
@ -334,6 +335,7 @@ const defaults: IConfig = {
},
'WALLETS': {
'ENABLED': false,
'AUTO': false,
'WALLETS': [],
},
'STRATUM': {

View File

@ -16,10 +16,10 @@
"mobileOrder": 4
},
{
"component": "balance",
"component": "walletBalance",
"mobileOrder": 1,
"props": {
"address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo"
"wallet": "ONBTC"
}
},
{
@ -30,21 +30,22 @@
}
},
{
"component": "address",
"component": "wallet",
"mobileOrder": 2,
"props": {
"address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo",
"period": "1m"
"wallet": "ONBTC",
"period": "1m",
"label": "bitcoin.gob.sv"
}
},
{
"component": "blocks"
},
{
"component": "addressTransactions",
"component": "walletTransactions",
"mobileOrder": 3,
"props": {
"address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo"
"wallet": "ONBTC"
}
}
]

View File

@ -44,6 +44,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
@Input() right: number | string = 10;
@Input() left: number | string = 70;
@Input() widget: boolean = false;
@Input() label: string = '';
@Input() defaultFiat: boolean = false;
@Input() showLegend: boolean = true;
@Input() showYAxis: boolean = true;
@ -55,6 +56,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
hoverData: any[] = [];
conversions: any;
allowZoom: boolean = false;
labelGraphic: any;
selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false };
@ -85,6 +87,18 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true;
this.labelGraphic = this.label ? {
type: 'text',
right: '36px',
bottom: '36px',
z: 100,
silent: true,
style: {
fill: '#fff',
text: this.label,
font: '24px sans-serif'
}
} : undefined;
if (!this.addressSummary$ && (!this.address || !this.stats)) {
return;
}
@ -205,6 +219,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
right: this.adjustedRight,
left: this.adjustedLeft,
},
graphic: this.labelGraphic ? [{
...this.labelGraphic,
right: this.adjustedRight + 22 + 'px',
}] : undefined,
legend: (this.showLegend && !this.stateService.isAnyTestnet()) ? {
data: [
{
@ -443,6 +461,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
right: this.adjustedRight,
left: this.adjustedLeft,
},
graphic: this.labelGraphic ? [{
...this.labelGraphic,
right: this.adjustedRight + 22 + 'px',
}] : undefined,
legend: {
selected: this.selected,
},

View File

@ -238,7 +238,7 @@
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [defaultFiat]="true" [height]="graphHeight"></app-address-graph>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [defaultFiat]="true" [height]="graphHeight" [label]="widget.props.label"></app-address-graph>
</div>
</div>
</div>
@ -272,7 +272,7 @@
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight"></app-address-graph>
<app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight" [label]="widget.props.label"></app-address-graph>
</div>
</div>
</div>

View File

@ -30,7 +30,6 @@
</span>
<div class="container-buttons">
<button *ngIf="successBroadcast" type="button" class="btn btn-sm btn-success no-cursor" i18n="transaction.broadcasted|Broadcasted">Broadcasted</button>
<button class="btn btn-sm" style="margin-left: 10px; padding: 0;" (click)="resetForm()">&#10005;</button>
</div>
@ -40,14 +39,18 @@
<div class="clearfix"></div>
<div *ngIf="!successBroadcast" class="alert alert-mempool" style="align-items: center;">
<div class="alert alert-mempool" style="align-items: center;">
<span>
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
<ng-container i18n="transaction.local-tx|This transaction is stored locally in your browser.">
<ng-container *ngIf="!successBroadcast" i18n="transaction.local-tx|This transaction is stored locally in your browser.">
This transaction is stored locally in your browser. Broadcast it to add it to the mempool.
</ng-container>
<ng-container *ngIf="successBroadcast" i18n="transaction.redirecting|Redirecting to transaction page...">
Redirecting to transaction page...
</ng-container>
</span>
<button [disabled]="isLoadingBroadcast" type="button" class="btn btn-sm btn-primary" i18n="transaction.broadcast|Broadcast" (click)="postTx()">Broadcast</button>
<button *ngIf="!successBroadcast" [disabled]="isLoadingBroadcast" type="button" class="btn btn-sm btn-primary btn-broadcast" i18n="transaction.broadcast|Broadcast" (click)="postTx()">Broadcast</button>
<button *ngIf="successBroadcast" type="button" class="btn btn-sm btn-success no-cursor btn-broadcast" i18n="transaction.broadcasted|Broadcasted">Broadcasted</button>
</div>
@if (!hasPrevouts) {

View File

@ -191,4 +191,12 @@
.no-cursor {
cursor: default !important;
pointer-events: none;
}
.btn-broadcast {
margin-left: 5px;
@media (max-width: 567px) {
margin-left: 0;
margin-top: 5px;
}
}

View File

@ -3,7 +3,7 @@ import { Transaction, Vout } from '@interfaces/electrs.interface';
import { StateService } from '../../services/state.service';
import { Filter, toFilters } from '../../shared/filters.utils';
import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils';
import { firstValueFrom, Subscription } from 'rxjs';
import { catchError, firstValueFrom, Subscription, switchMap, tap, throwError, timer } from 'rxjs';
import { WebsocketService } from '../../services/websocket.service';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
@ -36,6 +36,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
isLoadingBroadcast: boolean;
errorBroadcast: string;
successBroadcast: boolean;
broadcastSubscription: Subscription;
isMobile: boolean;
@ViewChild('graphContainer')
@ -82,7 +83,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
this.resetState();
this.isLoading = true;
try {
const { tx, hex } = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network);
const { tx, hex } = decodeRawTransaction(this.pushTxForm.get('txRaw').value.trim(), this.stateService.network);
await this.fetchPrevouts(tx);
await this.fetchCpfpInfo(tx);
this.processTransaction(tx, hex);
@ -207,18 +208,22 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
});
}
async postTx(): Promise<string> {
postTx(): void {
this.isLoadingBroadcast = true;
this.errorBroadcast = null;
return new Promise((resolve, reject) => {
this.apiService.postTransaction$(this.rawHexTransaction)
.subscribe((result) => {
this.broadcastSubscription = this.apiService.postTransaction$(this.rawHexTransaction).pipe(
tap((txid: string) => {
this.isLoadingBroadcast = false;
this.successBroadcast = true;
this.transaction.txid = result;
resolve(result);
},
(error) => {
this.transaction.txid = txid;
}),
switchMap((txid: string) =>
timer(2000).pipe(
tap(() => this.router.navigate([this.relativeUrlPipe.transform('/tx/' + txid)])),
)
),
catchError((error) => {
if (typeof error.error === 'string') {
const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"');
this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + (matchText && matchText[1] || error.error);
@ -226,9 +231,9 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + error.message;
}
this.isLoadingBroadcast = false;
reject(this.error);
});
});
return throwError(() => error);
})
).subscribe();
}
resetState() {
@ -253,6 +258,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
this.missingPrevouts = [];
this.stateService.markBlock$.next({});
this.mempoolBlocksSubscription?.unsubscribe();
this.broadcastSubscription?.unsubscribe();
}
resetForm() {
@ -308,6 +314,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy {
this.mempoolBlocksSubscription?.unsubscribe();
this.flowPrefSubscription?.unsubscribe();
this.stateService.markBlock$.next({});
this.broadcastSubscription?.unsubscribe();
}
}

View File

@ -16,6 +16,11 @@
<div class="header-bg box" [attr.data-cy]="'tx-' + i">
<div *ngIf="errorUnblinded" class="error-unblinded">{{ errorUnblinded }}</div>
@if (similarityMatches.get(tx.txid)?.size) {
<div class="alert alert-mempool" role="alert">
<span i18n="transaction.poison.warning">Warning! This transaction involves deceptively similar addresses. It may be an address poisoning attack.</span>
</div>
}
<div class="row">
<div class="col">
<table class="table table-fixed table-borderless smaller-text table-sm table-tx-vin">
@ -68,9 +73,11 @@
</ng-template>
</ng-template>
<ng-template #defaultAddress>
<a class="address" *ngIf="vin.prevout.scriptpubkey_address; else vinScriptPubkeyType" [routerLink]="['/address/' | relativeUrl, vin.prevout.scriptpubkey_address]" title="{{ vin.prevout.scriptpubkey_address }}">
<app-truncate [text]="vin.prevout.scriptpubkey_address" [lastChars]="8"></app-truncate>
</a>
<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)"
></app-address-text>
<ng-template #vinScriptPubkeyType>
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
</ng-template>
@ -217,9 +224,11 @@
'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2)))
}">
<td class="address-cell">
<a class="address" *ngIf="vout.scriptpubkey_address; else pubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}">
<app-truncate [text]="vout.scriptpubkey_address" [lastChars]="8"></app-truncate>
</a>
<app-address-text
*ngIf="vout.scriptpubkey_address; else pubkey_type"
[address]="vout.scriptpubkey_address"
[similarity]="similarityMatches.get(tx.txid)?.get(vout.scriptpubkey_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) }}">

View File

@ -14,6 +14,7 @@ import { StorageService } from '@app/services/storage.service';
import { OrdApiService } from '@app/services/ord-api.service';
import { Inscription } from '@app/shared/ord/inscription.utils';
import { Etching, Runestone } from '@app/shared/ord/rune.utils';
import { ADDRESS_SIMILARITY_THRESHOLD, AddressMatch, AddressSimilarity, AddressType, AddressTypeInfo, checkedCompareAddressStrings, detectAddressType } from '@app/shared/address-utils';
@Component({
selector: 'app-transactions-list',
@ -55,6 +56,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
showFullScript: { [vinIndex: number]: boolean } = {};
showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {};
showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {};
similarityMatches: Map<string, Map<string, { score: number, match: AddressMatch, group: number }>> = new Map();
constructor(
public stateService: StateService,
@ -144,6 +146,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.currency = currency;
this.refreshPrice();
});
this.updateAddressSimilarities();
}
refreshPrice(): void {
@ -183,6 +187,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
if (changes.transactions || changes.addresses) {
this.similarityMatches.clear();
this.updateAddressSimilarities();
if (!this.transactions || !this.transactions.length) {
return;
}
@ -296,6 +302,56 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
updateAddressSimilarities(): void {
if (!this.transactions || !this.transactions.length) {
return;
}
for (const tx of this.transactions) {
if (this.similarityMatches.get(tx.txid)) {
continue;
}
const similarityGroups: Map<string, number> = new Map();
let lastGroup = 0;
// Check for address poisoning similarity matches
this.similarityMatches.set(tx.txid, new Map());
const comparableVouts = [
...tx.vout.slice(0, 20),
...this.addresses.map(addr => ({ scriptpubkey_address: addr, scriptpubkey_type: detectAddressType(addr, this.stateService.network) }))
].filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v.scriptpubkey_type));
const comparableVins = tx.vin.slice(0, 20).map(v => v.prevout).filter(v => ['p2pkh', 'p2sh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(v?.scriptpubkey_type));
for (const vout of comparableVouts) {
const address = vout.scriptpubkey_address;
const addressType = vout.scriptpubkey_type;
if (this.similarityMatches.get(tx.txid)?.has(address)) {
continue;
}
for (const compareAddr of [
...comparableVouts.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address),
...comparableVins.filter(v => v.scriptpubkey_type === addressType && v.scriptpubkey_address !== address)
]) {
const similarity = checkedCompareAddressStrings(address, compareAddr.scriptpubkey_address, addressType as AddressType, this.stateService.network);
if (similarity?.status === 'comparable' && similarity.score > ADDRESS_SIMILARITY_THRESHOLD) {
let group = similarityGroups.get(address) || lastGroup++;
similarityGroups.set(address, group);
const bestVout = this.similarityMatches.get(tx.txid)?.get(address);
if (!bestVout || bestVout.score < similarity.score) {
this.similarityMatches.get(tx.txid)?.set(address, { score: similarity.score, match: similarity.left, group });
}
// opportunistically update the entry for the compared address
const bestCompare = this.similarityMatches.get(tx.txid)?.get(compareAddr.scriptpubkey_address);
if (!bestCompare || bestCompare.score < similarity.score) {
group = similarityGroups.get(compareAddr.scriptpubkey_address) || lastGroup++;
similarityGroups.set(compareAddr.scriptpubkey_address, group);
this.similarityMatches.get(tx.txid)?.set(compareAddr.scriptpubkey_address, { score: similarity.score, match: similarity.right, group });
}
}
}
}
}
}
onScroll(): void {
this.loadMore.emit();
}

View File

@ -1,7 +1,7 @@
// Import tree-shakeable echarts
import * as echarts from 'echarts/core';
import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart } from 'echarts/charts';
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components';
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent, GraphicComponent } from 'echarts/components';
import { SVGRenderer, CanvasRenderer } from 'echarts/renderers';
// Typescript interfaces
import { EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption } from 'echarts';
@ -13,6 +13,6 @@ echarts.use([
LegendComponent, GeoComponent, DataZoomComponent,
VisualMapComponent, MarkLineComponent,
LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart,
CustomChart,
CustomChart, GraphicComponent
]);
export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption };

View File

@ -78,6 +78,7 @@ const p2trRegex = RegExp('^p' + BECH32_CHARS_LW + '{58}$');
const pubkeyRegex = RegExp('^' + `(04${HEX_CHARS}{128})|(0[23]${HEX_CHARS}{64})$`);
export function detectAddressType(address: string, network: string): AddressType {
network = network || 'mainnet';
// normal address types
const firstChar = address.substring(0, 1);
if (ADDRESS_PREFIXES[network].base58.pubkey.includes(firstChar) && base58Regex.test(address.slice(1))) {
@ -211,6 +212,18 @@ export class AddressTypeInfo {
}
}
public compareTo(other: AddressTypeInfo): AddressSimilarityResult {
return compareAddresses(this.address, other.address, this.network);
}
public compareToString(other: string): AddressSimilarityResult {
if (other === this.address) {
return { status: 'identical' };
}
const otherInfo = new AddressTypeInfo(this.network, other);
return this.compareTo(otherInfo);
}
private processScript(script: ScriptInfo): void {
this.scripts.set(script.key, script);
if (script.template?.type === 'multisig') {
@ -218,3 +231,135 @@ export class AddressTypeInfo {
}
}
}
export interface AddressMatch {
prefix: string;
postfix: string;
}
export interface AddressSimilarity {
status: 'comparable';
score: number;
left: AddressMatch;
right: AddressMatch;
}
export type AddressSimilarityResult =
| { status: 'identical' }
| { status: 'incomparable' }
| AddressSimilarity;
export const ADDRESS_SIMILARITY_THRESHOLD = 10_000_000; // 1 false positive per ~10 million comparisons
function fuzzyPrefixMatch(a: string, b: string, rtl: boolean = false): { score: number, matchA: string, matchB: string } {
let score = 0;
let gap = false;
let done = false;
let ai = 0;
let bi = 0;
let prefixA = '';
let prefixB = '';
if (rtl) {
a = a.split('').reverse().join('');
b = b.split('').reverse().join('');
}
while (ai < a.length && bi < b.length && !done) {
if (a[ai] === b[bi]) {
// matching characters
prefixA += a[ai];
prefixB += b[bi];
score++;
ai++;
bi++;
} else if (!gap) {
// try looking ahead in both strings to find the best match
const nextMatchA = (ai + 1 < a.length && a[ai + 1] === b[bi]);
const nextMatchB = (bi + 1 < b.length && a[ai] === b[bi + 1]);
const nextMatchBoth = (ai + 1 < a.length && bi + 1 < b.length && a[ai + 1] === b[bi + 1]);
if (nextMatchBoth) {
// single differing character
prefixA += a[ai];
prefixB += b[bi];
ai++;
bi++;
} else if (nextMatchA) {
// character missing in b
prefixA += a[ai];
ai++;
} else if (nextMatchB) {
// character missing in a
prefixB += b[bi];
bi++;
} else {
ai++;
bi++;
}
gap = true;
} else {
done = true;
}
}
if (rtl) {
prefixA = prefixA.split('').reverse().join('');
prefixB = prefixB.split('').reverse().join('');
}
return { score, matchA: prefixA, matchB: prefixB };
}
export function compareAddressInfo(a: AddressTypeInfo, b: AddressTypeInfo): AddressSimilarityResult {
if (a.address === b.address) {
return { status: 'identical' };
}
if (a.type !== b.type) {
return { status: 'incomparable' };
}
if (!['p2pkh', 'p2sh', 'p2sh-p2wpkh', 'p2sh-p2wsh', 'v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(a.type)) {
return { status: 'incomparable' };
}
const isBase58 = a.type === 'p2pkh' || a.type === 'p2sh';
const left = fuzzyPrefixMatch(a.address, b.address);
const right = fuzzyPrefixMatch(a.address, b.address, true);
// depending on address type, some number of matching prefix characters are guaranteed
const prefixScore = isBase58 ? 1 : ADDRESS_PREFIXES[a.network || 'mainnet'].bech32.length;
// add the two scores together
const totalScore = left.score + right.score - prefixScore;
// adjust for the size of the alphabet (58 vs 32)
const normalizedScore = Math.pow(isBase58 ? 58 : 32, totalScore);
return {
status: 'comparable',
score: normalizedScore,
left: {
prefix: left.matchA,
postfix: right.matchA,
},
right: {
prefix: left.matchB,
postfix: right.matchB,
},
};
}
export function compareAddresses(a: string, b: string, network: string): AddressSimilarityResult {
if (a === b) {
return { status: 'identical' };
}
const aInfo = new AddressTypeInfo(network, a);
return aInfo.compareToString(b);
}
// avoids the overhead of creating AddressTypeInfo objects for each address,
// but a and b *MUST* be valid normalized addresses, of the same valid type
export function checkedCompareAddressStrings(a: string, b: string, type: AddressType, network: string): AddressSimilarityResult {
return compareAddressInfo(
{ address: a, type: type, network: network } as AddressTypeInfo,
{ address: b, type: type, network: network } as AddressTypeInfo,
);
}

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 }}</span>
<span class="infix" [ngStyle]="{'text-decoration-color': groupColors[similarity.group % (groupColors.length)]}">{{ address.slice(similarity.match.prefix.length || 0, -similarity.match.postfix.length || undefined) }}</span>
<span class="postfix"> {{ similarity.match.postfix }}</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,20 @@
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']
})
export class AddressTextComponent {
@Input() address: string;
@Input() info: AddressTypeInfo | null;
@Input() similarity: { score: number, match: AddressMatch, group: number } | null;
groupColors: string[] = [
'var(--primary)',
'var(--success)',
'var(--info)',
'white',
];
}

View File

@ -256,6 +256,11 @@ export function detectScriptTemplate(type: ScriptType, script_asm: string, witne
return ScriptTemplates.multisig(tapscriptMultisig.m, tapscriptMultisig.n);
}
const tapscriptUnanimousMultisig = parseTapscriptUnanimousMultisig(script_asm);
if (tapscriptUnanimousMultisig) {
return ScriptTemplates.multisig(tapscriptUnanimousMultisig, tapscriptUnanimousMultisig);
}
return;
}
@ -310,11 +315,13 @@ export function parseTapscriptMultisig(script: string): undefined | { m: number,
}
const ops = script.split(' ');
// At minimum, one pubkey group (3 tokens) + m push + final opcode = 5 tokens
if (ops.length < 5) return;
// At minimum, 2 pubkey group (3 tokens) + m push + final opcode = 8 tokens
if (ops.length < 8) {
return;
}
const finalOp = ops.pop();
if (finalOp !== 'OP_NUMEQUAL' && finalOp !== 'OP_GREATERTHANOREQUAL') {
if (!['OP_NUMEQUAL', 'OP_NUMEQUALVERIFY', 'OP_GREATERTHANOREQUAL', 'OP_GREATERTHAN', 'OP_EQUAL', 'OP_EQUALVERIFY'].includes(finalOp)) {
return;
}
@ -329,6 +336,10 @@ export function parseTapscriptMultisig(script: string): undefined | { m: number,
return;
}
if (finalOp === 'OP_GREATERTHAN') {
m += 1;
}
if (ops.length % 3 !== 0) {
return;
}
@ -360,6 +371,53 @@ export function parseTapscriptMultisig(script: string): undefined | { m: number,
return { m, n };
}
export function parseTapscriptUnanimousMultisig(script: string): undefined | number {
if (!script) {
return;
}
const ops = script.split(' ');
// At minimum, 2 pubkey group (3 tokens) = 6 tokens
if (ops.length < 6) {
return;
}
if (ops.length % 3 !== 0) {
return;
}
const n = ops.length / 3;
for (let i = 0; i < n; i++) {
const pushOp = ops.shift();
const pubkey = ops.shift();
const sigOp = ops.shift();
if (pushOp !== 'OP_PUSHBYTES_32') {
return;
}
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
return;
}
if (i < n - 1) {
if (sigOp !== 'OP_CHECKSIGVERIFY') {
return;
}
} else {
// Last opcode can be either CHECKSIG or CHECKSIGVERIFY
if (!(sigOp === 'OP_CHECKSIGVERIFY' || sigOp === 'OP_CHECKSIG')) {
return;
}
}
}
if (ops.length) {
return;
}
return n;
}
export function getVarIntLength(n: number): number {
if (n < 0xfd) {
return 1;

View File

@ -7,7 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft,
faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck,
faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline,
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope } from '@fortawesome/free-solid-svg-icons';
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MenuComponent } from '@components/menu/menu.component';
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
@ -94,6 +94,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';
@ -214,6 +215,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
AddressTextComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,
@ -360,6 +362,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c
BtcComponent,
FeeRateComponent,
AddressTypeComponent,
AddressTextComponent,
TruncateComponent,
SearchResultsComponent,
TimestampComponent,
@ -465,5 +468,6 @@ export class SharedModule {
library.addIcons(faShareNodes);
library.addIcons(faCreditCard);
library.addIcons(faMicroscope);
library.addIcons(faExclamationTriangle);
}
}

View File

@ -1,7 +1,16 @@
@reboot sleep 5 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1
@reboot sleep 5 ; /usr/local/bin/bitcoind -testnet4 >/dev/null 2>&1
@reboot sleep 5 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1
@reboot sleep 10 ; screen -dmS mainnet /bitcoin/electrs/start mainnet
@reboot sleep 10 ; screen -dmS testnet /bitcoin/electrs/start testnet
@reboot sleep 10 ; screen -dmS testnet4 /bitcoin/electrs/start testnet4
@reboot sleep 10 ; screen -dmS signet /bitcoin/electrs/start signet
# start test network daemons on boot
@reboot sleep 10 ; /usr/local/bin/bitcoind -testnet >/dev/null 2>&1
@reboot sleep 20 ; /usr/local/bin/bitcoind -testnet4 >/dev/null 2>&1
@reboot sleep 30 ; /usr/local/bin/bitcoind -signet >/dev/null 2>&1
# start electrs on boot
@reboot sleep 40 ; screen -dmS mainnet /bitcoin/electrs/start mainnet
@reboot sleep 50 ; screen -dmS testnet /bitcoin/electrs/start testnet
@reboot sleep 60 ; screen -dmS testnet4 /bitcoin/electrs/start testnet4
@reboot sleep 70 ; screen -dmS signet /bitcoin/electrs/start signet
# daily update of popular-scripts
30 03 * * * $HOME/electrs/start testnet4 popular-scripts >/dev/null 2>&1
31 03 * * * $HOME/electrs/start testnet popular-scripts >/dev/null 2>&1
32 03 * * * $HOME/electrs/start signet popular-scripts >/dev/null 2>&1
33 03 * * * $HOME/electrs/start mainnet popular-scripts >/dev/null 2>&1

View File

@ -8,3 +8,7 @@
# hourly asset update and electrs restart
6 * * * * cd $HOME/asset_registry_db && git pull --quiet origin master && cd $HOME/asset_registry_testnet_db && git pull --quiet origin master && killall electrs
# daily update of popular-scripts
32 03 * * * $HOME/electrs/start liquid popular-scripts >/dev/null 2>&1
33 03 * * * $HOME/electrs/start liquidtestnet popular-scripts >/dev/null 2>&1

View File

@ -159,6 +159,7 @@
},
"WALLETS": {
"ENABLED": true,
"AUTO": true,
"WALLETS": ["BITB", "3350"]
},
"STRATUM": {

View File

@ -4,7 +4,7 @@ hostname=$(hostname)
heat()
{
echo "$1"
curl -i -s "$1" | head -1
curl -o /dev/null -s "$1"
}
heatURLs=(

View File

@ -6,19 +6,19 @@ slugs=(`curl -sSL https://${hostname}/api/v1/mining/pools/3y|jq -r -S '(.pools[]
warmSlurp()
{
echo "$1"
curl -i -s -H 'User-Agent: Googlebot' "$1" | head -1
curl -o /dev/null -s -H 'User-Agent: Googlebot' "$1"
}
warmUnfurl()
{
echo "$1"
curl -i -s -H 'User-Agent: Twitterbot' "$1" | head -1
curl -o /dev/null -s -H 'User-Agent: Twitterbot' "$1"
}
warm()
{
echo "$1"
curl -i -s "$1" | head -1
curl -o /dev/null -s "$1"
}
warmSlurpURLs=(