Compare commits
78 Commits
natsoni/de
...
knorrium/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
541555051b | ||
|
|
5aeaa68259 | ||
|
|
e01898a4c5 | ||
|
|
0568a8c6c1 | ||
|
|
e53e810a55 | ||
|
|
e2c44b6c62 | ||
|
|
36b691e25b | ||
|
|
5f5e96984a | ||
|
|
4a14e8d921 | ||
|
|
4e735cc8b0 | ||
|
|
4520e3fdf2 | ||
|
|
390bbf1097 | ||
|
|
bd8c1efc8e | ||
|
|
8fbc497a58 | ||
|
|
003956fd16 | ||
|
|
227d99e990 | ||
|
|
3d1aacbd66 | ||
|
|
1098d2fe3c | ||
|
|
f59e95fcc8 | ||
|
|
7f6399093e | ||
|
|
e9e8b0c758 | ||
|
|
517a30d2b0 | ||
|
|
7e766cc28d | ||
|
|
34099e3861 | ||
|
|
671b5ea2f2 | ||
|
|
caa2d83247 | ||
|
|
703241acf0 | ||
|
|
6e8579363d | ||
|
|
b254be2f49 | ||
|
|
d6283c54ee | ||
|
|
9ba7172b5b | ||
|
|
cb4bf0611e | ||
|
|
3ea491ad13 | ||
|
|
eddd7344ad | ||
|
|
4ecf2eb679 | ||
|
|
34acbca4b9 | ||
|
|
8793fafa4c | ||
|
|
341da85c77 | ||
|
|
0d8f63feff | ||
|
|
e7af43efa2 | ||
|
|
f5a54ae62b | ||
|
|
4da145ff5c | ||
|
|
a898701830 | ||
|
|
aca2f2ec7d | ||
|
|
803b005880 | ||
|
|
204d54b189 | ||
|
|
c248544fe8 | ||
|
|
b65d00f289 | ||
|
|
f77dc68ec7 | ||
|
|
c4ec50b771 | ||
|
|
8529b99675 | ||
|
|
cd02d89235 | ||
|
|
4dcbccd9b2 | ||
|
|
6a4aeaf7ed | ||
|
|
6432f72664 | ||
|
|
f6ab2caaf9 | ||
|
|
0a255d7fe5 | ||
|
|
ca0a8aee49 | ||
|
|
4a4259fa7d | ||
|
|
f142b421f9 | ||
|
|
47cc58c610 | ||
|
|
c3686a5500 | ||
|
|
9fbbe4980d | ||
|
|
0611773647 | ||
|
|
7740908a4c | ||
|
|
68ea7c59f3 | ||
|
|
915f7a6c27 | ||
|
|
e18c572549 | ||
|
|
25133d8505 | ||
|
|
9f5666f410 | ||
|
|
6553344489 | ||
|
|
37ddc29c2c | ||
|
|
a5c67b5ca1 | ||
|
|
f49152d09d | ||
|
|
464fabf137 | ||
|
|
ba1ee15286 | ||
|
|
72ddb8c6a4 | ||
|
|
cdc4a430cd |
78
.github/workflows/ci.yml
vendored
78
.github/workflows/ci.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: ["20", "21"]
|
node: ["20", "22"]
|
||||||
flavor: ["dev", "prod"]
|
flavor: ["dev", "prod"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
@@ -160,7 +160,7 @@ jobs:
|
|||||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: ["20", "21"]
|
node: ["20", "22"]
|
||||||
flavor: ["dev", "prod"]
|
flavor: ["dev", "prod"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
@@ -251,17 +251,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
module: ["mempool", "liquid"]
|
module: ["mempool", "liquid", "testnet4"]
|
||||||
include:
|
|
||||||
- module: "mempool"
|
|
||||||
spec: |
|
|
||||||
cypress/e2e/mainnet/*.spec.ts
|
|
||||||
cypress/e2e/signet/*.spec.ts
|
|
||||||
cypress/e2e/testnet4/*.spec.ts
|
|
||||||
- module: "liquid"
|
|
||||||
spec: |
|
|
||||||
cypress/e2e/liquid/liquid.spec.ts
|
|
||||||
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
|
|
||||||
|
|
||||||
name: E2E tests for ${{ matrix.module }}
|
name: E2E tests for ${{ matrix.module }}
|
||||||
steps:
|
steps:
|
||||||
@@ -273,7 +263,7 @@ jobs:
|
|||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
|
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
|
||||||
|
|
||||||
@@ -310,8 +300,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Unzip assets before building (src/resources)
|
- name: Unzip assets before building (src/resources)
|
||||||
run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video
|
run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video
|
||||||
|
|
||||||
|
# mempool
|
||||||
- name: Chrome browser tests (${{ matrix.module }})
|
- name: Chrome browser tests (${{ matrix.module }})
|
||||||
|
if: ${{ matrix.module == 'mempool' }}
|
||||||
uses: cypress-io/github-action@v5
|
uses: cypress-io/github-action@v5
|
||||||
with:
|
with:
|
||||||
tag: ${{ github.event_name }}
|
tag: ${{ github.event_name }}
|
||||||
@@ -322,7 +314,9 @@ jobs:
|
|||||||
wait-on-timeout: 120
|
wait-on-timeout: 120
|
||||||
record: true
|
record: true
|
||||||
parallel: true
|
parallel: true
|
||||||
spec: ${{ matrix.spec }}
|
spec: |
|
||||||
|
cypress/e2e/mainnet/*.spec.ts
|
||||||
|
cypress/e2e/signet/*.spec.ts
|
||||||
group: Tests on Chrome (${{ matrix.module }})
|
group: Tests on Chrome (${{ matrix.module }})
|
||||||
browser: "chrome"
|
browser: "chrome"
|
||||||
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
|
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
|
||||||
@@ -332,6 +326,56 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||||
|
|
||||||
|
# liquid
|
||||||
|
- name: Chrome browser tests (${{ matrix.module }})
|
||||||
|
if: ${{ matrix.module == 'liquid' }}
|
||||||
|
uses: cypress-io/github-action@v5
|
||||||
|
with:
|
||||||
|
tag: ${{ github.event_name }}
|
||||||
|
working-directory: ${{ matrix.module }}/frontend
|
||||||
|
build: npm run config:defaults:${{ matrix.module }}
|
||||||
|
start: npm run start:local-staging
|
||||||
|
wait-on: "http://localhost:4200"
|
||||||
|
wait-on-timeout: 120
|
||||||
|
record: true
|
||||||
|
parallel: true
|
||||||
|
spec: |
|
||||||
|
cypress/e2e/liquid/liquid.spec.ts
|
||||||
|
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
|
||||||
|
group: Tests on Chrome (${{ matrix.module }})
|
||||||
|
browser: "chrome"
|
||||||
|
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
|
||||||
|
env:
|
||||||
|
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
|
||||||
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||||
|
|
||||||
|
# testnet
|
||||||
|
- name: Chrome browser tests (${{ matrix.module }})
|
||||||
|
if: ${{ matrix.module == 'testnet4' }}
|
||||||
|
uses: cypress-io/github-action@v5
|
||||||
|
with:
|
||||||
|
tag: ${{ github.event_name }}
|
||||||
|
working-directory: ${{ matrix.module }}/frontend
|
||||||
|
build: npm run config:defaults:mempool
|
||||||
|
start: npm run start:local-staging
|
||||||
|
wait-on: "http://localhost:4200"
|
||||||
|
wait-on-timeout: 120
|
||||||
|
record: true
|
||||||
|
parallel: true
|
||||||
|
spec: |
|
||||||
|
cypress/e2e/testnet4/*.spec.ts
|
||||||
|
group: Tests on Chrome (${{ matrix.module }})
|
||||||
|
browser: "chrome"
|
||||||
|
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
|
||||||
|
env:
|
||||||
|
CYPRESS_REROUTE_TESTNET: true
|
||||||
|
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
|
||||||
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||||
|
|
||||||
validate_docker_json:
|
validate_docker_json:
|
||||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
@@ -359,4 +403,4 @@ jobs:
|
|||||||
- name: Validate JSON syntax
|
- name: Validate JSON syntax
|
||||||
run: |
|
run: |
|
||||||
cat mempool-config.json | jq
|
cat mempool-config.json | jq
|
||||||
working-directory: docker/docker/backend
|
working-directory: docker/docker/backend
|
||||||
@@ -155,6 +155,10 @@
|
|||||||
"API": "https://mempool.space/api/v1/services",
|
"API": "https://mempool.space/api/v1/services",
|
||||||
"ACCELERATIONS": false
|
"ACCELERATIONS": false
|
||||||
},
|
},
|
||||||
|
"STRATUM": {
|
||||||
|
"ENABLED": false,
|
||||||
|
"API": "http://localhost:1234"
|
||||||
|
},
|
||||||
"FIAT_PRICE": {
|
"FIAT_PRICE": {
|
||||||
"ENABLED": true,
|
"ENABLED": true,
|
||||||
"PAID": false,
|
"PAID": false,
|
||||||
|
|||||||
@@ -151,5 +151,9 @@
|
|||||||
"ENABLED": true,
|
"ENABLED": true,
|
||||||
"PAID": false,
|
"PAID": false,
|
||||||
"API_KEY": "__MEMPOOL_CURRENCY_API_KEY__"
|
"API_KEY": "__MEMPOOL_CURRENCY_API_KEY__"
|
||||||
|
},
|
||||||
|
"STRATUM": {
|
||||||
|
"ENABLED": false,
|
||||||
|
"API": "http://localhost:1234"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ describe('Mempool Backend Config', () => {
|
|||||||
PAID: false,
|
PAID: false,
|
||||||
API_KEY: '',
|
API_KEY: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(config.STRATUM).toStrictEqual({
|
||||||
|
ENABLED: false,
|
||||||
|
API: 'http://localhost:1234',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ class BitcoinRoutes {
|
|||||||
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
|
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
||||||
.post(config.MEMPOOL.API_URL_PREFIX + 'prevouts', this.$getPrevouts)
|
|
||||||
.post(config.MEMPOOL.API_URL_PREFIX + 'cpfp', this.getCpfpLocalTxs)
|
|
||||||
// Temporarily add txs/package endpoint for all backends until esplora supports it
|
// Temporarily add txs/package endpoint for all backends until esplora supports it
|
||||||
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage)
|
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage)
|
||||||
;
|
;
|
||||||
@@ -932,92 +930,6 @@ class BitcoinRoutes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async $getPrevouts(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const outpoints = req.body;
|
|
||||||
if (!Array.isArray(outpoints) || outpoints.some((item) => !/^[a-fA-F0-9]{64}$/.test(item.txid) || typeof item.vout !== 'number')) {
|
|
||||||
handleError(req, res, 400, 'Invalid outpoints format');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outpoints.length > 100) {
|
|
||||||
handleError(req, res, 400, 'Too many outpoints requested');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = Array(outpoints.length).fill(null);
|
|
||||||
const memPool = mempool.getMempool();
|
|
||||||
|
|
||||||
for (let i = 0; i < outpoints.length; i++) {
|
|
||||||
const outpoint = outpoints[i];
|
|
||||||
let prevout: IEsploraApi.Vout | null = null;
|
|
||||||
let unconfirmed: boolean | null = null;
|
|
||||||
|
|
||||||
const mempoolTx = memPool[outpoint.txid];
|
|
||||||
if (mempoolTx) {
|
|
||||||
if (outpoint.vout < mempoolTx.vout.length) {
|
|
||||||
prevout = mempoolTx.vout[outpoint.vout];
|
|
||||||
unconfirmed = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const rawPrevout = await bitcoinClient.getTxOut(outpoint.txid, outpoint.vout, false);
|
|
||||||
if (rawPrevout) {
|
|
||||||
prevout = {
|
|
||||||
value: Math.round(rawPrevout.value * 100000000),
|
|
||||||
scriptpubkey: rawPrevout.scriptPubKey.hex,
|
|
||||||
scriptpubkey_asm: rawPrevout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(rawPrevout.scriptPubKey.hex) : '',
|
|
||||||
scriptpubkey_type: transactionUtils.translateScriptPubKeyType(rawPrevout.scriptPubKey.type),
|
|
||||||
scriptpubkey_address: rawPrevout.scriptPubKey && rawPrevout.scriptPubKey.address ? rawPrevout.scriptPubKey.address : '',
|
|
||||||
};
|
|
||||||
unconfirmed = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore bitcoin client errors, just leave prevout as null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevout) {
|
|
||||||
result[i] = { prevout, unconfirmed };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
handleError(req, res, 500, 'Failed to get prevouts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCpfpLocalTxs(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const transactions = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(transactions) || transactions.some(tx =>
|
|
||||||
!tx || typeof tx !== 'object' ||
|
|
||||||
!/^[a-fA-F0-9]{64}$/.test(tx.txid) ||
|
|
||||||
typeof tx.weight !== 'number' ||
|
|
||||||
typeof tx.sigops !== 'number' ||
|
|
||||||
typeof tx.fee !== 'number' ||
|
|
||||||
!Array.isArray(tx.vin) ||
|
|
||||||
!Array.isArray(tx.vout)
|
|
||||||
)) {
|
|
||||||
handleError(req, res, 400, 'Invalid transactions format');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transactions.length > 1) {
|
|
||||||
handleError(req, res, 400, 'More than one transaction is not supported yet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cpfpInfo = calculateMempoolTxCpfp(transactions[0], mempool.getMempool(), true);
|
|
||||||
res.json([cpfpInfo]);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
handleError(req, res, 500, 'Failed to calculate CPFP info');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new BitcoinRoutes();
|
export default new BitcoinRoutes();
|
||||||
|
|||||||
@@ -167,10 +167,8 @@ export function calculateGoodBlockCpfp(height: number, transactions: MempoolTran
|
|||||||
/**
|
/**
|
||||||
* Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for
|
* Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for
|
||||||
* that transaction (and all others in the same cluster)
|
* that transaction (and all others in the same cluster)
|
||||||
* If the passed transaction is not guaranteed to be in the mempool, set localTx to true: this will
|
|
||||||
* prevent updating the CPFP data of other transactions in the cluster
|
|
||||||
*/
|
*/
|
||||||
export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }, localTx: boolean = false): CpfpInfo {
|
export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo {
|
||||||
if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) {
|
if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) {
|
||||||
tx.cpfpDirty = false;
|
tx.cpfpDirty = false;
|
||||||
return {
|
return {
|
||||||
@@ -200,27 +198,18 @@ export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool:
|
|||||||
totalFee += tx.fees.base;
|
totalFee += tx.fees.base;
|
||||||
}
|
}
|
||||||
const effectiveFeePerVsize = totalFee / totalVsize;
|
const effectiveFeePerVsize = totalFee / totalVsize;
|
||||||
|
for (const tx of cluster.values()) {
|
||||||
if (localTx) {
|
mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
|
||||||
tx.effectiveFeePerVsize = effectiveFeePerVsize;
|
mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
||||||
tx.ancestors = Array.from(cluster.get(tx.txid)?.ancestors.values() || []).map(ancestor => ({ txid: ancestor.txid, weight: ancestor.weight, fee: ancestor.fees.base }));
|
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
||||||
tx.descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !cluster.get(tx.txid)?.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
mempool[tx.txid].bestDescendant = null;
|
||||||
tx.bestDescendant = null;
|
mempool[tx.txid].cpfpChecked = true;
|
||||||
} else {
|
mempool[tx.txid].cpfpDirty = true;
|
||||||
for (const tx of cluster.values()) {
|
mempool[tx.txid].cpfpUpdated = Date.now();
|
||||||
mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
|
|
||||||
mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
|
||||||
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
|
|
||||||
mempool[tx.txid].bestDescendant = null;
|
|
||||||
mempool[tx.txid].cpfpChecked = true;
|
|
||||||
mempool[tx.txid].cpfpDirty = true;
|
|
||||||
mempool[tx.txid].cpfpUpdated = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
tx = mempool[tx.txid];
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx = mempool[tx.txid];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ancestors: tx.ancestors || [],
|
ancestors: tx.ancestors || [],
|
||||||
bestDescendant: tx.bestDescendant || null,
|
bestDescendant: tx.bestDescendant || null,
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ class RbfCache {
|
|||||||
|
|
||||||
|
|
||||||
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
|
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
|
||||||
if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) {
|
if ( !newTxExtended
|
||||||
|
|| !replaced?.length
|
||||||
|
|| this.txs.has(newTxExtended.txid)
|
||||||
|
|| !(replaced.some(tx => !this.replacedBy.has(tx.txid)))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
backend/src/api/services/stratum.ts
Normal file
105
backend/src/api/services/stratum.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import logger from '../../logger';
|
||||||
|
import config from '../../config';
|
||||||
|
import websocketHandler from '../websocket-handler';
|
||||||
|
|
||||||
|
export interface StratumJob {
|
||||||
|
pool: number;
|
||||||
|
height: number;
|
||||||
|
coinbase: string;
|
||||||
|
scriptsig: string;
|
||||||
|
reward: number;
|
||||||
|
jobId: string;
|
||||||
|
extraNonce: string;
|
||||||
|
extraNonce2Size: number;
|
||||||
|
prevHash: string;
|
||||||
|
coinbase1: string;
|
||||||
|
coinbase2: string;
|
||||||
|
merkleBranches: string[];
|
||||||
|
version: string;
|
||||||
|
bits: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
cleanJobs: boolean;
|
||||||
|
received: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStratumJob(obj: any): obj is StratumJob {
|
||||||
|
return obj
|
||||||
|
&& typeof obj === 'object'
|
||||||
|
&& 'pool' in obj
|
||||||
|
&& 'prevHash' in obj
|
||||||
|
&& 'height' in obj
|
||||||
|
&& 'received' in obj
|
||||||
|
&& 'version' in obj
|
||||||
|
&& 'timestamp' in obj
|
||||||
|
&& 'bits' in obj
|
||||||
|
&& 'merkleBranches' in obj
|
||||||
|
&& 'cleanJobs' in obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StratumApi {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private runWebsocketLoop: boolean = false;
|
||||||
|
private startedWebsocketLoop: boolean = false;
|
||||||
|
private websocketConnected: boolean = false;
|
||||||
|
private jobs: Record<string, StratumJob> = {};
|
||||||
|
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public getJobs(): Record<string, StratumJob> {
|
||||||
|
return this.jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWebsocketMessage(msg: any): void {
|
||||||
|
if (isStratumJob(msg)) {
|
||||||
|
this.jobs[msg.pool] = msg;
|
||||||
|
websocketHandler.handleNewStratumJob(this.jobs[msg.pool]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connectWebsocket(): Promise<void> {
|
||||||
|
if (!config.STRATUM.ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.runWebsocketLoop = true;
|
||||||
|
if (this.startedWebsocketLoop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (this.runWebsocketLoop) {
|
||||||
|
this.startedWebsocketLoop = true;
|
||||||
|
if (!this.ws) {
|
||||||
|
this.ws = new WebSocket(`${config.STRATUM.API}`);
|
||||||
|
this.websocketConnected = true;
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
logger.info('Stratum websocket opened');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (error) => {
|
||||||
|
logger.err('Stratum websocket error: ' + error);
|
||||||
|
this.ws = null;
|
||||||
|
this.websocketConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', () => {
|
||||||
|
logger.info('Stratum websocket closed');
|
||||||
|
this.ws = null;
|
||||||
|
this.websocketConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('message', (data, isBinary) => {
|
||||||
|
try {
|
||||||
|
const parsedMsg = JSON.parse((isBinary ? data : data.toString()) as string);
|
||||||
|
this.handleWebsocketMessage(parsedMsg);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to parse stratum websocket message: ' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new StratumApi();
|
||||||
@@ -420,29 +420,6 @@ class TransactionUtils {
|
|||||||
|
|
||||||
return { prioritized, deprioritized };
|
return { prioritized, deprioritized };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/bitcoin/bitcoin-api.ts#L324
|
|
||||||
public translateScriptPubKeyType(outputType: string): string {
|
|
||||||
const map = {
|
|
||||||
'pubkey': 'p2pk',
|
|
||||||
'pubkeyhash': 'p2pkh',
|
|
||||||
'scripthash': 'p2sh',
|
|
||||||
'witness_v0_keyhash': 'v0_p2wpkh',
|
|
||||||
'witness_v0_scripthash': 'v0_p2wsh',
|
|
||||||
'witness_v1_taproot': 'v1_p2tr',
|
|
||||||
'nonstandard': 'nonstandard',
|
|
||||||
'multisig': 'multisig',
|
|
||||||
'anchor': 'anchor',
|
|
||||||
'nulldata': 'op_return'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (map[outputType]) {
|
|
||||||
return map[outputType];
|
|
||||||
} else {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new TransactionUtils();
|
export default new TransactionUtils();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface AddressTransactions {
|
|||||||
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
||||||
import { calculateMempoolTxCpfp } from './cpfp';
|
import { calculateMempoolTxCpfp } from './cpfp';
|
||||||
import { getRecentFirstSeen } from '../utils/file-read';
|
import { getRecentFirstSeen } from '../utils/file-read';
|
||||||
|
import stratumApi, { StratumJob } from './services/stratum';
|
||||||
|
|
||||||
// valid 'want' subscriptions
|
// valid 'want' subscriptions
|
||||||
const wantable = [
|
const wantable = [
|
||||||
@@ -403,6 +404,16 @@ class WebsocketHandler {
|
|||||||
delete client['track-mempool'];
|
delete client['track-mempool'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsedMessage && parsedMessage['track-stratum'] != null) {
|
||||||
|
if (parsedMessage['track-stratum']) {
|
||||||
|
const sub = parsedMessage['track-stratum'];
|
||||||
|
client['track-stratum'] = sub;
|
||||||
|
response['stratumJobs'] = this.socketData['stratumJobs'];
|
||||||
|
} else {
|
||||||
|
client['track-stratum'] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(response).length) {
|
if (Object.keys(response).length) {
|
||||||
client.send(this.serializeResponse(response));
|
client.send(this.serializeResponse(response));
|
||||||
}
|
}
|
||||||
@@ -1384,6 +1395,23 @@ class WebsocketHandler {
|
|||||||
await statistics.runStatistics();
|
await statistics.runStatistics();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public handleNewStratumJob(job: StratumJob): void {
|
||||||
|
this.updateSocketDataFields({ 'stratumJobs': stratumApi.getJobs() });
|
||||||
|
|
||||||
|
for (const server of this.webSocketServers) {
|
||||||
|
server.clients.forEach((client) => {
|
||||||
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (client['track-stratum'] && (client['track-stratum'] === 'all' || client['track-stratum'] === job.pool)) {
|
||||||
|
client.send(JSON.stringify({
|
||||||
|
'stratumJob': job
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// takes a dictionary of JSON serialized values
|
// takes a dictionary of JSON serialized values
|
||||||
// and zips it together into a valid JSON object
|
// and zips it together into a valid JSON object
|
||||||
private serializeResponse(response): string {
|
private serializeResponse(response): string {
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ interface IConfig {
|
|||||||
WALLETS: {
|
WALLETS: {
|
||||||
ENABLED: boolean;
|
ENABLED: boolean;
|
||||||
WALLETS: string[];
|
WALLETS: string[];
|
||||||
|
},
|
||||||
|
STRATUM: {
|
||||||
|
ENABLED: boolean;
|
||||||
|
API: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +336,10 @@ const defaults: IConfig = {
|
|||||||
'ENABLED': false,
|
'ENABLED': false,
|
||||||
'WALLETS': [],
|
'WALLETS': [],
|
||||||
},
|
},
|
||||||
|
'STRATUM': {
|
||||||
|
'ENABLED': false,
|
||||||
|
'API': 'http://localhost:1234',
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
class Config implements IConfig {
|
class Config implements IConfig {
|
||||||
@@ -354,6 +362,7 @@ class Config implements IConfig {
|
|||||||
REDIS: IConfig['REDIS'];
|
REDIS: IConfig['REDIS'];
|
||||||
FIAT_PRICE: IConfig['FIAT_PRICE'];
|
FIAT_PRICE: IConfig['FIAT_PRICE'];
|
||||||
WALLETS: IConfig['WALLETS'];
|
WALLETS: IConfig['WALLETS'];
|
||||||
|
STRATUM: IConfig['STRATUM'];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const configs = this.merge(configFromFile, defaults);
|
const configs = this.merge(configFromFile, defaults);
|
||||||
@@ -376,6 +385,7 @@ class Config implements IConfig {
|
|||||||
this.REDIS = configs.REDIS;
|
this.REDIS = configs.REDIS;
|
||||||
this.FIAT_PRICE = configs.FIAT_PRICE;
|
this.FIAT_PRICE = configs.FIAT_PRICE;
|
||||||
this.WALLETS = configs.WALLETS;
|
this.WALLETS = configs.WALLETS;
|
||||||
|
this.STRATUM = configs.STRATUM;
|
||||||
}
|
}
|
||||||
|
|
||||||
merge = (...objects: object[]): IConfig => {
|
merge = (...objects: object[]): IConfig => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import accelerationRoutes from './api/acceleration/acceleration.routes';
|
|||||||
import aboutRoutes from './api/about.routes';
|
import aboutRoutes from './api/about.routes';
|
||||||
import mempoolBlocks from './api/mempool-blocks';
|
import mempoolBlocks from './api/mempool-blocks';
|
||||||
import walletApi from './api/services/wallets';
|
import walletApi from './api/services/wallets';
|
||||||
|
import stratumApi from './api/services/stratum';
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
private wss: WebSocket.Server | undefined;
|
private wss: WebSocket.Server | undefined;
|
||||||
@@ -320,6 +321,9 @@ class Server {
|
|||||||
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
|
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
|
||||||
|
|
||||||
accelerationApi.connectWebsocket();
|
accelerationApi.connectWebsocket();
|
||||||
|
if (config.STRATUM.ENABLED) {
|
||||||
|
stratumApi.connectWebsocket();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUpHttpApiRoutes(): void {
|
setUpHttpApiRoutes(): void {
|
||||||
|
|||||||
@@ -148,6 +148,10 @@
|
|||||||
"API": "__MEMPOOL_SERVICES_API__",
|
"API": "__MEMPOOL_SERVICES_API__",
|
||||||
"ACCELERATIONS": __MEMPOOL_SERVICES_ACCELERATIONS__
|
"ACCELERATIONS": __MEMPOOL_SERVICES_ACCELERATIONS__
|
||||||
},
|
},
|
||||||
|
"STRATUM": {
|
||||||
|
"ENABLED": __STRATUM_ENABLED__,
|
||||||
|
"API": "__STRATUM_API__"
|
||||||
|
},
|
||||||
"REDIS": {
|
"REDIS": {
|
||||||
"ENABLED": __REDIS_ENABLED__,
|
"ENABLED": __REDIS_ENABLED__,
|
||||||
"UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__",
|
"UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__",
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
|
|||||||
__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}
|
__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}
|
||||||
__MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false}
|
__MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false}
|
||||||
|
|
||||||
|
# STRATUM
|
||||||
|
__STRATUM_ENABLED__=${STRATUM_ENABLED:=false}
|
||||||
|
__STRATUM_API__=${STRATUM_API:="http://localhost:1234"}
|
||||||
|
|
||||||
# REDIS
|
# REDIS
|
||||||
__REDIS_ENABLED__=${REDIS_ENABLED:=false}
|
__REDIS_ENABLED__=${REDIS_ENABLED:=false}
|
||||||
__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""}
|
__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""}
|
||||||
@@ -300,6 +304,10 @@ sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.j
|
|||||||
sed -i "s!__MEMPOOL_SERVICES_API__!${__MEMPOOL_SERVICES_API__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_SERVICES_API__!${__MEMPOOL_SERVICES_API__}!g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_SERVICES_ACCELERATIONS__!${__MEMPOOL_SERVICES_ACCELERATIONS__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_SERVICES_ACCELERATIONS__!${__MEMPOOL_SERVICES_ACCELERATIONS__}!g" mempool-config.json
|
||||||
|
|
||||||
|
# STRATUM
|
||||||
|
sed -i "s!__STRATUM_ENABLED__!${__STRATUM_ENABLED__}!g" mempool-config.json
|
||||||
|
sed -i "s!__STRATUM_API__!${__STRATUM_API__}!g" mempool-config.json
|
||||||
|
|
||||||
# REDIS
|
# REDIS
|
||||||
sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json
|
sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json
|
||||||
sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json
|
sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json
|
||||||
|
|||||||
@@ -344,7 +344,9 @@ describe('Mainnet', () => {
|
|||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.waitForSkeletonGone();
|
cy.waitForSkeletonGone();
|
||||||
|
|
||||||
cy.changeNetwork('testnet4');
|
//TODO(knorrium): add a check for the proxied server
|
||||||
|
// cy.changeNetwork('testnet4');
|
||||||
|
|
||||||
cy.changeNetwork('signet');
|
cy.changeNetwork('signet');
|
||||||
cy.changeNetwork('mainnet');
|
cy.changeNetwork('mainnet');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,5 +27,6 @@
|
|||||||
"ACCELERATOR": false,
|
"ACCELERATOR": false,
|
||||||
"ACCELERATOR_BUTTON": true,
|
"ACCELERATOR_BUTTON": true,
|
||||||
"PUBLIC_ACCELERATIONS": false,
|
"PUBLIC_ACCELERATIONS": false,
|
||||||
|
"STRATUM_ENABLED": false,
|
||||||
"SERVICES_API": "https://mempool.space/api/v1/services"
|
"SERVICES_API": "https://mempool.space/api/v1/services"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ const fs = require('fs');
|
|||||||
let PROXY_CONFIG = require('./proxy.conf');
|
let PROXY_CONFIG = require('./proxy.conf');
|
||||||
|
|
||||||
PROXY_CONFIG.forEach(entry => {
|
PROXY_CONFIG.forEach(entry => {
|
||||||
entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space");
|
const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.fmt.mempool.space';
|
||||||
entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space");
|
console.log(`e2e tests running against ${hostname}`);
|
||||||
|
entry.target = entry.target.replace("mempool.space", hostname);
|
||||||
|
entry.target = entry.target.replace("liquid.network", "liquid-staging.fmt.mempool.space");
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = PROXY_CONFIG;
|
module.exports = PROXY_CONFIG;
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<div class="box card w-100" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
|
<div class="box card w-100 accelerate-checkout-inner" [class.input-disabled]="isCheckoutLocked > 0" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
|
||||||
@if (accelerateError) {
|
@if (accelerateError) {
|
||||||
<div class="row mb-1 text-center">
|
@if (accelerateError.includes('Payment declined')) {
|
||||||
<div class="col-sm">
|
<div class="row mb-1 text-center">
|
||||||
<h1 style="font-size: larger;" i18n="accelerator.sorry-error-title">Sorry, something went wrong!</h1>
|
<div class="col-sm">
|
||||||
|
<h1 style="font-size: larger;">{{ accelerateError }}</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
} @else {
|
||||||
|
<div class="row mb-1 text-center">
|
||||||
|
<div class="col-sm">
|
||||||
|
<h1 style="font-size: larger;" i18n="accelerator.sorry-error-title">Sorry, something went wrong!</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="row text-center mt-1">
|
<div class="row text-center mt-1">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="d-flex flex-row justify-content-center align-items-center">
|
<div class="d-flex flex-row justify-content-center align-items-center">
|
||||||
@@ -361,7 +369,7 @@
|
|||||||
<div class="row text-center justify-content-center mx-2">
|
<div class="row text-center justify-content-center mx-2">
|
||||||
<p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p>
|
<p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p>
|
||||||
</div>
|
</div>
|
||||||
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) {
|
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
|
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
|
||||||
<p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container> <small style="font-family: monospace;">{{ cost | number }}</small> <span class="symbol" i18n="shared.sats">sats</span></p>
|
<p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container> <small style="font-family: monospace;">{{ cost | number }}</small> <span class="symbol" i18n="shared.sats">sats</span></p>
|
||||||
@@ -484,6 +492,11 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (isTokenizing > 0) {
|
||||||
|
<div class="d-flex flex-row justify-content-center">
|
||||||
|
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
color: var(--green)
|
color: var(--green)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accelerate-checkout-inner {
|
||||||
|
&.input-disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.paymentMethod {
|
.paymentMethod {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: var(--secondary);
|
background-color: var(--secondary);
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
calculating = true;
|
calculating = true;
|
||||||
processing = false;
|
processing = false;
|
||||||
|
isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked
|
||||||
|
isTokenizing = 0; // reference counter, 0 = false, >0 = true
|
||||||
selectedOption: 'wait' | 'accel';
|
selectedOption: 'wait' | 'accel';
|
||||||
cantPayReason = '';
|
cantPayReason = '';
|
||||||
quoteError = ''; // error fetching estimate or initial data
|
quoteError = ''; // error fetching estimate or initial data
|
||||||
@@ -154,7 +156,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
this.accelerateError = null;
|
this.accelerateError = null;
|
||||||
this.timePaid = 0;
|
this.timePaid = 0;
|
||||||
this.btcpayInvoiceFailed = false;
|
this.btcpayInvoiceFailed = false;
|
||||||
this.moveToStep('summary');
|
this.moveToStep('summary', true);
|
||||||
} else {
|
} else {
|
||||||
this.auth = auth;
|
this.auth = auth;
|
||||||
}
|
}
|
||||||
@@ -163,11 +165,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.get('cash_request_id')) { // Redirected from cashapp
|
if (urlParams.get('cash_request_id')) { // Redirected from cashapp
|
||||||
this.moveToStep('processing');
|
this.moveToStep('processing', true);
|
||||||
this.insertSquare();
|
this.insertSquare();
|
||||||
this.setupSquare();
|
this.setupSquare();
|
||||||
} else {
|
} else {
|
||||||
this.moveToStep('summary');
|
this.moveToStep('summary', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
this.conversionsSubscription = this.stateService.conversions$.subscribe(
|
||||||
@@ -192,14 +194,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
if (changes.accelerating && this.accelerating) {
|
if (changes.accelerating && this.accelerating) {
|
||||||
if (this.step === 'processing' || this.step === 'paid') {
|
if (this.step === 'processing' || this.step === 'paid') {
|
||||||
this.moveToStep('success');
|
this.moveToStep('success', true);
|
||||||
} else { // Edge case where the transaction gets accelerated by someone else or on another session
|
} else { // Edge case where the transaction gets accelerated by someone else or on another session
|
||||||
this.closeModal();
|
this.closeModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
moveToStep(step: CheckoutStep): void {
|
moveToStep(step: CheckoutStep, force: boolean = false): void {
|
||||||
|
if (this.isCheckoutLocked > 0 && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
this._step = step;
|
this._step = step;
|
||||||
if (this.timeoutTimer) {
|
if (this.timeoutTimer) {
|
||||||
@@ -242,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
closeModal(): void {
|
closeModal(): void {
|
||||||
this.completed.emit(true);
|
this.completed.emit(true);
|
||||||
this.moveToStep('summary');
|
this.moveToStep('summary', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -393,7 +398,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
this.audioService.playSound('ascend-chime-cartoon');
|
this.audioService.playSound('ascend-chime-cartoon');
|
||||||
this.showSuccess = true;
|
this.showSuccess = true;
|
||||||
this.estimateSubscription.unsubscribe();
|
this.estimateSubscription.unsubscribe();
|
||||||
this.moveToStep('paid');
|
this.moveToStep('paid', true);
|
||||||
},
|
},
|
||||||
error: (response) => {
|
error: (response) => {
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
@@ -503,56 +508,75 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.loadingApplePay = false;
|
this.loadingApplePay = false;
|
||||||
applePayButton.addEventListener('click', async event => {
|
applePayButton.addEventListener('click', async event => {
|
||||||
|
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const tokenResult = await this.applePay.tokenize();
|
try {
|
||||||
if (tokenResult?.status === 'OK') {
|
// lock the checkout UI and show a loading spinner until the square modals are finished
|
||||||
const card = tokenResult.details?.card;
|
this.isCheckoutLocked++;
|
||||||
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
|
this.isTokenizing++;
|
||||||
console.error(`Cannot retreive payment card details`);
|
const tokenResult = await this.applePay.tokenize();
|
||||||
this.accelerateError = 'apple_pay_no_card_details';
|
if (tokenResult?.status === 'OK') {
|
||||||
this.processing = false;
|
const card = tokenResult.details?.card;
|
||||||
return;
|
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
|
||||||
}
|
console.error(`Cannot retreive payment card details`);
|
||||||
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
|
this.accelerateError = 'apple_pay_no_card_details';
|
||||||
this.servicesApiService.accelerateWithApplePay$(
|
|
||||||
this.tx.txid,
|
|
||||||
tokenResult.token,
|
|
||||||
cardTag,
|
|
||||||
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
|
||||||
costUSD
|
|
||||||
).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
return;
|
||||||
this.audioService.playSound('ascend-chime-cartoon');
|
|
||||||
if (this.applePay) {
|
|
||||||
this.applePay.destroy();
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.moveToStep('paid');
|
|
||||||
}, 1000);
|
|
||||||
},
|
|
||||||
error: (response) => {
|
|
||||||
this.processing = false;
|
|
||||||
this.accelerateError = response.error;
|
|
||||||
if (!(response.status === 403 && response.error === 'not_available')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
// Reset everything by reloading the page :D, can be improved
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
|
||||||
} else {
|
// keep checkout in loading state until the acceleration request completes
|
||||||
this.processing = false;
|
this.isTokenizing++;
|
||||||
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
|
this.isCheckoutLocked++;
|
||||||
if (tokenResult.errors) {
|
this.servicesApiService.accelerateWithApplePay$(
|
||||||
errorMessage += ` and errors: ${JSON.stringify(
|
this.tx.txid,
|
||||||
tokenResult.errors,
|
tokenResult.token,
|
||||||
)}`;
|
cardTag,
|
||||||
|
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
||||||
|
costUSD
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.processing = false;
|
||||||
|
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
||||||
|
this.audioService.playSound('ascend-chime-cartoon');
|
||||||
|
if (this.applePay) {
|
||||||
|
this.applePay.destroy();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isTokenizing--;
|
||||||
|
this.isCheckoutLocked--;
|
||||||
|
this.moveToStep('paid', true);
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
error: (response) => {
|
||||||
|
this.processing = false;
|
||||||
|
this.accelerateError = response.error;
|
||||||
|
if (!(response.status === 403 && response.error === 'not_available')) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isTokenizing--;
|
||||||
|
this.isCheckoutLocked--;
|
||||||
|
// Reset everything by reloading the page :D, can be improved
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.processing = false;
|
||||||
|
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
|
||||||
|
if (tokenResult.errors) {
|
||||||
|
errorMessage += ` and errors: ${JSON.stringify(
|
||||||
|
tokenResult.errors,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
} finally {
|
||||||
|
// always unlock the checkout once we're finished
|
||||||
|
this.isTokenizing--;
|
||||||
|
this.isCheckoutLocked--;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -602,64 +626,84 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
this.loadingGooglePay = false;
|
this.loadingGooglePay = false;
|
||||||
|
|
||||||
document.getElementById('google-pay-button').addEventListener('click', async event => {
|
document.getElementById('google-pay-button').addEventListener('click', async event => {
|
||||||
|
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const tokenResult = await this.googlePay.tokenize();
|
try {
|
||||||
if (tokenResult?.status === 'OK') {
|
// lock the checkout UI and show a loading spinner until the square modals are finished
|
||||||
const card = tokenResult.details?.card;
|
this.isCheckoutLocked++;
|
||||||
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
|
this.isTokenizing++;
|
||||||
console.error(`Cannot retreive payment card details`);
|
const tokenResult = await this.googlePay.tokenize();
|
||||||
this.accelerateError = 'apple_pay_no_card_details';
|
if (tokenResult?.status === 'OK') {
|
||||||
this.processing = false;
|
const card = tokenResult.details?.card;
|
||||||
return;
|
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
|
||||||
}
|
console.error(`Cannot retreive payment card details`);
|
||||||
const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2));
|
this.accelerateError = 'apple_pay_no_card_details';
|
||||||
if (!verificationToken) {
|
|
||||||
console.error(`SCA verification failed`);
|
|
||||||
this.accelerateError = 'SCA Verification Failed. Payment Declined.';
|
|
||||||
this.processing = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
|
|
||||||
this.servicesApiService.accelerateWithGooglePay$(
|
|
||||||
this.tx.txid,
|
|
||||||
tokenResult.token,
|
|
||||||
verificationToken,
|
|
||||||
cardTag,
|
|
||||||
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
|
||||||
costUSD
|
|
||||||
).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
return;
|
||||||
this.audioService.playSound('ascend-chime-cartoon');
|
|
||||||
if (this.googlePay) {
|
|
||||||
this.googlePay.destroy();
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.moveToStep('paid');
|
|
||||||
}, 1000);
|
|
||||||
},
|
|
||||||
error: (response) => {
|
|
||||||
this.processing = false;
|
|
||||||
this.accelerateError = response.error;
|
|
||||||
if (!(response.status === 403 && response.error === 'not_available')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
// Reset everything by reloading the page :D, can be improved
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2));
|
||||||
} else {
|
if (!verificationToken || !verificationToken.token) {
|
||||||
this.processing = false;
|
console.error(`SCA verification failed`);
|
||||||
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
|
this.accelerateError = 'SCA Verification Failed. Payment Declined.';
|
||||||
if (tokenResult.errors) {
|
this.processing = false;
|
||||||
errorMessage += ` and errors: ${JSON.stringify(
|
return;
|
||||||
tokenResult.errors,
|
}
|
||||||
)}`;
|
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
|
||||||
|
// keep checkout in loading state until the acceleration request completes
|
||||||
|
this.isCheckoutLocked++;
|
||||||
|
this.isTokenizing++;
|
||||||
|
this.servicesApiService.accelerateWithGooglePay$(
|
||||||
|
this.tx.txid,
|
||||||
|
tokenResult.token,
|
||||||
|
verificationToken.token,
|
||||||
|
cardTag,
|
||||||
|
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
||||||
|
costUSD,
|
||||||
|
verificationToken.userChallenged
|
||||||
|
).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.processing = false;
|
||||||
|
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
||||||
|
this.audioService.playSound('ascend-chime-cartoon');
|
||||||
|
if (this.googlePay) {
|
||||||
|
this.googlePay.destroy();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isTokenizing--;
|
||||||
|
this.isCheckoutLocked--;
|
||||||
|
this.moveToStep('paid', true);
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
error: (response) => {
|
||||||
|
this.processing = false;
|
||||||
|
this.accelerateError = response.error;
|
||||||
|
this.isTokenizing--;
|
||||||
|
this.isCheckoutLocked--;
|
||||||
|
if (!(response.status === 403 && response.error === 'not_available')) {
|
||||||
|
setTimeout(() => {
|
||||||
|
// Reset everything by reloading the page :D, can be improved
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.processing = false;
|
||||||
|
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
|
||||||
|
if (tokenResult.errors) {
|
||||||
|
errorMessage += ` and errors: ${JSON.stringify(
|
||||||
|
tokenResult.errors,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
throw new Error(errorMessage);
|
} finally {
|
||||||
|
// always unlock the checkout once we're finished
|
||||||
|
this.isTokenizing--;
|
||||||
|
this.isCheckoutLocked--;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -726,7 +770,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
this.cashAppPay.destroy();
|
this.cashAppPay.destroy();
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.moveToStep('paid');
|
this.moveToStep('paid', true);
|
||||||
if (window.history.replaceState) {
|
if (window.history.replaceState) {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ''));
|
window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ''));
|
||||||
@@ -741,7 +785,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
// Reset everything by reloading the page :D, can be improved
|
// Reset everything by reloading the page :D, can be improved
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
|
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
|
||||||
}, 3000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -752,9 +796,9 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Required in SCA Mandated Regions: Learn more at https://developer.squareup.com/docs/sca-overview
|
* https://developer.squareup.com/docs/sca-overview
|
||||||
*/
|
*/
|
||||||
async $verifyBuyer(payments, token, details, amount) {
|
async $verifyBuyer(payments, token, details, amount): Promise<{token: string, userChallenged: boolean}> {
|
||||||
const verificationDetails = {
|
const verificationDetails = {
|
||||||
amount: amount,
|
amount: amount,
|
||||||
currencyCode: 'USD',
|
currencyCode: 'USD',
|
||||||
@@ -774,7 +818,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
token,
|
token,
|
||||||
verificationDetails,
|
verificationDetails,
|
||||||
);
|
);
|
||||||
return verificationResults.token;
|
return verificationResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -800,7 +844,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
|||||||
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
|
||||||
this.audioService.playSound('ascend-chime-cartoon');
|
this.audioService.playSound('ascend-chime-cartoon');
|
||||||
this.estimateSubscription.unsubscribe();
|
this.estimateSubscription.unsubscribe();
|
||||||
this.moveToStep('paid');
|
this.moveToStep('paid', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoggedIn(): boolean {
|
isLoggedIn(): boolean {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||||||
|
|
||||||
aggregatedHistory$: Observable<any>;
|
aggregatedHistory$: Observable<any>;
|
||||||
statsSubscription: Subscription;
|
statsSubscription: Subscription;
|
||||||
|
aggregatedHistorySubscription: Subscription;
|
||||||
|
fragmentSubscription: Subscription;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
formatNumber = formatNumber;
|
formatNumber = formatNumber;
|
||||||
timespan = '';
|
timespan = '';
|
||||||
@@ -79,8 +81,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||||||
}
|
}
|
||||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
||||||
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
|
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
|
||||||
|
|
||||||
this.route.fragment.subscribe((fragment) => {
|
this.fragmentSubscription = this.route.fragment.subscribe((fragment) => {
|
||||||
if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) {
|
if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) {
|
||||||
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
|
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||||||
share(),
|
share(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.aggregatedHistory$.subscribe();
|
this.aggregatedHistorySubscription = this.aggregatedHistory$.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
@@ -335,8 +337,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (this.statsSubscription) {
|
this.aggregatedHistorySubscription?.unsubscribe();
|
||||||
this.statsSubscription.unsubscribe();
|
this.fragmentSubscription?.unsubscribe();
|
||||||
}
|
this.statsSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,25 +478,30 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extendSummary(summary) {
|
extendSummary(summary) {
|
||||||
let extendedSummary = summary.slice();
|
const extendedSummary = summary.slice();
|
||||||
|
|
||||||
// Add a point at today's date to make the graph end at the current time
|
// Add a point at today's date to make the graph end at the current time
|
||||||
extendedSummary.unshift({ time: Date.now() / 1000, value: 0 });
|
extendedSummary.unshift({ time: Date.now() / 1000, value: 0 });
|
||||||
extendedSummary.reverse();
|
|
||||||
|
|
||||||
let oneHour = 60 * 60;
|
let maxTime = Date.now() / 1000;
|
||||||
|
|
||||||
|
const oneHour = 60 * 60;
|
||||||
// Fill gaps longer than interval
|
// Fill gaps longer than interval
|
||||||
for (let i = 0; i < extendedSummary.length - 1; i++) {
|
for (let i = 0; i < extendedSummary.length - 1; i++) {
|
||||||
let hours = Math.floor((extendedSummary[i + 1].time - extendedSummary[i].time) / oneHour);
|
if (extendedSummary[i].time > maxTime) {
|
||||||
|
extendedSummary[i].time = maxTime - 30;
|
||||||
|
}
|
||||||
|
maxTime = extendedSummary[i].time;
|
||||||
|
const hours = Math.floor((extendedSummary[i].time - extendedSummary[i + 1].time) / oneHour);
|
||||||
if (hours > 1) {
|
if (hours > 1) {
|
||||||
for (let j = 1; j < hours; j++) {
|
for (let j = 1; j < hours; j++) {
|
||||||
let newTime = extendedSummary[i].time + oneHour * j;
|
const newTime = extendedSummary[i].time - oneHour * j;
|
||||||
extendedSummary.splice(i + j, 0, { time: newTime, value: 0 });
|
extendedSummary.splice(i + j, 0, { time: newTime, value: 0 });
|
||||||
}
|
}
|
||||||
i += hours - 1;
|
i += hours - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return extendedSummary.reverse();
|
return extendedSummary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
handleKeyboardEvents(event: KeyboardEvent) {
|
handleKeyboardEvents(event: KeyboardEvent) {
|
||||||
if (event.target instanceof HTMLInputElement) {
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// prevent arrow key horizontal scrolling
|
// prevent arrow key horizontal scrolling
|
||||||
|
|||||||
@@ -172,13 +172,19 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (this.animationFrameRequest) {
|
if (this.animationFrameRequest) {
|
||||||
cancelAnimationFrame(this.animationFrameRequest);
|
cancelAnimationFrame(this.animationFrameRequest);
|
||||||
clearTimeout(this.animationHeartBeat);
|
|
||||||
}
|
}
|
||||||
|
clearTimeout(this.animationHeartBeat);
|
||||||
if (this.canvas) {
|
if (this.canvas) {
|
||||||
this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost);
|
this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost);
|
||||||
this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored);
|
this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored);
|
||||||
this.themeChangedSubscription?.unsubscribe();
|
|
||||||
}
|
}
|
||||||
|
if (this.scene) {
|
||||||
|
this.scene.destroy();
|
||||||
|
}
|
||||||
|
this.vertexArray.destroy();
|
||||||
|
this.vertexArray = null;
|
||||||
|
this.themeChangedSubscription?.unsubscribe();
|
||||||
|
this.searchSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(direction): void {
|
clear(direction): void {
|
||||||
@@ -447,7 +453,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
}
|
}
|
||||||
this.applyQueuedUpdates();
|
this.applyQueuedUpdates();
|
||||||
// skip re-render if there's no change to the scene
|
// skip re-render if there's no change to the scene
|
||||||
if (this.scene && this.gl) {
|
if (this.scene && this.gl && this.vertexArray) {
|
||||||
/* SET UP SHADER UNIFORMS */
|
/* SET UP SHADER UNIFORMS */
|
||||||
// screen dimensions
|
// screen dimensions
|
||||||
this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
|
this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
|
||||||
@@ -489,9 +495,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) {
|
if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) {
|
||||||
this.doRun();
|
this.doRun();
|
||||||
} else {
|
} else {
|
||||||
if (this.animationHeartBeat) {
|
clearTimeout(this.animationHeartBeat);
|
||||||
clearTimeout(this.animationHeartBeat);
|
|
||||||
}
|
|
||||||
this.animationHeartBeat = window.setTimeout(() => {
|
this.animationHeartBeat = window.setTimeout(() => {
|
||||||
this.start();
|
this.start();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class FastVertexArray {
|
|||||||
freeSlots: number[];
|
freeSlots: number[];
|
||||||
lastSlot: number;
|
lastSlot: number;
|
||||||
dirty = false;
|
dirty = false;
|
||||||
|
destroyed = false;
|
||||||
|
|
||||||
constructor(length, stride) {
|
constructor(length, stride) {
|
||||||
this.length = length;
|
this.length = length;
|
||||||
@@ -32,6 +33,9 @@ export class FastVertexArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
insert(sprite: TxSprite): number {
|
insert(sprite: TxSprite): number {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.count++;
|
this.count++;
|
||||||
|
|
||||||
let position;
|
let position;
|
||||||
@@ -45,11 +49,14 @@ export class FastVertexArray {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.sprites[position] = sprite;
|
this.sprites[position] = sprite;
|
||||||
return position;
|
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(index: number): void {
|
remove(index: number): void {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.count--;
|
this.count--;
|
||||||
this.clearData(index);
|
this.clearData(index);
|
||||||
this.freeSlots.push(index);
|
this.freeSlots.push(index);
|
||||||
@@ -61,20 +68,26 @@ export class FastVertexArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setData(index: number, dataChunk: number[]): void {
|
setData(index: number, dataChunk: number[]): void {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.data.set(dataChunk, (index * this.stride));
|
this.data.set(dataChunk, (index * this.stride));
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearData(index: number): void {
|
private clearData(index: number): void {
|
||||||
this.data.fill(0, (index * this.stride), ((index + 1) * this.stride));
|
this.data.fill(0, (index * this.stride), ((index + 1) * this.stride));
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getData(index: number): Float32Array {
|
getData(index: number): Float32Array {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return this.data.subarray(index, this.stride);
|
return this.data.subarray(index, this.stride);
|
||||||
}
|
}
|
||||||
|
|
||||||
expand(): void {
|
private expand(): void {
|
||||||
this.length *= 2;
|
this.length *= 2;
|
||||||
const newData = new Float32Array(this.length * this.stride);
|
const newData = new Float32Array(this.length * this.stride);
|
||||||
newData.set(this.data);
|
newData.set(this.data);
|
||||||
@@ -82,7 +95,7 @@ export class FastVertexArray {
|
|||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
compact(): void {
|
private compact(): void {
|
||||||
// New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512)
|
// New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512)
|
||||||
const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count))));
|
const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count))));
|
||||||
if (newLength !== this.length) {
|
if (newLength !== this.length) {
|
||||||
@@ -110,4 +123,13 @@ export class FastVertexArray {
|
|||||||
getVertexData(): Float32Array {
|
getVertexData(): Float32Array {
|
||||||
return this.data;
|
return this.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.data = null;
|
||||||
|
this.sprites = null;
|
||||||
|
this.freeSlots = null;
|
||||||
|
this.lastSlot = 0;
|
||||||
|
this.dirty = false;
|
||||||
|
this.destroyed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export class BlockViewComponent implements OnInit, OnDestroy {
|
|||||||
this.isLoadingBlock = false;
|
this.isLoadingBlock = false;
|
||||||
this.isLoadingOverview = true;
|
this.isLoadingOverview = true;
|
||||||
}),
|
}),
|
||||||
shareReplay(1)
|
shareReplay({ bufferSize: 1, refCount: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
this.overviewSubscription = block$.pipe(
|
this.overviewSubscription = block$.pipe(
|
||||||
@@ -176,5 +176,8 @@ export class BlockViewComponent implements OnInit, OnDestroy {
|
|||||||
if (this.queryParamsSubscription) {
|
if (this.queryParamsSubscription) {
|
||||||
this.queryParamsSubscription.unsubscribe();
|
this.queryParamsSubscription.unsubscribe();
|
||||||
}
|
}
|
||||||
|
if (this.blockGraph) {
|
||||||
|
this.blockGraph.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
|
|||||||
this.openGraphService.waitOver('block-data-' + this.rawId);
|
this.openGraphService.waitOver('block-data-' + this.rawId);
|
||||||
}),
|
}),
|
||||||
throttleTime(50, asyncScheduler, { leading: true, trailing: true }),
|
throttleTime(50, asyncScheduler, { leading: true, trailing: true }),
|
||||||
shareReplay(1)
|
shareReplay({ bufferSize: 1, refCount: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
this.overviewSubscription = block$.pipe(
|
this.overviewSubscription = block$.pipe(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core';
|
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core';
|
||||||
import { Location } from '@angular/common';
|
import { Location } from '@angular/common';
|
||||||
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
|
||||||
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||||
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators';
|
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter, take } from 'rxjs/operators';
|
||||||
import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs';
|
import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs';
|
||||||
import { StateService } from '@app/services/state.service';
|
import { StateService } from '@app/services/state.service';
|
||||||
import { SeoService } from '@app/services/seo.service';
|
import { SeoService } from '@app/services/seo.service';
|
||||||
@@ -68,6 +68,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
|
paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
|
||||||
numUnexpected: number = 0;
|
numUnexpected: number = 0;
|
||||||
mode: 'projected' | 'actual' = 'projected';
|
mode: 'projected' | 'actual' = 'projected';
|
||||||
|
currentQueryParams: Params;
|
||||||
|
|
||||||
overviewSubscription: Subscription;
|
overviewSubscription: Subscription;
|
||||||
accelerationsSubscription: Subscription;
|
accelerationsSubscription: Subscription;
|
||||||
@@ -80,8 +81,8 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
timeLtr: boolean;
|
timeLtr: boolean;
|
||||||
childChangeSubscription: Subscription;
|
childChangeSubscription: Subscription;
|
||||||
auditPrefSubscription: Subscription;
|
auditPrefSubscription: Subscription;
|
||||||
|
isAuditEnabledSubscription: Subscription;
|
||||||
oobSubscription: Subscription;
|
oobSubscription: Subscription;
|
||||||
|
|
||||||
priceSubscription: Subscription;
|
priceSubscription: Subscription;
|
||||||
blockConversion: Price;
|
blockConversion: Price;
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
this.setAuditAvailable(this.auditSupported);
|
this.setAuditAvailable(this.auditSupported);
|
||||||
|
|
||||||
if (this.auditSupported) {
|
if (this.auditSupported) {
|
||||||
this.isAuditEnabledFromParam().subscribe(auditParam => {
|
this.isAuditEnabledSubscription = this.isAuditEnabledFromParam().subscribe(auditParam => {
|
||||||
if (this.auditParamEnabled) {
|
if (this.auditParamEnabled) {
|
||||||
this.auditModeEnabled = auditParam;
|
this.auditModeEnabled = auditParam;
|
||||||
} else {
|
} else {
|
||||||
@@ -281,7 +282,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
throttleTime(300, asyncScheduler, { leading: true, trailing: true }),
|
throttleTime(300, asyncScheduler, { leading: true, trailing: true }),
|
||||||
shareReplay(1)
|
shareReplay({ bufferSize: 1, refCount: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
this.overviewSubscription = this.block$.pipe(
|
this.overviewSubscription = this.block$.pipe(
|
||||||
@@ -363,6 +364,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe((network) => this.network = network);
|
.subscribe((network) => this.network = network);
|
||||||
|
|
||||||
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
|
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
|
||||||
|
this.currentQueryParams = params;
|
||||||
if (params.showDetails === 'true') {
|
if (params.showDetails === 'true') {
|
||||||
this.showDetails = true;
|
this.showDetails = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -414,6 +416,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.stateService.markBlock$.next({});
|
this.stateService.markBlock$.next({});
|
||||||
this.overviewSubscription?.unsubscribe();
|
this.overviewSubscription?.unsubscribe();
|
||||||
|
this.accelerationsSubscription?.unsubscribe();
|
||||||
this.keyNavigationSubscription?.unsubscribe();
|
this.keyNavigationSubscription?.unsubscribe();
|
||||||
this.blocksSubscription?.unsubscribe();
|
this.blocksSubscription?.unsubscribe();
|
||||||
this.cacheBlocksSubscription?.unsubscribe();
|
this.cacheBlocksSubscription?.unsubscribe();
|
||||||
@@ -421,8 +424,16 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
this.queryParamsSubscription?.unsubscribe();
|
this.queryParamsSubscription?.unsubscribe();
|
||||||
this.timeLtrSubscription?.unsubscribe();
|
this.timeLtrSubscription?.unsubscribe();
|
||||||
this.childChangeSubscription?.unsubscribe();
|
this.childChangeSubscription?.unsubscribe();
|
||||||
this.priceSubscription?.unsubscribe();
|
this.auditPrefSubscription?.unsubscribe();
|
||||||
|
this.isAuditEnabledSubscription?.unsubscribe();
|
||||||
this.oobSubscription?.unsubscribe();
|
this.oobSubscription?.unsubscribe();
|
||||||
|
this.priceSubscription?.unsubscribe();
|
||||||
|
this.blockGraphProjected.forEach(graph => {
|
||||||
|
graph.destroy();
|
||||||
|
});
|
||||||
|
this.blockGraphActual.forEach(graph => {
|
||||||
|
graph.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - Refactor this.fees/this.reward for liquid because it is not
|
// TODO - Refactor this.fees/this.reward for liquid because it is not
|
||||||
@@ -733,19 +744,18 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
toggleAuditMode(): void {
|
toggleAuditMode(): void {
|
||||||
this.stateService.hideAudit.next(this.auditModeEnabled);
|
this.stateService.hideAudit.next(this.auditModeEnabled);
|
||||||
|
|
||||||
this.route.queryParams.subscribe(params => {
|
const queryParams = { ...this.currentQueryParams };
|
||||||
const queryParams = { ...params };
|
delete queryParams['audit'];
|
||||||
delete queryParams['audit'];
|
|
||||||
|
|
||||||
let newUrl = this.router.url.split('?')[0];
|
let newUrl = this.router.url.split('?')[0];
|
||||||
const queryString = new URLSearchParams(queryParams).toString();
|
const queryString = new URLSearchParams(queryParams).toString();
|
||||||
if (queryString) {
|
if (queryString) {
|
||||||
newUrl += '?' + queryString;
|
newUrl += '?' + queryString;
|
||||||
}
|
}
|
||||||
|
this.location.replaceState(newUrl);
|
||||||
this.location.replaceState(newUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// avoid duplicate subscriptions
|
||||||
|
this.auditPrefSubscription?.unsubscribe();
|
||||||
this.auditPrefSubscription = this.stateService.hideAudit.subscribe((hide) => {
|
this.auditPrefSubscription = this.stateService.hideAudit.subscribe((hide) => {
|
||||||
this.auditModeEnabled = !hide;
|
this.auditModeEnabled = !hide;
|
||||||
this.showAudit = this.auditAvailable && this.auditModeEnabled;
|
this.showAudit = this.auditAvailable && this.auditModeEnabled;
|
||||||
@@ -762,7 +772,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
return this.route.queryParams.pipe(
|
return this.route.queryParams.pipe(
|
||||||
map(params => {
|
map(params => {
|
||||||
this.auditParamEnabled = 'audit' in params;
|
this.auditParamEnabled = 'audit' in params;
|
||||||
|
|
||||||
return this.auditParamEnabled ? !(params['audit'] === 'false') : true;
|
return this.auditParamEnabled ? !(params['audit'] === 'false') : true;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -281,9 +281,11 @@
|
|||||||
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="title-link">
|
<a class="title-link mb-0" style="margin-top: -2px" href="" [routerLink]="['/wallet/' + widget.props.wallet | relativeUrl]">
|
||||||
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
|
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
|
||||||
</span>
|
<span> </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-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
|
<app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
|||||||
this.cacheBlocksSubscription?.unsubscribe();
|
this.cacheBlocksSubscription?.unsubscribe();
|
||||||
this.networkChangedSubscription?.unsubscribe();
|
this.networkChangedSubscription?.unsubscribe();
|
||||||
this.queryParamsSubscription?.unsubscribe();
|
this.queryParamsSubscription?.unsubscribe();
|
||||||
|
this.blockGraphs.forEach(graph => {
|
||||||
|
graph.destroy();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
shiftTestBlocks(): void {
|
shiftTestBlocks(): void {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
this.blockGraph?.destroy();
|
||||||
this.blockSub.unsubscribe();
|
this.blockSub.unsubscribe();
|
||||||
this.timeLtrSubscription.unsubscribe();
|
this.timeLtrSubscription.unsubscribe();
|
||||||
this.websocketService.stopTrackMempoolBlock();
|
this.websocketService.stopTrackMempoolBlock();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<h1 class="m-0 pt-1 pt-md-0">{{ poolStats.pool.name }}</h1>
|
<h1 class="m-0 pt-1 pt-md-0">{{ poolStats.pool.name }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box pool-details">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
@@ -173,7 +173,119 @@
|
|||||||
<div class="spinner-border text-light"></div>
|
<div class="spinner-border text-light"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Stratum Job -->
|
||||||
|
<ng-container *ngIf="(job$ | async) as job;">
|
||||||
|
<h2 i18n="pool.next_block">Next block</h2>
|
||||||
|
<div class="box mb-3">
|
||||||
|
<div class="row" >
|
||||||
|
<div class="col">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table class="job-table table table-xs table-borderless table-fixed table-data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="data-title clip text-center height" i18n="latest-blocks.height">Height</th>
|
||||||
|
<th class="data-title clip text-center expected" i18n="next-block.expected-time">Expected</th>
|
||||||
|
<th class="data-title clip text-center reward" i18n="latest-blocks.reward">Reward</th>
|
||||||
|
<th class="data-title clip text-center timestamp" i18n="next-block.timestamp">Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="text-center height">
|
||||||
|
{{ job.height }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center expected">
|
||||||
|
<ng-container *ngIf="(expectedBlockTime$ | async) as expectedBlockTime; else expectedPlaceholder">
|
||||||
|
<app-time kind="until" [time]="expectedBlockTime" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #expectedPlaceholder>~</ng-template>
|
||||||
|
</td>
|
||||||
|
<td class="text-center reward">
|
||||||
|
<app-amount [satoshis]="job.reward"></app-amount>
|
||||||
|
</td>
|
||||||
|
<td class="text-center timestamp">
|
||||||
|
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.timestamp" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table class="job-table table table-xs table-borderless table-fixed table-data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="data-title clip text-center coinbase" i18n="latest-blocks.coinbasetag">Coinbase tag</th>
|
||||||
|
<th class="data-title clip text-center clean" i18n="next-block.clean">Clean</th>
|
||||||
|
<th class="data-title clip text-center prevhash" i18n="next-block.prevhash">Prevhash</th>
|
||||||
|
<th class="data-title clip text-center job-received" i18n="next-block.job-received">Job Received</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="text-center coinbase">
|
||||||
|
{{ job.scriptsig | hex2ascii }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center clean">
|
||||||
|
@if (job.cleanJobs) {
|
||||||
|
<fa-icon [icon]="['fas', 'check-circle']" [fixedWidth]="true"></fa-icon>
|
||||||
|
} @else {
|
||||||
|
<fa-icon [icon]="['fas', 'times-circle']" [fixedWidth]="true"></fa-icon>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-center prevhash">
|
||||||
|
<a [routerLink]="['/block' | relativeUrl, job.prevHash]">
|
||||||
|
<app-truncate [text]="job.prevHash" [lastChars]="8"></app-truncate>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="text-center job-received">
|
||||||
|
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.received / 1000" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table class="stratum-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="data-title clip text-center" [attr.colspan]="Math.max(job.merkleBranches.length, 12)">
|
||||||
|
<a class="title-link" href="" [routerLink]="['/stratum' | relativeUrl]">
|
||||||
|
Merkle Branches
|
||||||
|
<span> </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>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
@for (branch of job.merkleBranches; track $index) {
|
||||||
|
<td class="merkle" [style.background-color]="branch ? '#' + branch.slice(0, 6) : ''"></td>
|
||||||
|
}
|
||||||
|
@for (_ of [].constructor(Math.max(0, 12 - job.merkleBranches.length)); track $index) {
|
||||||
|
<td class="merkle empty-branch"></td>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<!-- Blocks list -->
|
<!-- Blocks list -->
|
||||||
|
<h2 i18n="master-page.blocks">Blocks</h2>
|
||||||
<table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5"
|
<table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5"
|
||||||
[infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
|
[infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
|
||||||
<ng-container *ngIf="blocks$ | async as blocks; else skeleton">
|
<ng-container *ngIf="blocks$ | async as blocks; else skeleton">
|
||||||
|
|||||||
@@ -49,111 +49,110 @@ div.scrollable {
|
|||||||
max-height: 75px;
|
max-height: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.pool-details {
|
||||||
padding-bottom: 5px;
|
|
||||||
@media (min-width: 767.98px) {
|
@media (min-width: 767.98px) {
|
||||||
min-height: 187px;
|
min-height: 187px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
width: 25%;
|
width: 25%;
|
||||||
@media (min-width: 767.98px) {
|
@media (min-width: 767.98px) {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
.label.addresses {
|
||||||
font-weight: bold;
|
vertical-align: top;
|
||||||
|
padding-top: 25px;
|
||||||
|
}
|
||||||
|
.addresses-data {
|
||||||
|
vertical-align: top;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.label.addresses {
|
|
||||||
vertical-align: top;
|
|
||||||
padding-top: 25px;
|
|
||||||
}
|
|
||||||
.addresses-data {
|
|
||||||
vertical-align: top;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data {
|
.data {
|
||||||
text-align: right;
|
|
||||||
padding-left: 5%;
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
text-align: left;
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
@media (max-width: 450px) {
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
padding-left: 5%;
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 450px) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
background-color: var(--secondary);
|
background-color: var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.coinbase {
|
.coinbase {
|
||||||
width: 20%;
|
|
||||||
@media (max-width: 875px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.height {
|
|
||||||
width: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
@media (max-width: 875px) {
|
|
||||||
padding-left: 50px;
|
|
||||||
}
|
|
||||||
@media (max-width: 685px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mined {
|
|
||||||
width: 13%;
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.txs {
|
|
||||||
padding-right: 40px;
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
@media (max-width: 875px) {
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
|
||||||
@media (max-width: 567px) {
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.size {
|
|
||||||
width: 12%;
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
width: 15%;
|
|
||||||
}
|
|
||||||
@media (max-width: 875px) {
|
|
||||||
width: 20%;
|
width: 20%;
|
||||||
|
@media (max-width: 875px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 650px) {
|
|
||||||
width: 20%;
|
|
||||||
}
|
|
||||||
@media (max-width: 450px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scriptmessage {
|
.height {
|
||||||
overflow: hidden;
|
width: 10%;
|
||||||
display: inline-block;
|
}
|
||||||
text-overflow: ellipsis;
|
|
||||||
vertical-align: middle;
|
.timestamp {
|
||||||
width: auto;
|
@media (max-width: 875px) {
|
||||||
text-align: left;
|
padding-left: 50px;
|
||||||
|
}
|
||||||
|
@media (max-width: 685px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mined {
|
||||||
|
width: 13%;
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.txs {
|
||||||
|
padding-right: 40px;
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
@media (max-width: 875px) {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
@media (max-width: 567px) {
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
width: 12%;
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
@media (max-width: 875px) {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
@media (max-width: 650px) {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
@media (max-width: 450px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scriptmessage {
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-block;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-loader {
|
.skeleton-loader {
|
||||||
@@ -214,4 +213,55 @@ div.scrollable {
|
|||||||
|
|
||||||
.taller-row {
|
.taller-row {
|
||||||
height: 75px;
|
height: 75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stratum-table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.merkle {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-branch {
|
||||||
|
outline: solid 1px white;
|
||||||
|
outline-offset: -1px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(to top left, transparent, transparent 48%, white 49%, white 51%, transparent 52%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
position: relative;
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-table {
|
||||||
|
td, th {
|
||||||
|
width: 25%;
|
||||||
|
max-width: 25%;
|
||||||
|
min-width: 25%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.expected, .timestamp, .clean, .job-received {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,9 @@ import { selectPowerOfTen } from '@app/bitcoin.utils';
|
|||||||
import { formatNumber } from '@angular/common';
|
import { formatNumber } from '@angular/common';
|
||||||
import { SeoService } from '@app/services/seo.service';
|
import { SeoService } from '@app/services/seo.service';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { StratumJob } from '../../interfaces/websocket.interface';
|
||||||
|
import { WebsocketService } from '../../services/websocket.service';
|
||||||
|
import { MiningService } from '../../services/mining.service';
|
||||||
|
|
||||||
interface AccelerationTotal {
|
interface AccelerationTotal {
|
||||||
cost: number,
|
cost: number,
|
||||||
@@ -27,12 +30,16 @@ export class PoolComponent implements OnInit {
|
|||||||
@Input() left: number | string = 75;
|
@Input() left: number | string = 75;
|
||||||
|
|
||||||
gfg = true;
|
gfg = true;
|
||||||
|
stratumEnabled = this.stateService.env.STRATUM_ENABLED;
|
||||||
|
|
||||||
formatNumber = formatNumber;
|
formatNumber = formatNumber;
|
||||||
|
Math = Math;
|
||||||
slugSubscription: Subscription;
|
slugSubscription: Subscription;
|
||||||
poolStats$: Observable<PoolStat>;
|
poolStats$: Observable<PoolStat>;
|
||||||
blocks$: Observable<BlockExtended[]>;
|
blocks$: Observable<BlockExtended[]>;
|
||||||
oobFees$: Observable<AccelerationTotal[]>;
|
oobFees$: Observable<AccelerationTotal[]>;
|
||||||
|
job$: Observable<StratumJob | null>;
|
||||||
|
expectedBlockTime$: Observable<number>;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error: HttpErrorResponse | null = null;
|
error: HttpErrorResponse | null = null;
|
||||||
|
|
||||||
@@ -53,6 +60,8 @@ export class PoolComponent implements OnInit {
|
|||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
public stateService: StateService,
|
public stateService: StateService,
|
||||||
|
private websocketService: WebsocketService,
|
||||||
|
private miningService: MiningService,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
) {
|
) {
|
||||||
this.auditAvailable = this.stateService.env.AUDIT;
|
this.auditAvailable = this.stateService.env.AUDIT;
|
||||||
@@ -62,7 +71,7 @@ export class PoolComponent implements OnInit {
|
|||||||
this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => {
|
this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.blocks = [];
|
this.blocks = [];
|
||||||
this.chartOptions = {};
|
this.chartOptions = {};
|
||||||
this.slug = slug;
|
this.slug = slug;
|
||||||
this.initializeObservables();
|
this.initializeObservables();
|
||||||
});
|
});
|
||||||
@@ -129,6 +138,31 @@ export class PoolComponent implements OnInit {
|
|||||||
}),
|
}),
|
||||||
filter(oob => oob.length === 3 && oob[2].count > 0)
|
filter(oob => oob.length === 3 && oob[2].count > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.stratumEnabled) {
|
||||||
|
this.job$ = combineLatest([
|
||||||
|
this.poolStats$.pipe(
|
||||||
|
tap((poolStats) => {
|
||||||
|
this.websocketService.startTrackStratum(poolStats.pool.unique_id);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
this.stateService.stratumJobs$
|
||||||
|
]).pipe(
|
||||||
|
map(([poolStats, jobs]) => {
|
||||||
|
return jobs[poolStats.pool.unique_id];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.expectedBlockTime$ = combineLatest([
|
||||||
|
this.miningService.getMiningStats('1w'),
|
||||||
|
this.poolStats$,
|
||||||
|
this.stateService.difficultyAdjustment$
|
||||||
|
]).pipe(
|
||||||
|
map(([miningStats, poolStat, da]) => {
|
||||||
|
return (da.timeAvg / ((poolStat.estimatedHashrate || 0) / (miningStats.lastEstimatedHashrate * 1_000_000_000_000_000_000))) + Date.now() + da.timeOffset;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareChartOptions(hashrate, share) {
|
prepareChartOptions(hashrate, share) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<div class="container-xl" style="min-height: 335px">
|
||||||
|
<h1 class="float-left" i18n="master-page.blocks">Stratum Jobs</h1>
|
||||||
|
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
|
<div style="min-height: 295px">
|
||||||
|
<table *ngIf="poolsReady && (rows$ | async) as rows;" class="stratum-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td class="height">Height</td>
|
||||||
|
<td class="reward">Reward</td>
|
||||||
|
<td class="tag">Coinbase Tag</td>
|
||||||
|
<td class="merkle" [attr.colspan]="rows[0]?.merkleCells?.length || 4">
|
||||||
|
Merkle Branches
|
||||||
|
</td>
|
||||||
|
<td class="pool">Pool</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track row.job.pool) {
|
||||||
|
<tr>
|
||||||
|
<td class="height">
|
||||||
|
{{ row.job.height }}
|
||||||
|
</td>
|
||||||
|
<td class="reward">
|
||||||
|
<app-amount [satoshis]="row.job.reward"></app-amount>
|
||||||
|
</td>
|
||||||
|
<td class="tag">
|
||||||
|
{{ row.job.tag }}
|
||||||
|
</td>
|
||||||
|
@for (cell of row.merkleCells; track $index) {
|
||||||
|
<td class="merkle" [style.background-color]="cell.hash ? '#' + cell.hash.slice(0, 6) : ''">
|
||||||
|
<div class="pipe-segment" [class]="pipeToClass(cell.type)"></div>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td class="pool">
|
||||||
|
@if (pools[row.job.pool]) {
|
||||||
|
<a class="badge" [routerLink]="[('/mining/pool/' + pools[row.job.pool].slug) | relativeUrl]">
|
||||||
|
<img class="pool-logo" [src]="'/resources/mining-pools/' + pools[row.job.pool].slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + pools[row.job.pool].name + ' mining pool'">
|
||||||
|
{{ pools[row.job.pool].name}}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
.stratum-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
position: relative;
|
||||||
|
height: 2em;
|
||||||
|
|
||||||
|
&.height, &.reward, &.tag {
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.tag {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pool {
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.merkle {
|
||||||
|
width: 100px;
|
||||||
|
.pipe-segment {
|
||||||
|
position: absolute;
|
||||||
|
border-color: white;
|
||||||
|
box-sizing: content-box;
|
||||||
|
|
||||||
|
&.vertical {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
border-left: solid 4px;
|
||||||
|
}
|
||||||
|
&.horizontal {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
border-top: solid 4px;
|
||||||
|
}
|
||||||
|
&.branch-top {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
border-top: solid 4px;
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: content-box;
|
||||||
|
top: -4px;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 50%;
|
||||||
|
border-top: solid 4px;
|
||||||
|
border-left: solid 4px;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.branch-mid {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0px;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
border-left: solid 4px;
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: content-box;
|
||||||
|
top: -4px;
|
||||||
|
left: -4px;
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
border-bottom: solid 4px;
|
||||||
|
border-left: solid 4px;
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.branch-end {
|
||||||
|
top: -4px;
|
||||||
|
right: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
border-bottom: solid 4px;
|
||||||
|
border-left: solid 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: relative;
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-logo {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { Component, OnInit, ChangeDetectionStrategy, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { StateService } from '../../../services/state.service';
|
||||||
|
import { WebsocketService } from '../../../services/websocket.service';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import { StratumJob } from '../../../interfaces/websocket.interface';
|
||||||
|
import { MiningService } from '../../../services/mining.service';
|
||||||
|
import { SinglePoolStats } from '../../../interfaces/node-api.interface';
|
||||||
|
|
||||||
|
type MerkleCellType = ' ' | '┬' | '├' | '└' | '│' | '─' | 'leaf';
|
||||||
|
|
||||||
|
|
||||||
|
interface TaggedStratumJob extends StratumJob {
|
||||||
|
tag: string;
|
||||||
|
merkleBranchIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MerkleCell {
|
||||||
|
hash: string;
|
||||||
|
type: MerkleCellType;
|
||||||
|
job?: TaggedStratumJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MerkleTree {
|
||||||
|
hash?: string;
|
||||||
|
job: string;
|
||||||
|
size: number;
|
||||||
|
children?: MerkleTree[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PoolRow {
|
||||||
|
job: TaggedStratumJob;
|
||||||
|
merkleCells: MerkleCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTag(scriptSig: string): string {
|
||||||
|
const hex = scriptSig.slice(8).replace(/6d6d.{64}/, '');
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes.push(parseInt(hex.substr(i, 2), 16));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const ascii = new TextDecoder('utf8').decode(Uint8Array.from(bytes)).replace(/\uFFFD/g, '').replace(/\\0/g, '').replace(/[\x00-\x1F\x7F-\x9F]/g, '');
|
||||||
|
if (ascii.includes('/ViaBTC/')) {
|
||||||
|
return '/ViaBTC/';
|
||||||
|
} else if (ascii.includes('SpiderPool/')) {
|
||||||
|
return 'SpiderPool/';
|
||||||
|
}
|
||||||
|
return (ascii.match(/\/.*\//)?.[0] || ascii).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMerkleBranchIds(merkleBranches: string[], numBranches: number): string[] {
|
||||||
|
let lastHash = '';
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < numBranches; i++) {
|
||||||
|
if (merkleBranches[i]) {
|
||||||
|
lastHash = merkleBranches[i];
|
||||||
|
}
|
||||||
|
ids.push(`${i}-${lastHash}`);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-stratum-list',
|
||||||
|
templateUrl: './stratum-list.component.html',
|
||||||
|
styleUrls: ['./stratum-list.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class StratumList implements OnInit, OnDestroy {
|
||||||
|
rows$: Observable<PoolRow[]>;
|
||||||
|
pools: { [id: number]: SinglePoolStats } = {};
|
||||||
|
poolsReady: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private stateService: StateService,
|
||||||
|
private websocketService: WebsocketService,
|
||||||
|
private miningService: MiningService,
|
||||||
|
private cd: ChangeDetectorRef,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.websocketService.want(['stats', 'blocks', 'mempool-blocks']);
|
||||||
|
this.miningService.getPools().subscribe(pools => {
|
||||||
|
this.pools = {};
|
||||||
|
for (const pool of pools) {
|
||||||
|
this.pools[pool.unique_id] = pool;
|
||||||
|
}
|
||||||
|
this.poolsReady = true;
|
||||||
|
this.cd.markForCheck();
|
||||||
|
});
|
||||||
|
this.rows$ = this.stateService.stratumJobs$.pipe(
|
||||||
|
map((jobs) => this.processJobs(jobs)),
|
||||||
|
);
|
||||||
|
this.websocketService.startTrackStratum('all');
|
||||||
|
}
|
||||||
|
|
||||||
|
processJobs(rawJobs: Record<string, StratumJob>): PoolRow[] {
|
||||||
|
const numBranches = Math.max(...Object.values(rawJobs).map(job => job.merkleBranches.length));
|
||||||
|
const jobs: Record<string, TaggedStratumJob> = {};
|
||||||
|
for (const [id, job] of Object.entries(rawJobs)) {
|
||||||
|
jobs[id] = { ...job, tag: parseTag(job.scriptsig), merkleBranchIds: getMerkleBranchIds(job.merkleBranches, numBranches) };
|
||||||
|
}
|
||||||
|
if (Object.keys(jobs).length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let trees: MerkleTree[] = Object.keys(jobs).map(job => ({
|
||||||
|
job,
|
||||||
|
size: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// build tree from bottom up
|
||||||
|
for (let col = numBranches - 1; col >= 0; col--) {
|
||||||
|
const groups: Record<string, MerkleTree[]> = {};
|
||||||
|
for (const tree of trees) {
|
||||||
|
const branchId = jobs[tree.job].merkleBranchIds[col];
|
||||||
|
if (!groups[branchId]) {
|
||||||
|
groups[branchId] = [];
|
||||||
|
}
|
||||||
|
groups[branchId].push(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
trees = Object.values(groups).map(group => ({
|
||||||
|
hash: jobs[group[0].job].merkleBranches[col],
|
||||||
|
job: group[0].job,
|
||||||
|
children: group,
|
||||||
|
size: group.reduce((acc, tree) => acc + tree.size, 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize grid of cells
|
||||||
|
const rows: (MerkleCell | null)[][] = [];
|
||||||
|
for (let i = 0; i < Object.keys(jobs).length; i++) {
|
||||||
|
const row: (MerkleCell | null)[] = [];
|
||||||
|
for (let j = 0; j <= numBranches; j++) {
|
||||||
|
row.push(null);
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill in the cells
|
||||||
|
let colTrees = [trees.sort((a, b) => {
|
||||||
|
if (a.size !== b.size) {
|
||||||
|
return b.size - a.size;
|
||||||
|
}
|
||||||
|
return a.job.localeCompare(b.job);
|
||||||
|
})];
|
||||||
|
for (let col = 0; col <= numBranches; col++) {
|
||||||
|
let row = 0;
|
||||||
|
const nextTrees: MerkleTree[][] = [];
|
||||||
|
for (let g = 0; g < colTrees.length; g++) {
|
||||||
|
for (let t = 0; t < colTrees[g].length; t++) {
|
||||||
|
const tree = colTrees[g][t];
|
||||||
|
const isFirstTree = (t === 0);
|
||||||
|
const isLastTree = (t === colTrees[g].length - 1);
|
||||||
|
for (let i = 0; i < tree.size; i++) {
|
||||||
|
const isFirstCell = (i === 0);
|
||||||
|
const isLeaf = (col === numBranches);
|
||||||
|
rows[row][col] = {
|
||||||
|
hash: tree.hash,
|
||||||
|
job: isLeaf ? jobs[tree.job] : undefined,
|
||||||
|
type: 'leaf',
|
||||||
|
};
|
||||||
|
if (col > 0) {
|
||||||
|
rows[row][col - 1].type = getCellType(isFirstCell, isFirstTree, isLastTree);
|
||||||
|
}
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
if (tree.children) {
|
||||||
|
nextTrees.push(tree.children.sort((a, b) => {
|
||||||
|
if (a.size !== b.size) {
|
||||||
|
return b.size - a.size;
|
||||||
|
}
|
||||||
|
return a.job.localeCompare(b.job);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
colTrees = nextTrees;
|
||||||
|
}
|
||||||
|
return rows.map(row => ({
|
||||||
|
job: row[row.length - 1].job,
|
||||||
|
merkleCells: row.slice(0, -1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeToClass(type: MerkleCellType): string {
|
||||||
|
return {
|
||||||
|
' ': 'empty',
|
||||||
|
'┬': 'branch-top',
|
||||||
|
'├': 'branch-mid',
|
||||||
|
'└': 'branch-end',
|
||||||
|
'│': 'vertical',
|
||||||
|
'─': 'horizontal',
|
||||||
|
'leaf': 'leaf'
|
||||||
|
}[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.websocketService.stopTrackStratum();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellType(isFirstCell, isFirstTree, isLastTree): MerkleCellType {
|
||||||
|
if (isFirstCell) {
|
||||||
|
if (isFirstTree) {
|
||||||
|
if (isLastTree) {
|
||||||
|
return '─';
|
||||||
|
} else {
|
||||||
|
return '┬';
|
||||||
|
}
|
||||||
|
} else if (isLastTree) {
|
||||||
|
return '└';
|
||||||
|
} else {
|
||||||
|
return '├';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isLastTree) {
|
||||||
|
return ' ';
|
||||||
|
} else {
|
||||||
|
return '│';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<br>
|
|
||||||
<div class="title">
|
|
||||||
<h2 class="text-left" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
|
|
||||||
</div>
|
|
||||||
<div class="box cpfp-details">
|
|
||||||
<table class="table table-fixed table-borderless table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th i18n="transactions-list.vout.scriptpubkey-type">Type</th>
|
|
||||||
<th class="txids" i18n="dashboard.latest-transactions.txid">TXID</th>
|
|
||||||
<th *only-vsize class="d-none d-lg-table-cell" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</th>
|
|
||||||
<th *only-weight class="d-none d-lg-table-cell" i18n="transaction.weight|Transaction Weight">Weight</th>
|
|
||||||
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
|
|
||||||
<th class="d-none d-lg-table-cell"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<ng-template [ngIf]="cpfpInfo?.descendants?.length">
|
|
||||||
<tr *ngFor="let cpfpTx of cpfpInfo.descendants">
|
|
||||||
<td><span class="badge badge-primary" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
|
||||||
<td>
|
|
||||||
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
|
||||||
</td>
|
|
||||||
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
|
||||||
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
|
||||||
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
|
||||||
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) > roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template [ngIf]="cpfpInfo?.bestDescendant">
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge badge-success" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
|
||||||
<td class="txids">
|
|
||||||
<app-truncate [text]="cpfpInfo.bestDescendant.txid" [link]="['/tx' | relativeUrl, cpfpInfo.bestDescendant.txid]"></app-truncate>
|
|
||||||
</td>
|
|
||||||
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight / 4 | vbytes: 2"></td>
|
|
||||||
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight | wuBytes: 2"></td>
|
|
||||||
<td><app-fee-rate [fee]="cpfpInfo.bestDescendant.fee" [weight]="cpfpInfo.bestDescendant.weight"></app-fee-rate></td>
|
|
||||||
<td class="d-none d-lg-table-cell"><fa-icon class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template [ngIf]="cpfpInfo?.ancestors?.length">
|
|
||||||
<tr *ngFor="let cpfpTx of cpfpInfo.ancestors">
|
|
||||||
<td><span class="badge badge-primary" i18n="transaction.ancestor|Transaction Ancestor">Ancestor</span></td>
|
|
||||||
<td class="txids">
|
|
||||||
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
|
||||||
</td>
|
|
||||||
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
|
||||||
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
|
||||||
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
|
||||||
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) < roundToOneDecimal(tx)" class="arrow-red" [icon]="['fas', 'angle-double-down']" [fixedWidth]="true"></fa-icon></td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
.title {
|
|
||||||
h2 {
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cpfp-details {
|
|
||||||
.txids {
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.txids {
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-green {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-red {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
position: relative;
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Component, OnInit, Input, ChangeDetectionStrategy } from '@angular/core';
|
|
||||||
import { CpfpInfo } from '@interfaces/node-api.interface';
|
|
||||||
import { Transaction } from '@interfaces/electrs.interface';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-cpfp-info',
|
|
||||||
templateUrl: './cpfp-info.component.html',
|
|
||||||
styleUrls: ['./cpfp-info.component.scss'],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
|
||||||
export class CpfpInfoComponent implements OnInit {
|
|
||||||
@Input() cpfpInfo: CpfpInfo;
|
|
||||||
@Input() tx: Transaction;
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
ngOnInit(): void {}
|
|
||||||
|
|
||||||
roundToOneDecimal(cpfpTx: any): number {
|
|
||||||
return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
<div class="container-xl">
|
|
||||||
|
|
||||||
@if (!transaction) {
|
|
||||||
|
|
||||||
<h1 style="margin-top: 19px;" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</h1>
|
|
||||||
|
|
||||||
<form [formGroup]="pushTxForm" (submit)="decodeTransaction()" novalidate>
|
|
||||||
<div class="mb-3">
|
|
||||||
<textarea formControlName="txRaw" class="form-control" rows="5" i18n-placeholder="transaction.hex" placeholder="Transaction hex"></textarea>
|
|
||||||
</div>
|
|
||||||
<button [disabled]="isLoading" type="submit" class="btn btn-primary mr-2" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</button>
|
|
||||||
<input type="checkbox" [checked]="!offlineMode" id="offline-mode" (change)="onOfflineModeChange($event)">
|
|
||||||
<label class="label" for="offline-mode">
|
|
||||||
<span i18n="transaction.fetch-prevout-data">Fetch prevout data</span>
|
|
||||||
</label>
|
|
||||||
<p *ngIf="error" class="red-color d-inline">Error decoding transaction, reason: {{ error }}</p>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (transaction && !error && !isLoading) {
|
|
||||||
<div class="title-block">
|
|
||||||
<h1 i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</h1>
|
|
||||||
|
|
||||||
<span class="tx-link">
|
|
||||||
<span class="txid">
|
|
||||||
<app-truncate [text]="transaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, transaction.txid]" [disabled]="!successBroadcast">
|
|
||||||
<app-clipboard [text]="transaction.txid"></app-clipboard>
|
|
||||||
</app-truncate>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="container-buttons">
|
|
||||||
<button *ngIf="!successBroadcast" [disabled]="isLoadingBroadcast" type="button" class="btn btn-sm btn-primary" i18n="transaction.broadcast|Broadcast" (click)="postTx()">Broadcast</button>
|
|
||||||
<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()">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="red-color d-inline">{{ errorBroadcast }}</p>
|
|
||||||
|
|
||||||
<div class="clearfix"></div>
|
|
||||||
|
|
||||||
@if (!hasPrevouts) {
|
|
||||||
<div class="alert alert-mempool">
|
|
||||||
@if (offlineMode) {
|
|
||||||
<span><strong>Prevouts are not loaded, some fields like fee rate cannot be displayed.</strong></span>
|
|
||||||
} @else {
|
|
||||||
<span><strong>Error loading prevouts</strong>. {{ errorPrevouts ? 'Reason: ' + errorPrevouts : '' }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (errorCpfpInfo) {
|
|
||||||
<div class="alert alert-mempool">
|
|
||||||
<span><strong>Error loading CPFP data</strong>. Reason: {{ errorCpfpInfo }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<app-transaction-details
|
|
||||||
[network]="stateService.network"
|
|
||||||
[tx]="transaction"
|
|
||||||
[isLoadingTx]="false"
|
|
||||||
[isMobile]="isMobile"
|
|
||||||
[isLoadingFirstSeen]="false"
|
|
||||||
[featuresEnabled]="true"
|
|
||||||
[filters]="filters"
|
|
||||||
[hasEffectiveFeeRate]="false"
|
|
||||||
[cpfpInfo]="null"
|
|
||||||
[ETA$]="ETA$"
|
|
||||||
[hasEffectiveFeeRate]="hasEffectiveFeeRate"
|
|
||||||
[cpfpInfo]="cpfpInfo"
|
|
||||||
[hasCpfp]="hasCpfp"
|
|
||||||
(toggleCpfp$)="this.showCpfpDetails = !this.showCpfpDetails"
|
|
||||||
></app-transaction-details>
|
|
||||||
|
|
||||||
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="transaction"></app-cpfp-info>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<ng-container *ngIf="flowEnabled; else flowPlaceholder">
|
|
||||||
<div class="title float-left">
|
|
||||||
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-info flow-toggle btn-sm float-right" (click)="toggleGraph()" i18n="hide-diagram">Hide diagram</button>
|
|
||||||
|
|
||||||
<div class="clearfix"></div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<div class="graph-container" #graphContainer>
|
|
||||||
<tx-bowtie-graph
|
|
||||||
[tx]="transaction"
|
|
||||||
[cached]="true"
|
|
||||||
[width]="graphWidth"
|
|
||||||
[height]="graphHeight"
|
|
||||||
[lineLimit]="inOutLimit"
|
|
||||||
[maxStrands]="graphExpanded ? maxInOut : 24"
|
|
||||||
[network]="stateService.network"
|
|
||||||
[tooltip]="true"
|
|
||||||
[connectors]="true"
|
|
||||||
[inputIndex]="null" [outputIndex]="null"
|
|
||||||
>
|
|
||||||
</tx-bowtie-graph>
|
|
||||||
</div>
|
|
||||||
<div class="toggle-wrapper" *ngIf="maxInOut > 24">
|
|
||||||
<button class="btn btn-sm btn-primary graph-toggle" (click)="expandGraph();" *ngIf="!graphExpanded; else collapseBtn"><span i18n="show-more">Show more</span></button>
|
|
||||||
<ng-template #collapseBtn>
|
|
||||||
<button class="btn btn-sm btn-primary graph-toggle" (click)="collapseGraph();"><span i18n="show-less">Show less</span></button>
|
|
||||||
</ng-template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #flowPlaceholder>
|
|
||||||
<div class="box hidden">
|
|
||||||
<div class="graph-container" #graphContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<div class="subtitle-block">
|
|
||||||
<div class="title">
|
|
||||||
<h2 i18n="transaction.inputs-and-outputs|Transaction inputs and outputs">Inputs & Outputs</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="title-buttons">
|
|
||||||
<button *ngIf="!flowEnabled" type="button" class="btn btn-outline-info flow-toggle btn-sm" (click)="toggleGraph()" i18n="show-diagram">Show diagram</button>
|
|
||||||
<button type="button" class="btn btn-outline-info btn-sm" (click)="txList.toggleDetails()" i18n="transaction.details|Transaction Details">Details</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<app-transactions-list #txList [transactions]="[transaction]" [transactionPage]="true" [txPreview]="true"></app-transactions-list>
|
|
||||||
|
|
||||||
<div class="title text-left">
|
|
||||||
<h2 i18n="transaction.details|Transaction Details">Details</h2>
|
|
||||||
</div>
|
|
||||||
<div class="box">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm">
|
|
||||||
<table class="table table-borderless table-striped">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td i18n="block.size">Size</td>
|
|
||||||
<td [innerHTML]="'‎' + (transaction.size | bytes: 2)"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
|
|
||||||
<td [innerHTML]="'‎' + (transaction.weight / 4 | vbytes: 2)"></td>
|
|
||||||
</tr>
|
|
||||||
<tr *ngIf="adjustedVsize">
|
|
||||||
<td><ng-container i18n="transaction.adjusted-vsize|Transaction Adjusted VSize">Adjusted vsize</ng-container>
|
|
||||||
<a class="info-link" [routerLink]="['/docs/faq/' | relativeUrl]" fragment="what-is-adjusted-vsize">
|
|
||||||
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td [innerHTML]="'‎' + (adjustedVsize | vbytes: 2)"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td i18n="block.weight">Weight</td>
|
|
||||||
<td [innerHTML]="'‎' + (transaction.weight | wuBytes: 2)"></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm">
|
|
||||||
<table class="table table-borderless table-striped">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td i18n="transaction.version">Version</td>
|
|
||||||
<td [innerHTML]="'‎' + (transaction.version | number)"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td i18n="transaction.locktime">Locktime</td>
|
|
||||||
<td [innerHTML]="'‎' + (transaction.locktime | number)"></td>
|
|
||||||
</tr>
|
|
||||||
<tr *ngIf="transaction.sigops >= 0">
|
|
||||||
<td><ng-container i18n="transaction.sigops|Transaction Sigops">Sigops</ng-container>
|
|
||||||
<a class="info-link" [routerLink]="['/docs/faq/' | relativeUrl]" fragment="what-are-sigops">
|
|
||||||
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td [innerHTML]="'‎' + (transaction.sigops | number)"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td i18n="transaction.hex">Transaction hex</td>
|
|
||||||
<td><app-clipboard [text]="pushTxForm.get('txRaw').value" [leftPadding]="false"></app-clipboard></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (isLoading) {
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="spinner-border text-light mt-2 mb-2"></div>
|
|
||||||
<h3 i18n="transaction.error.loading-prevouts">
|
|
||||||
Loading {{ isLoadingPrevouts ? 'transaction prevouts' : isLoadingCpfpInfo ? 'CPFP' : '' }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
.label {
|
|
||||||
margin: 0 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-buttons {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-block {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
@media (min-width: 650px) {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 0rem;
|
|
||||||
margin-right: 15px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tx-link {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: baseline;
|
|
||||||
width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-right: 0px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
margin-top: 8px;
|
|
||||||
@media (min-width: 651px) {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
margin-right: 1em;
|
|
||||||
top: 1px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
@media (max-width: 650px) {
|
|
||||||
width: 100%;
|
|
||||||
order: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.txid {
|
|
||||||
width: 200px;
|
|
||||||
min-width: 200px;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-xl {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
flex-direction: column;
|
|
||||||
@media (min-width: 850px) {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.box.hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
height: 0px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
margin-top: 0px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--stat-box-bg);
|
|
||||||
padding: 10px 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
margin: 1.25em 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graph-toggle {
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
tr td {
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
@media (min-width: 576px) {
|
|
||||||
padding: 0.75rem 0.75rem;
|
|
||||||
}
|
|
||||||
&:last-child {
|
|
||||||
text-align: right;
|
|
||||||
@media (min-width: 850px) {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.wrap-cell {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.effective-fee-container {
|
|
||||||
display: block;
|
|
||||||
@media (min-width: 768px){
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
@media (max-width: 425px){
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.effective-fee-rating {
|
|
||||||
@media (max-width: 767px){
|
|
||||||
margin-right: 0px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
h2 {
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-info {
|
|
||||||
margin-top: 5px;
|
|
||||||
@media (min-width: 768px){
|
|
||||||
margin-top: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.flow-toggle {
|
|
||||||
margin-top: -5px;
|
|
||||||
margin-left: 10px;
|
|
||||||
@media (min-width: 768px){
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 0px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-buttons {
|
|
||||||
flex-shrink: 1;
|
|
||||||
text-align: right;
|
|
||||||
.btn {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cpfp-details {
|
|
||||||
.txids {
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.txids {
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-cursor {
|
|
||||||
cursor: default !important;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy } from '@angular/core';
|
|
||||||
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 { ETA, EtaService } from '../../services/eta.service';
|
|
||||||
import { combineLatest, firstValueFrom, map, Observable, startWith, Subscription } from 'rxjs';
|
|
||||||
import { WebsocketService } from '../../services/websocket.service';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
|
||||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
|
||||||
import { SeoService } from '../../services/seo.service';
|
|
||||||
import { seoDescriptionNetwork } from '@app/shared/common.utils';
|
|
||||||
import { ApiService } from '../../services/api.service';
|
|
||||||
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
|
|
||||||
import { CpfpInfo } from '../../interfaces/node-api.interface';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-transaction-raw',
|
|
||||||
templateUrl: './transaction-raw.component.html',
|
|
||||||
styleUrls: ['./transaction-raw.component.scss'],
|
|
||||||
})
|
|
||||||
export class TransactionRawComponent implements OnInit, OnDestroy {
|
|
||||||
|
|
||||||
pushTxForm: UntypedFormGroup;
|
|
||||||
isLoading: boolean;
|
|
||||||
isLoadingPrevouts: boolean;
|
|
||||||
isLoadingCpfpInfo: boolean;
|
|
||||||
offlineMode: boolean = false;
|
|
||||||
transaction: Transaction;
|
|
||||||
error: string;
|
|
||||||
errorPrevouts: string;
|
|
||||||
errorCpfpInfo: string;
|
|
||||||
hasPrevouts: boolean;
|
|
||||||
missingPrevouts: string[];
|
|
||||||
isLoadingBroadcast: boolean;
|
|
||||||
errorBroadcast: string;
|
|
||||||
successBroadcast: boolean;
|
|
||||||
|
|
||||||
isMobile: boolean;
|
|
||||||
@ViewChild('graphContainer')
|
|
||||||
graphContainer: ElementRef;
|
|
||||||
graphExpanded: boolean = false;
|
|
||||||
graphWidth: number = 1068;
|
|
||||||
graphHeight: number = 360;
|
|
||||||
inOutLimit: number = 150;
|
|
||||||
maxInOut: number = 0;
|
|
||||||
flowPrefSubscription: Subscription;
|
|
||||||
hideFlow: boolean = this.stateService.hideFlow.value;
|
|
||||||
flowEnabled: boolean;
|
|
||||||
adjustedVsize: number;
|
|
||||||
filters: Filter[] = [];
|
|
||||||
hasEffectiveFeeRate: boolean;
|
|
||||||
fetchCpfp: boolean;
|
|
||||||
cpfpInfo: CpfpInfo | null;
|
|
||||||
hasCpfp: boolean = false;
|
|
||||||
showCpfpDetails = false;
|
|
||||||
ETA$: Observable<ETA | null>;
|
|
||||||
mempoolBlocksSubscription: Subscription;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public route: ActivatedRoute,
|
|
||||||
public router: Router,
|
|
||||||
public stateService: StateService,
|
|
||||||
public etaService: EtaService,
|
|
||||||
public electrsApi: ElectrsApiService,
|
|
||||||
public websocketService: WebsocketService,
|
|
||||||
public formBuilder: UntypedFormBuilder,
|
|
||||||
public seoService: SeoService,
|
|
||||||
public apiService: ApiService,
|
|
||||||
public relativeUrlPipe: RelativeUrlPipe,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.seoService.setTitle($localize`:@@meta.title.preview-tx:Preview Transaction`);
|
|
||||||
this.seoService.setDescription($localize`:@@meta.description.preview-tx:Preview a transaction to the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network using the transaction's raw hex data.`);
|
|
||||||
this.websocketService.want(['blocks', 'mempool-blocks']);
|
|
||||||
this.pushTxForm = this.formBuilder.group({
|
|
||||||
txRaw: ['', Validators.required],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async decodeTransaction(): Promise<void> {
|
|
||||||
this.resetState();
|
|
||||||
this.isLoading = true;
|
|
||||||
try {
|
|
||||||
const tx = decodeRawTransaction(this.pushTxForm.get('txRaw').value, this.stateService.network);
|
|
||||||
await this.fetchPrevouts(tx);
|
|
||||||
await this.fetchCpfpInfo(tx);
|
|
||||||
this.processTransaction(tx);
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error.message;
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchPrevouts(transaction: Transaction): Promise<void> {
|
|
||||||
if (this.offlineMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevoutsToFetch = transaction.vin.map((input) => ({ txid: input.txid, vout: input.vout }));
|
|
||||||
|
|
||||||
if (!prevoutsToFetch.length || transaction.vin[0].is_coinbase) {
|
|
||||||
this.hasPrevouts = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.missingPrevouts = [];
|
|
||||||
this.isLoadingPrevouts = true;
|
|
||||||
|
|
||||||
const prevouts: { prevout: Vout, unconfirmed: boolean }[] = await firstValueFrom(this.apiService.getPrevouts$(prevoutsToFetch));
|
|
||||||
|
|
||||||
if (prevouts?.length !== prevoutsToFetch.length) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.vin = transaction.vin.map((input, index) => {
|
|
||||||
if (prevouts[index]) {
|
|
||||||
input.prevout = prevouts[index].prevout;
|
|
||||||
addInnerScriptsToVin(input);
|
|
||||||
} else {
|
|
||||||
this.missingPrevouts.push(`${input.txid}:${input.vout}`);
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.missingPrevouts.length) {
|
|
||||||
throw new Error(`Some prevouts do not exist or are already spent (${this.missingPrevouts.length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.fee = transaction.vin.some(input => input.is_coinbase)
|
|
||||||
? 0
|
|
||||||
: transaction.vin.reduce((fee, input) => {
|
|
||||||
return fee + (input.prevout?.value || 0);
|
|
||||||
}, 0) - transaction.vout.reduce((sum, output) => sum + output.value, 0);
|
|
||||||
transaction.feePerVsize = transaction.fee / (transaction.weight / 4);
|
|
||||||
transaction.sigops = countSigops(transaction);
|
|
||||||
|
|
||||||
this.hasPrevouts = true;
|
|
||||||
this.isLoadingPrevouts = false;
|
|
||||||
this.fetchCpfp = prevouts.some(prevout => prevout?.unconfirmed);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
this.errorPrevouts = error?.error?.error || error?.message;
|
|
||||||
this.isLoadingPrevouts = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchCpfpInfo(transaction: Transaction): Promise<void> {
|
|
||||||
// Fetch potential cpfp data if all prevouts were parsed successfully and at least one of them is unconfirmed
|
|
||||||
if (this.hasPrevouts && this.fetchCpfp) {
|
|
||||||
try {
|
|
||||||
this.isLoadingCpfpInfo = true;
|
|
||||||
const cpfpInfo: CpfpInfo[] = await firstValueFrom(this.apiService.getCpfpLocalTx$([{
|
|
||||||
txid: transaction.txid,
|
|
||||||
weight: transaction.weight,
|
|
||||||
sigops: transaction.sigops,
|
|
||||||
fee: transaction.fee,
|
|
||||||
vin: transaction.vin,
|
|
||||||
vout: transaction.vout
|
|
||||||
}]));
|
|
||||||
|
|
||||||
if (cpfpInfo?.[0]?.ancestors?.length) {
|
|
||||||
const { ancestors, effectiveFeePerVsize } = cpfpInfo[0];
|
|
||||||
transaction.effectiveFeePerVsize = effectiveFeePerVsize;
|
|
||||||
this.cpfpInfo = { ancestors, effectiveFeePerVsize };
|
|
||||||
this.hasCpfp = true;
|
|
||||||
this.hasEffectiveFeeRate = true;
|
|
||||||
}
|
|
||||||
this.isLoadingCpfpInfo = false;
|
|
||||||
} catch (error) {
|
|
||||||
this.errorCpfpInfo = error?.error?.error || error?.message;
|
|
||||||
this.isLoadingCpfpInfo = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processTransaction(tx: Transaction): void {
|
|
||||||
this.transaction = tx;
|
|
||||||
|
|
||||||
this.transaction.flags = getTransactionFlags(this.transaction, null, null, null, this.stateService.network);
|
|
||||||
this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : [];
|
|
||||||
if (this.transaction.sigops >= 0) {
|
|
||||||
this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setupGraph();
|
|
||||||
this.setFlowEnabled();
|
|
||||||
this.flowPrefSubscription = this.stateService.hideFlow.subscribe((hide) => {
|
|
||||||
this.hideFlow = !!hide;
|
|
||||||
this.setFlowEnabled();
|
|
||||||
});
|
|
||||||
this.setGraphSize();
|
|
||||||
|
|
||||||
this.ETA$ = combineLatest([
|
|
||||||
this.stateService.mempoolBlocks$.pipe(startWith(null)),
|
|
||||||
this.stateService.difficultyAdjustment$.pipe(startWith(null)),
|
|
||||||
]).pipe(
|
|
||||||
map(([mempoolBlocks, da]) => {
|
|
||||||
return this.etaService.calculateETA(
|
|
||||||
this.stateService.network,
|
|
||||||
this.transaction,
|
|
||||||
mempoolBlocks,
|
|
||||||
null,
|
|
||||||
da,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe(() => {
|
|
||||||
if (this.transaction) {
|
|
||||||
this.stateService.markBlock$.next({
|
|
||||||
txid: this.transaction.txid,
|
|
||||||
txFeePerVSize: this.transaction.effectiveFeePerVsize || this.transaction.feePerVsize,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async postTx(): Promise<string> {
|
|
||||||
this.isLoadingBroadcast = true;
|
|
||||||
this.errorBroadcast = null;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.apiService.postTransaction$(this.pushTxForm.get('txRaw').value)
|
|
||||||
.subscribe((result) => {
|
|
||||||
this.isLoadingBroadcast = false;
|
|
||||||
this.successBroadcast = true;
|
|
||||||
this.transaction.txid = result;
|
|
||||||
resolve(result);
|
|
||||||
},
|
|
||||||
(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);
|
|
||||||
} else if (error.message) {
|
|
||||||
this.errorBroadcast = 'Failed to broadcast transaction, reason: ' + error.message;
|
|
||||||
}
|
|
||||||
this.isLoadingBroadcast = false;
|
|
||||||
reject(this.error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resetState() {
|
|
||||||
this.transaction = null;
|
|
||||||
this.error = null;
|
|
||||||
this.errorPrevouts = null;
|
|
||||||
this.errorBroadcast = null;
|
|
||||||
this.successBroadcast = false;
|
|
||||||
this.isLoading = false;
|
|
||||||
this.isLoadingPrevouts = false;
|
|
||||||
this.isLoadingCpfpInfo = false;
|
|
||||||
this.isLoadingBroadcast = false;
|
|
||||||
this.adjustedVsize = null;
|
|
||||||
this.showCpfpDetails = false;
|
|
||||||
this.hasCpfp = false;
|
|
||||||
this.fetchCpfp = false;
|
|
||||||
this.cpfpInfo = null;
|
|
||||||
this.hasEffectiveFeeRate = false;
|
|
||||||
this.filters = [];
|
|
||||||
this.hasPrevouts = false;
|
|
||||||
this.missingPrevouts = [];
|
|
||||||
this.stateService.markBlock$.next({});
|
|
||||||
this.mempoolBlocksSubscription?.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm() {
|
|
||||||
this.resetState();
|
|
||||||
this.pushTxForm.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('window:resize', ['$event'])
|
|
||||||
setGraphSize(): void {
|
|
||||||
this.isMobile = window.innerWidth < 850;
|
|
||||||
if (this.graphContainer?.nativeElement && this.stateService.isBrowser) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.graphContainer?.nativeElement?.clientWidth) {
|
|
||||||
this.graphWidth = this.graphContainer.nativeElement.clientWidth;
|
|
||||||
} else {
|
|
||||||
setTimeout(() => { this.setGraphSize(); }, 1);
|
|
||||||
}
|
|
||||||
}, 1);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => { this.setGraphSize(); }, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupGraph() {
|
|
||||||
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.transaction?.vin?.length || 1, this.transaction?.vout?.length + 1 || 1));
|
|
||||||
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleGraph() {
|
|
||||||
const showFlow = !this.flowEnabled;
|
|
||||||
this.stateService.hideFlow.next(!showFlow);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFlowEnabled() {
|
|
||||||
this.flowEnabled = !this.hideFlow;
|
|
||||||
}
|
|
||||||
|
|
||||||
expandGraph() {
|
|
||||||
this.graphExpanded = true;
|
|
||||||
this.graphHeight = this.maxInOut * 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
collapseGraph() {
|
|
||||||
this.graphExpanded = false;
|
|
||||||
this.graphHeight = Math.min(360, this.maxInOut * 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
onOfflineModeChange(e): void {
|
|
||||||
this.offlineMode = !e.target.checked;
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.mempoolBlocksSubscription?.unsubscribe();
|
|
||||||
this.flowPrefSubscription?.unsubscribe();
|
|
||||||
this.stateService.markBlock$.next({});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
[height]="tx?.status?.block_height"
|
[height]="tx?.status?.block_height"
|
||||||
[replaced]="replaced"
|
[replaced]="replaced"
|
||||||
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
|
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
|
||||||
|
[cached]="isCached"
|
||||||
></app-confirmations>
|
></app-confirmations>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@@ -66,7 +67,64 @@
|
|||||||
<ng-template [ngIf]="!isLoadingTx && !error">
|
<ng-template [ngIf]="!isLoadingTx && !error">
|
||||||
|
|
||||||
<!-- CPFP Details -->
|
<!-- CPFP Details -->
|
||||||
<app-cpfp-info *ngIf="showCpfpDetails" [cpfpInfo]="cpfpInfo" [tx]="tx"></app-cpfp-info>
|
<ng-template [ngIf]="showCpfpDetails">
|
||||||
|
<br>
|
||||||
|
<div class="title">
|
||||||
|
<h2 class="text-left" i18n="transaction.related-transactions|CPFP List">Related Transactions</h2>
|
||||||
|
</div>
|
||||||
|
<div class="box cpfp-details">
|
||||||
|
<table class="table table-fixed table-borderless table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th i18n="transactions-list.vout.scriptpubkey-type">Type</th>
|
||||||
|
<th class="txids" i18n="dashboard.latest-transactions.txid">TXID</th>
|
||||||
|
<th *only-vsize class="d-none d-lg-table-cell" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</th>
|
||||||
|
<th *only-weight class="d-none d-lg-table-cell" i18n="transaction.weight|Transaction Weight">Weight</th>
|
||||||
|
<th i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
|
||||||
|
<th class="d-none d-lg-table-cell"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<ng-template [ngIf]="cpfpInfo?.descendants?.length">
|
||||||
|
<tr *ngFor="let cpfpTx of cpfpInfo.descendants">
|
||||||
|
<td><span class="badge badge-primary" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
||||||
|
<td>
|
||||||
|
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
||||||
|
</td>
|
||||||
|
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
||||||
|
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
||||||
|
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
||||||
|
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) > roundToOneDecimal(tx)" class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="cpfpInfo?.bestDescendant">
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge badge-success" i18n="transaction.descendant|Descendant">Descendant</span></td>
|
||||||
|
<td class="txids">
|
||||||
|
<app-truncate [text]="cpfpInfo.bestDescendant.txid" [link]="['/tx' | relativeUrl, cpfpInfo.bestDescendant.txid]"></app-truncate>
|
||||||
|
</td>
|
||||||
|
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight / 4 | vbytes: 2"></td>
|
||||||
|
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpInfo.bestDescendant.weight | wuBytes: 2"></td>
|
||||||
|
<td><app-fee-rate [fee]="cpfpInfo.bestDescendant.fee" [weight]="cpfpInfo.bestDescendant.weight"></app-fee-rate></td>
|
||||||
|
<td class="d-none d-lg-table-cell"><fa-icon class="arrow-green" [icon]="['fas', 'angle-double-up']" [fixedWidth]="true"></fa-icon></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="cpfpInfo?.ancestors?.length">
|
||||||
|
<tr *ngFor="let cpfpTx of cpfpInfo.ancestors">
|
||||||
|
<td><span class="badge badge-primary" i18n="transaction.ancestor|Transaction Ancestor">Ancestor</span></td>
|
||||||
|
<td class="txids">
|
||||||
|
<app-truncate [text]="cpfpTx.txid" [link]="['/tx' | relativeUrl, cpfpTx.txid]"></app-truncate>
|
||||||
|
</td>
|
||||||
|
<td *only-vsize class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight / 4 | vbytes: 2"></td>
|
||||||
|
<td *only-weight class="d-none d-lg-table-cell" [innerHTML]="cpfpTx.weight | wuBytes: 2"></td>
|
||||||
|
<td><app-fee-rate [fee]="cpfpTx.fee" [weight]="cpfpTx.weight"></app-fee-rate></td>
|
||||||
|
<td class="d-none d-lg-table-cell"><fa-icon *ngIf="roundToOneDecimal(cpfpTx) < roundToOneDecimal(tx)" class="arrow-red" [icon]="['fas', 'angle-double-down']" [fixedWidth]="true"></fa-icon></td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<!-- Accelerator -->
|
<!-- Accelerator -->
|
||||||
<ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary && (ETA$ | async) as eta;">
|
<ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary && (ETA$ | async) as eta;">
|
||||||
|
|||||||
@@ -227,6 +227,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cpfp-details {
|
||||||
|
.txids {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.txids {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tx-list {
|
.tx-list {
|
||||||
.alert-link {
|
.alert-link {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
retry({ count: 2, delay: 2000 }),
|
retry({ count: 2, delay: 2000 }),
|
||||||
// Try again until we either get a valid response, or the transaction is confirmed
|
// Try again until we either get a valid response, or the transaction is confirmed
|
||||||
repeat({ delay: 2000 }),
|
repeat({ delay: 2000 }),
|
||||||
filter((transactionTimes) => transactionTimes?.length && transactionTimes[0] > 0 && !this.tx.status?.confirmed),
|
filter((transactionTimes) => transactionTimes?.[0] > 0 || this.tx.status?.confirmed),
|
||||||
take(1),
|
take(1),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
@@ -1054,6 +1054,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.stateService.markBlock$.next({});
|
this.stateService.markBlock$.next({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roundToOneDecimal(cpfpTx: any): number {
|
||||||
|
return +(cpfpTx.fee / (cpfpTx.weight / 4)).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
setupGraph() {
|
setupGraph() {
|
||||||
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1));
|
this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1));
|
||||||
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
|
this.graphHeight = this.graphExpanded ? this.maxInOut * 15 : Math.min(360, this.maxInOut * 80);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { TransactionExtrasModule } from '@components/transaction/transaction-ext
|
|||||||
import { GraphsModule } from '@app/graphs/graphs.module';
|
import { GraphsModule } from '@app/graphs/graphs.module';
|
||||||
import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component';
|
import { AccelerateCheckout } from '@components/accelerate-checkout/accelerate-checkout.component';
|
||||||
import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component';
|
import { AccelerateFeeGraphComponent } from '@components/accelerate-checkout/accelerate-fee-graph.component';
|
||||||
import { TransactionRawComponent } from '@components/transaction/transaction-raw.component';
|
|
||||||
import { CpfpInfoComponent } from '@components/transaction/cpfp-info.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -18,10 +16,6 @@ const routes: Routes = [
|
|||||||
redirectTo: '/',
|
redirectTo: '/',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'preview',
|
|
||||||
component: TransactionRawComponent,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: ':id',
|
path: ':id',
|
||||||
component: TransactionComponent,
|
component: TransactionComponent,
|
||||||
@@ -55,15 +49,12 @@ export class TransactionRoutingModule { }
|
|||||||
TransactionDetailsComponent,
|
TransactionDetailsComponent,
|
||||||
AccelerateCheckout,
|
AccelerateCheckout,
|
||||||
AccelerateFeeGraphComponent,
|
AccelerateFeeGraphComponent,
|
||||||
TransactionRawComponent,
|
|
||||||
CpfpInfoComponent,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TransactionComponent,
|
TransactionComponent,
|
||||||
TransactionDetailsComponent,
|
TransactionDetailsComponent,
|
||||||
AccelerateCheckout,
|
AccelerateCheckout,
|
||||||
AccelerateFeeGraphComponent,
|
AccelerateFeeGraphComponent,
|
||||||
CpfpInfoComponent,
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class TransactionModule { }
|
export class TransactionModule { }
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
@Input() addresses: string[] = [];
|
@Input() addresses: string[] = [];
|
||||||
@Input() rowLimit = 12;
|
@Input() rowLimit = 12;
|
||||||
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
|
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
|
||||||
@Input() txPreview = false;
|
|
||||||
|
|
||||||
@Output() loadMore = new EventEmitter();
|
@Output() loadMore = new EventEmitter();
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
this.refreshOutspends$
|
this.refreshOutspends$
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap((txIds) => {
|
switchMap((txIds) => {
|
||||||
if (!this.cached && !this.txPreview) {
|
if (!this.cached) {
|
||||||
// break list into batches of 50 (maximum supported by esplora)
|
// break list into batches of 50 (maximum supported by esplora)
|
||||||
const batches = [];
|
const batches = [];
|
||||||
for (let i = 0; i < txIds.length; i += 50) {
|
for (let i = 0; i < txIds.length; i += 50) {
|
||||||
@@ -120,7 +119,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
),
|
),
|
||||||
this.refreshChannels$
|
this.refreshChannels$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(() => this.stateService.networkSupportsLightning() && !this.txPreview),
|
filter(() => this.stateService.networkSupportsLightning()),
|
||||||
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
|
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
// handle 404
|
// handle 404
|
||||||
@@ -188,10 +187,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.transactionsLength = this.transactions.length;
|
this.transactionsLength = this.transactions.length;
|
||||||
|
this.cacheService.setTxCache(this.transactions);
|
||||||
if (!this.txPreview) {
|
|
||||||
this.cacheService.setTxCache(this.transactions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
|
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
|
||||||
this.transactions.forEach((tx) => {
|
this.transactions.forEach((tx) => {
|
||||||
@@ -206,12 +202,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
for (const address of this.addresses) {
|
for (const address of this.addresses) {
|
||||||
switch (address.length) {
|
switch (address.length) {
|
||||||
case 130: {
|
case 130: {
|
||||||
if (v.scriptpubkey === '21' + address + 'ac') {
|
if (v.scriptpubkey === '41' + address + 'ac') {
|
||||||
return v.value;
|
return v.value;
|
||||||
}
|
}
|
||||||
} break;
|
} break;
|
||||||
case 66: {
|
case 66: {
|
||||||
if (v.scriptpubkey === '41' + address + 'ac') {
|
if (v.scriptpubkey === '21' + address + 'ac') {
|
||||||
return v.value;
|
return v.value;
|
||||||
}
|
}
|
||||||
} break;
|
} break;
|
||||||
@@ -228,12 +224,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
for (const address of this.addresses) {
|
for (const address of this.addresses) {
|
||||||
switch (address.length) {
|
switch (address.length) {
|
||||||
case 130: {
|
case 130: {
|
||||||
if (v.prevout?.scriptpubkey === '21' + address + 'ac') {
|
if (v.prevout?.scriptpubkey === '41' + address + 'ac') {
|
||||||
return v.prevout?.value;
|
return v.prevout?.value;
|
||||||
}
|
}
|
||||||
} break;
|
} break;
|
||||||
case 66: {
|
case 66: {
|
||||||
if (v.prevout?.scriptpubkey === '41' + address + 'ac') {
|
if (v.prevout?.scriptpubkey === '21' + address + 'ac') {
|
||||||
return v.prevout?.value;
|
return v.prevout?.value;
|
||||||
}
|
}
|
||||||
} break;
|
} break;
|
||||||
@@ -355,7 +351,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadMoreInputs(tx: Transaction): void {
|
loadMoreInputs(tx: Transaction): void {
|
||||||
if (!tx['@vinLoaded'] && !this.txPreview) {
|
if (!tx['@vinLoaded']) {
|
||||||
this.electrsApiService.getTransaction$(tx.txid)
|
this.electrsApiService.getTransaction$(tx.txid)
|
||||||
.subscribe((newTx) => {
|
.subscribe((newTx) => {
|
||||||
tx['@vinLoaded'] = true;
|
tx['@vinLoaded'] = true;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'">
|
<div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'">
|
||||||
<div class="title-address">
|
<div class="title-address">
|
||||||
<h1 i18n="shared.wallet">Wallet</h1>
|
<h1>{{ walletName }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
@@ -74,6 +74,36 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="title-tx">
|
||||||
|
<h2 class="text-left" i18n="address.transactions">Transactions</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-transactions-list [transactions]="transactions" [showConfirmations]="true" [addresses]="addressStrings" (loadMore)="loadMore()"></app-transactions-list>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<ng-template [ngIf]="isLoadingTransactions">
|
||||||
|
<div class="header-bg box">
|
||||||
|
<div class="row" style="height: 107px;">
|
||||||
|
<div class="col-sm">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template [ngIf]="retryLoadMore">
|
||||||
|
<br>
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm" (click)="loadMore()"><fa-icon [icon]="['fas', 'redo-alt']" [fixedWidth]="true"></fa-icon></button>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ng-template #loadingTemplate>
|
<ng-template #loadingTemplate>
|
||||||
|
|
||||||
<div class="box" *ngIf="!error; else errorTemplate">
|
<div class="box" *ngIf="!error; else errorTemplate">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { of, Observable, Subscription } from 'rxjs';
|
|||||||
import { SeoService } from '@app/services/seo.service';
|
import { SeoService } from '@app/services/seo.service';
|
||||||
import { seoDescriptionNetwork } from '@app/shared/common.utils';
|
import { seoDescriptionNetwork } from '@app/shared/common.utils';
|
||||||
import { WalletAddress } from '@interfaces/node-api.interface';
|
import { WalletAddress } from '@interfaces/node-api.interface';
|
||||||
|
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||||
|
import { AudioService } from '@app/services/audio.service';
|
||||||
|
|
||||||
class WalletStats implements ChainStats {
|
class WalletStats implements ChainStats {
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
@@ -24,6 +26,7 @@ class WalletStats implements ChainStats {
|
|||||||
acc.funded_txo_sum += stat.funded_txo_sum;
|
acc.funded_txo_sum += stat.funded_txo_sum;
|
||||||
acc.spent_txo_count += stat.spent_txo_count;
|
acc.spent_txo_count += stat.spent_txo_count;
|
||||||
acc.spent_txo_sum += stat.spent_txo_sum;
|
acc.spent_txo_sum += stat.spent_txo_sum;
|
||||||
|
acc.tx_count += stat.tx_count;
|
||||||
return acc;
|
return acc;
|
||||||
}, {
|
}, {
|
||||||
funded_txo_count: 0,
|
funded_txo_count: 0,
|
||||||
@@ -109,12 +112,17 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
addressStrings: string[] = [];
|
addressStrings: string[] = [];
|
||||||
walletName: string;
|
walletName: string;
|
||||||
isLoadingWallet = true;
|
isLoadingWallet = true;
|
||||||
|
isLoadingTransactions = true;
|
||||||
|
transactions: Transaction[];
|
||||||
|
totalTransactionCount: number;
|
||||||
|
retryLoadMore = false;
|
||||||
wallet$: Observable<Record<string, WalletAddress>>;
|
wallet$: Observable<Record<string, WalletAddress>>;
|
||||||
walletAddresses$: Observable<Record<string, Address>>;
|
walletAddresses$: Observable<Record<string, Address>>;
|
||||||
walletSummary$: Observable<AddressTxSummary[]>;
|
walletSummary$: Observable<AddressTxSummary[]>;
|
||||||
walletStats$: Observable<WalletStats>;
|
walletStats$: Observable<WalletStats>;
|
||||||
error: any;
|
error: any;
|
||||||
walletSubscription: Subscription;
|
walletSubscription: Subscription;
|
||||||
|
transactionSubscription: Subscription;
|
||||||
|
|
||||||
collapseAddresses: boolean = true;
|
collapseAddresses: boolean = true;
|
||||||
|
|
||||||
@@ -129,6 +137,8 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
private websocketService: WebsocketService,
|
private websocketService: WebsocketService,
|
||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
|
private electrsApiService: ElectrsApiService,
|
||||||
|
private audioService: AudioService,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -172,6 +182,21 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
}),
|
}),
|
||||||
switchMap(initial => this.stateService.walletTransactions$.pipe(
|
switchMap(initial => this.stateService.walletTransactions$.pipe(
|
||||||
startWith(null),
|
startWith(null),
|
||||||
|
tap((transactions) => {
|
||||||
|
if (!transactions?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
const tx = this.transactions.find((t) => t.txid === transaction.txid);
|
||||||
|
if (tx) {
|
||||||
|
tx.status = transaction.status;
|
||||||
|
} else {
|
||||||
|
this.transactions.unshift(transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.transactions = this.transactions.slice();
|
||||||
|
this.audioService.playSound('magic');
|
||||||
|
}),
|
||||||
scan((wallet, walletTransactions) => {
|
scan((wallet, walletTransactions) => {
|
||||||
for (const tx of (walletTransactions || [])) {
|
for (const tx of (walletTransactions || [])) {
|
||||||
const funded: Record<string, number> = {};
|
const funded: Record<string, number> = {};
|
||||||
@@ -267,8 +292,57 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
return stats;
|
return stats;
|
||||||
}, walletStats),
|
}, walletStats),
|
||||||
);
|
);
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.transactionSubscription = this.wallet$.pipe(
|
||||||
|
switchMap(wallet => {
|
||||||
|
const addresses = Object.keys(wallet).map(addr => this.normalizeAddress(addr));
|
||||||
|
return this.electrsApiService.getAddressesTransactions$(addresses);
|
||||||
|
}),
|
||||||
|
map(transactions => {
|
||||||
|
// only confirmed transactions supported for now
|
||||||
|
return transactions.filter(tx => tx.status.confirmed).sort((a, b) => b.status.block_height - a.status.block_height);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.log(error);
|
||||||
|
this.error = error;
|
||||||
|
this.seoService.logSoft404();
|
||||||
|
this.isLoadingWallet = false;
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
).subscribe((transactions: Transaction[] | null) => {
|
||||||
|
if (!transactions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.transactions = transactions;
|
||||||
|
this.isLoadingTransactions = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMore(): void {
|
||||||
|
if (this.isLoadingTransactions || this.fullyLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isLoadingTransactions = true;
|
||||||
|
this.retryLoadMore = false;
|
||||||
|
this.electrsApiService.getAddressesTransactions$(this.addressStrings, this.transactions[this.transactions.length - 1].txid)
|
||||||
|
.subscribe((transactions: Transaction[]) => {
|
||||||
|
if (transactions && transactions.length) {
|
||||||
|
this.transactions = this.transactions.concat(transactions.sort((a, b) => b.status.block_height - a.status.block_height));
|
||||||
|
} else {
|
||||||
|
this.fullyLoaded = true;
|
||||||
|
}
|
||||||
|
this.isLoadingTransactions = false;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
this.isLoadingTransactions = false;
|
||||||
|
this.retryLoadMore = true;
|
||||||
|
// In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page.
|
||||||
|
if (error.status === 422) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
|
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
|
||||||
@@ -299,5 +373,6 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.websocketService.stopTrackingWallet();
|
this.websocketService.stopTrackingWallet();
|
||||||
this.walletSubscription.unsubscribe();
|
this.walletSubscription.unsubscribe();
|
||||||
|
this.transactionSubscription.unsubscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
|
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
|
||||||
import { Env, StateService } from '@app/services/state.service';
|
import { Env, StateService } from '@app/services/state.service';
|
||||||
import { restApiDocsData } from '@app/docs/api-docs/api-docs-data';
|
import { restApiDocsData, wsApiDocsData } from '@app/docs/api-docs/api-docs-data';
|
||||||
import { faqData } from '@app/docs/api-docs/api-docs-data';
|
import { faqData } from '@app/docs/api-docs/api-docs-data';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -28,6 +28,8 @@ export class ApiDocsNavComponent implements OnInit {
|
|||||||
this.auditEnabled = this.env.AUDIT;
|
this.auditEnabled = this.env.AUDIT;
|
||||||
if (this.whichTab === 'rest') {
|
if (this.whichTab === 'rest') {
|
||||||
this.tabData = restApiDocsData;
|
this.tabData = restApiDocsData;
|
||||||
|
} else if (this.whichTab === 'websocket') {
|
||||||
|
this.tabData = wsApiDocsData;
|
||||||
} else if (this.whichTab === 'faq') {
|
} else if (this.whichTab === 'faq') {
|
||||||
this.tabData = faqData;
|
this.tabData = faqData;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,18 +108,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="websocketAPI" *ngIf="( whichTab === 'websocket' )">
|
<div id="websocketAPI" *ngIf="whichTab === 'websocket'">
|
||||||
<div class="api-category">
|
|
||||||
<div class="websocket">
|
<div id="doc-nav-desktop" class="hide-on-mobile" [ngClass]="desktopDocsNavPosition">
|
||||||
<div class="endpoint">
|
<app-api-docs-nav (navLinkClickEvent)="anchorLinkClick( $event )" [network]="{ val: network$ | async }" [whichTab]="whichTab"></app-api-docs-nav>
|
||||||
<div class="subtitle" i18n="Api docs endpoint">Endpoint</div>
|
</div>
|
||||||
{{ wrapUrl(network.val, wsDocs, true) }}
|
|
||||||
|
<div class="doc-content">
|
||||||
|
|
||||||
|
<div id="enterprise-cta-mobile" *ngIf="officialMempoolInstance && showMobileEnterpriseUpsell">
|
||||||
|
<p>Get higher API limits with <span class="no-line-break">Mempool Enterprise®</span></p>
|
||||||
|
<div class="button-group">
|
||||||
|
<a class="btn btn-small btn-secondary" (click)="showMobileEnterpriseUpsell = false">No Thanks</a>
|
||||||
|
<a class="btn btn-small btn-purple" href="https://mempool.space/enterprise">More Info <fa-icon [icon]="['fas', 'angle-right']" [styles]="{'font-size': '12px'}"></fa-icon></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
</div>
|
||||||
<div class="subtitle" i18n>Description</div>
|
|
||||||
<div i18n="api-docs.websocket.websocket">Default push: <code>{{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }}</code> to express what you want pushed. Available: <code>blocks</code>, <code>mempool-blocks</code>, <code>live-2h-chart</code>, and <code>stats</code>.<br><br>Push transactions related to address: <code>{{ '{' }} 'track-address': '3PbJ...bF9B' {{ '}' }}</code> to receive all new transactions containing that address as input or output. Returns an array of transactions. <code>address-transactions</code> for new mempool transactions, and <code>block-transactions</code> for new block confirmed transactions.</div>
|
<p class="doc-welcome-note">Below is a reference for the {{ network.val === '' ? 'Bitcoin' : network.val.charAt(0).toUpperCase() + network.val.slice(1) }} <ng-container i18n="api-docs.title-websocket">Websocket service</ng-container> running at {{ websocketUrl(network.val) }}.</p>
|
||||||
|
<p class="doc-welcome-note api-note" *ngIf="officialMempoolInstance">Note that usage limits apply to our WebSocket API. Consider an <a href="https://mempool.space/enterprise">enterprise sponsorship</a> if you need higher API limits, such as higher tracking limits.</p>
|
||||||
|
|
||||||
|
<div class="doc-item-container" *ngFor="let item of wsDocs">
|
||||||
|
<div *ngIf="!item.hasOwnProperty('options') || ( item.hasOwnProperty('options') && item.options.hasOwnProperty('officialOnly') && item.options.officialOnly && officialMempoolInstance )">
|
||||||
|
<h3 *ngIf="( item.type === 'category' ) && ( item.showConditions.indexOf(network.val) > -1 )">{{ item.title }}</h3>
|
||||||
|
<div *ngIf="( item.type !== 'category' ) && ( item.showConditions.indexOf(network.val) > -1 )" class="endpoint-container" id="{{ item.fragment }}">
|
||||||
|
<a id="{{ item.fragment + '-tab-header' }}" class="section-header" (click)="anchorLinkClick({event: $event, fragment: item.fragment})">{{ item.title }} <span>{{ item.category }}</span></a>
|
||||||
|
<div class="endpoint-content">
|
||||||
|
<div class="description">
|
||||||
|
<div class="subtitle" i18n>Description</div>
|
||||||
|
<div [innerHTML]="item.description.default" i18n></div>
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<div class="subtitle" i18n>Payload</div>
|
||||||
|
<pre><code [innerText]="item.payload"></code></pre>
|
||||||
|
</div>
|
||||||
|
<app-code-template [hostname]="hostname" [baseNetworkUrl]="baseNetworkUrl" [method]="item.httpRequestMethod" [code]="item.codeExample.default" [network]="network.val" [showCodeExample]="item.showJsExamples"></app-code-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-code-template [method]="'websocket'" [hostname]="hostname" [code]="wsDocs" [network]="network.val" [showCodeExample]="wsDocs.showJsExamples"></app-code-template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -470,3 +470,21 @@ dd {
|
|||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: var(--bg);
|
||||||
|
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
font-size: 87.5%;
|
||||||
|
color: #f18920;
|
||||||
|
background-color: var(--bg);
|
||||||
|
padding: 30px;
|
||||||
|
code{
|
||||||
|
background-color: transparent;
|
||||||
|
white-space: break-spaces;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
|
|||||||
if (document.getElementById( targetId + "-tab-header" )) {
|
if (document.getElementById( targetId + "-tab-header" )) {
|
||||||
tabHeaderHeight = document.getElementById( targetId + "-tab-header" ).scrollHeight;
|
tabHeaderHeight = document.getElementById( targetId + "-tab-header" ).scrollHeight;
|
||||||
}
|
}
|
||||||
if( ( window.innerWidth <= 992 ) && ( ( this.whichTab === 'rest' ) || ( this.whichTab === 'faq' ) ) && targetId ) {
|
if( ( window.innerWidth <= 992 ) && ( ( this.whichTab === 'rest' ) || ( this.whichTab === 'faq' ) || ( this.whichTab === 'websocket' ) ) && targetId ) {
|
||||||
const endpointContainerEl = document.querySelector<HTMLElement>( "#" + targetId );
|
const endpointContainerEl = document.querySelector<HTMLElement>( "#" + targetId );
|
||||||
const endpointContentEl = document.querySelector<HTMLElement>( "#" + targetId + " .endpoint-content" );
|
const endpointContentEl = document.querySelector<HTMLElement>( "#" + targetId + " .endpoint-content" );
|
||||||
const endPointContentElHeight = endpointContentEl.clientHeight;
|
const endPointContentElHeight = endpointContentEl.clientHeight;
|
||||||
@@ -207,13 +207,29 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
|
|||||||
text = text.replace('%{' + indexNumber + '}', curlText);
|
text = text.replace('%{' + indexNumber + '}', curlText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (websocket) {
|
|
||||||
const wsHostname = this.hostname.replace('https://', 'wss://');
|
|
||||||
wsHostname.replace('http://', 'ws://');
|
|
||||||
return `${wsHostname}${curlNetwork}${text}`;
|
|
||||||
}
|
|
||||||
return `${this.hostname}${curlNetwork}${text}`;
|
return `${this.hostname}${curlNetwork}${text}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
websocketUrl(network: string) {
|
||||||
|
let curlNetwork = '';
|
||||||
|
if (this.env.BASE_MODULE === 'mempool') {
|
||||||
|
if (!['', 'mainnet'].includes(network)) {
|
||||||
|
curlNetwork = `/${network}`;
|
||||||
|
}
|
||||||
|
} else if (this.env.BASE_MODULE === 'liquid') {
|
||||||
|
if (!['', 'liquid'].includes(network)) {
|
||||||
|
curlNetwork = `/${network}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (network === this.env.ROOT_NETWORK) {
|
||||||
|
curlNetwork = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let wsHostname = this.hostname.replace('https://', 'wss://');
|
||||||
|
wsHostname = wsHostname.replace('http://', 'ws://');
|
||||||
|
return `${wsHostname}${curlNetwork}/api/v1/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AddressTxSummary, Block, ChainStats, Transaction } from "./electrs.interface";
|
import { AddressTxSummary, Block, ChainStats } from "./electrs.interface";
|
||||||
|
|
||||||
export interface OptimizedMempoolStats {
|
export interface OptimizedMempoolStats {
|
||||||
added: number;
|
added: number;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface WebsocketResponse {
|
|||||||
rbfInfo?: RbfTree;
|
rbfInfo?: RbfTree;
|
||||||
rbfLatest?: RbfTree[];
|
rbfLatest?: RbfTree[];
|
||||||
rbfLatestSummary?: ReplacementInfo[];
|
rbfLatestSummary?: ReplacementInfo[];
|
||||||
|
stratumJob?: StratumJob;
|
||||||
|
stratumJobs?: Record<number, StratumJob>;
|
||||||
utxoSpent?: object;
|
utxoSpent?: object;
|
||||||
transactions?: TransactionStripped[];
|
transactions?: TransactionStripped[];
|
||||||
loadingIndicators?: ILoadingIndicators;
|
loadingIndicators?: ILoadingIndicators;
|
||||||
@@ -37,6 +39,7 @@ export interface WebsocketResponse {
|
|||||||
'track-rbf-summary'?: boolean;
|
'track-rbf-summary'?: boolean;
|
||||||
'track-accelerations'?: boolean;
|
'track-accelerations'?: boolean;
|
||||||
'track-wallet'?: string;
|
'track-wallet'?: string;
|
||||||
|
'track-stratum'?: string | number;
|
||||||
'watch-mempool'?: boolean;
|
'watch-mempool'?: boolean;
|
||||||
'refresh-blocks'?: boolean;
|
'refresh-blocks'?: boolean;
|
||||||
}
|
}
|
||||||
@@ -150,3 +153,24 @@ export interface HealthCheckHost {
|
|||||||
electrs?: string;
|
electrs?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StratumJob {
|
||||||
|
pool: number;
|
||||||
|
height: number;
|
||||||
|
coinbase: string;
|
||||||
|
scriptsig: string;
|
||||||
|
reward: number;
|
||||||
|
jobId: string;
|
||||||
|
extraNonce: string;
|
||||||
|
extraNonce2Size: number;
|
||||||
|
prevHash: string;
|
||||||
|
coinbase1: string;
|
||||||
|
coinbase2: string;
|
||||||
|
merkleBranches: string[];
|
||||||
|
version: string;
|
||||||
|
bits: string;
|
||||||
|
time: string;
|
||||||
|
timestamp: number;
|
||||||
|
cleanJobs: boolean;
|
||||||
|
received: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { TestTransactionsComponent } from '@components/test-transactions/test-tr
|
|||||||
import { CalculatorComponent } from '@components/calculator/calculator.component';
|
import { CalculatorComponent } from '@components/calculator/calculator.component';
|
||||||
import { BlocksList } from '@components/blocks-list/blocks-list.component';
|
import { BlocksList } from '@components/blocks-list/blocks-list.component';
|
||||||
import { RbfList } from '@components/rbf-list/rbf-list.component';
|
import { RbfList } from '@components/rbf-list/rbf-list.component';
|
||||||
|
import { StratumList } from '@components/stratum/stratum-list/stratum-list.component';
|
||||||
import { ServerHealthComponent } from '@components/server-health/server-health.component';
|
import { ServerHealthComponent } from '@components/server-health/server-health.component';
|
||||||
import { ServerStatusComponent } from '@components/server-health/server-status.component';
|
import { ServerStatusComponent } from '@components/server-health/server-status.component';
|
||||||
import { FaucetComponent } from '@components/faucet/faucet.component'
|
import { FaucetComponent } from '@components/faucet/faucet.component';
|
||||||
|
|
||||||
const browserWindow = window || {};
|
const browserWindow = window || {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -56,6 +57,16 @@ const routes: Routes = [
|
|||||||
path: 'rbf',
|
path: 'rbf',
|
||||||
component: RbfList,
|
component: RbfList,
|
||||||
},
|
},
|
||||||
|
...(browserWindowEnv.STRATUM_ENABLED ? [{
|
||||||
|
path: 'stratum',
|
||||||
|
component: StartComponent,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: StratumList,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
path: 'terms-of-service',
|
path: 'terms-of-service',
|
||||||
loadChildren: () => import('@components/terms-of-service/terms-of-service.module').then(m => m.TermsOfServiceModule),
|
loadChildren: () => import('@components/terms-of-service/terms-of-service.module').then(m => m.TermsOfServiceModule),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class GuardService {
|
|||||||
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
|
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
|
||||||
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
|
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
|
||||||
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
|
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
|
||||||
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path));
|
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test'].includes(path[1].path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -565,14 +565,6 @@ export class ApiService {
|
|||||||
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, '');
|
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
getPrevouts$(outpoints: {txid: string; vout: number}[]): Observable<any> {
|
|
||||||
return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/prevouts', outpoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCpfpLocalTx$(tx: any[]): Observable<CpfpInfo[]> {
|
|
||||||
return this.httpClient.post<CpfpInfo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/cpfp', tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache methods
|
// Cache methods
|
||||||
async setBlockAuditLoaded(hash: string) {
|
async setBlockAuditLoaded(hash: string) {
|
||||||
this.blockAuditLoaded[hash] = true;
|
this.blockAuditLoaded[hash] = true;
|
||||||
|
|||||||
@@ -142,12 +142,16 @@ export class ElectrsApiService {
|
|||||||
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params });
|
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getAddressesTransactions$(addresses: string[], txid?: string): Observable<Transaction[]> {
|
getAddressesTransactions$(addresses: string[], txid?: string): Observable<Transaction[]> {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
if (txid) {
|
if (txid) {
|
||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs?addresses=${addresses.join(',')}`, { params });
|
return this.httpClient.post<Transaction[]>(
|
||||||
|
this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs',
|
||||||
|
addresses,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAddressSummary$(address: string, txid?: string): Observable<AddressTxSummary[]> {
|
getAddressSummary$(address: string, txid?: string): Observable<AddressTxSummary[]> {
|
||||||
@@ -163,7 +167,7 @@ export class ElectrsApiService {
|
|||||||
if (txid) {
|
if (txid) {
|
||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs/summary?addresses=${addresses.join(',')}`, { params });
|
return this.httpClient.post<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs/summary', addresses, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getScriptHashTransactions$(script: string, txid?: string): Observable<Transaction[]> {
|
getScriptHashTransactions$(script: string, txid?: string): Observable<Transaction[]> {
|
||||||
@@ -182,7 +186,7 @@ export class ElectrsApiService {
|
|||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
||||||
switchMap(scriptHashes => this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs?scripthashes=${scriptHashes.join(',')}`, { params })),
|
switchMap(scriptHashes => this.httpClient.post<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs', scriptHashes, { params })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +216,7 @@ export class ElectrsApiService {
|
|||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
||||||
switchMap(scriptHashes => this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs/summary?scripthashes=${scriptHashes.join(',')}`, { params })),
|
switchMap(scriptHashes => this.httpClient.post<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs/summary', scriptHashes, { params })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export class MiningService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get names and slugs of all pools
|
* Get names and slugs of all pools
|
||||||
*/
|
*/
|
||||||
public getPools(): Observable<any[]> {
|
public getPools(): Observable<any[]> {
|
||||||
@@ -75,7 +75,6 @@ export class MiningService {
|
|||||||
return this.poolsData;
|
return this.poolsData;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Set the hashrate power of ten we want to display
|
* Set the hashrate power of ten we want to display
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export interface IUser {
|
|||||||
subscription_tag: string;
|
subscription_tag: string;
|
||||||
status: 'pending' | 'verified' | 'disabled';
|
status: 'pending' | 'verified' | 'disabled';
|
||||||
features: string | null;
|
features: string | null;
|
||||||
fullName: string | null;
|
|
||||||
countryCode: string | null;
|
countryCode: string | null;
|
||||||
imageMd5: string;
|
imageMd5: string;
|
||||||
ogRank: number | null;
|
ogRank: number | null;
|
||||||
@@ -143,8 +142,8 @@ export class ServicesApiServices {
|
|||||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, userApprovedUSD: userApprovedUSD });
|
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, userApprovedUSD: userApprovedUSD });
|
||||||
}
|
}
|
||||||
|
|
||||||
accelerateWithGooglePay$(txInput: string, token: string, verificationToken: string, cardTag: string, referenceId: string, userApprovedUSD: number) {
|
accelerateWithGooglePay$(txInput: string, token: string, verificationToken: string, cardTag: string, referenceId: string, userApprovedUSD: number, userChallenged: boolean) {
|
||||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, verificationToken: verificationToken, referenceId: referenceId, userApprovedUSD: userApprovedUSD });
|
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, verificationToken: verificationToken, referenceId: referenceId, userApprovedUSD: userApprovedUSD, userChallenged: userChallenged });
|
||||||
}
|
}
|
||||||
|
|
||||||
getAccelerations$(): Observable<Acceleration[]> {
|
getAccelerations$(): Observable<Acceleration[]> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
|
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
|
||||||
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
|
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
|
||||||
import { AddressTxSummary, Transaction } from '@interfaces/electrs.interface';
|
import { Transaction } from '@interfaces/electrs.interface';
|
||||||
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '@interfaces/websocket.interface';
|
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, StratumJob, isMempoolState } from '@interfaces/websocket.interface';
|
||||||
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface';
|
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface';
|
||||||
import { Router, NavigationStart } from '@angular/router';
|
import { Router, NavigationStart } from '@angular/router';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
@@ -81,6 +81,7 @@ export interface Env {
|
|||||||
ADDITIONAL_CURRENCIES: boolean;
|
ADDITIONAL_CURRENCIES: boolean;
|
||||||
GIT_COMMIT_HASH_MEMPOOL_SPACE?: string;
|
GIT_COMMIT_HASH_MEMPOOL_SPACE?: string;
|
||||||
PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string;
|
PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string;
|
||||||
|
STRATUM_ENABLED: boolean;
|
||||||
SERVICES_API?: string;
|
SERVICES_API?: string;
|
||||||
customize?: Customization;
|
customize?: Customization;
|
||||||
PROD_DOMAINS: string[];
|
PROD_DOMAINS: string[];
|
||||||
@@ -123,6 +124,7 @@ const defaultEnv: Env = {
|
|||||||
'ACCELERATOR_BUTTON': true,
|
'ACCELERATOR_BUTTON': true,
|
||||||
'PUBLIC_ACCELERATIONS': false,
|
'PUBLIC_ACCELERATIONS': false,
|
||||||
'ADDITIONAL_CURRENCIES': false,
|
'ADDITIONAL_CURRENCIES': false,
|
||||||
|
'STRATUM_ENABLED': false,
|
||||||
'SERVICES_API': 'https://mempool.space/api/v1/services',
|
'SERVICES_API': 'https://mempool.space/api/v1/services',
|
||||||
'PROD_DOMAINS': [],
|
'PROD_DOMAINS': [],
|
||||||
};
|
};
|
||||||
@@ -159,6 +161,8 @@ export class StateService {
|
|||||||
liveMempoolBlockTransactions$: Observable<{ block: number, transactions: { [txid: string]: TransactionStripped} }>;
|
liveMempoolBlockTransactions$: Observable<{ block: number, transactions: { [txid: string]: TransactionStripped} }>;
|
||||||
accelerations$ = new Subject<AccelerationDelta>();
|
accelerations$ = new Subject<AccelerationDelta>();
|
||||||
liveAccelerations$: Observable<Acceleration[]>;
|
liveAccelerations$: Observable<Acceleration[]>;
|
||||||
|
stratumJobUpdate$ = new Subject<{ state: Record<string, StratumJob> } | { job: StratumJob }>();
|
||||||
|
stratumJobs$ = new BehaviorSubject<Record<string, StratumJob>>({});
|
||||||
txConfirmed$ = new Subject<[string, BlockExtended]>();
|
txConfirmed$ = new Subject<[string, BlockExtended]>();
|
||||||
txReplaced$ = new Subject<ReplacedTransaction>();
|
txReplaced$ = new Subject<ReplacedTransaction>();
|
||||||
txRbfInfo$ = new Subject<RbfTree>();
|
txRbfInfo$ = new Subject<RbfTree>();
|
||||||
@@ -303,6 +307,24 @@ export class StateService {
|
|||||||
map((accMap) => Object.values(accMap).sort((a,b) => b.added - a.added))
|
map((accMap) => Object.values(accMap).sort((a,b) => b.added - a.added))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.stratumJobUpdate$.pipe(
|
||||||
|
scan((acc: Record<string, StratumJob>, update: { state: Record<string, StratumJob> } | { job: StratumJob }) => {
|
||||||
|
if ('state' in update) {
|
||||||
|
// Replace the entire state
|
||||||
|
return update.state;
|
||||||
|
} else {
|
||||||
|
// Update or create a single job entry
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[update.job.pool]: update.job
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, {}),
|
||||||
|
shareReplay(1)
|
||||||
|
).subscribe(val => {
|
||||||
|
this.stratumJobs$.next(val);
|
||||||
|
});
|
||||||
|
|
||||||
this.networkChanged$.subscribe((network) => {
|
this.networkChanged$.subscribe((network) => {
|
||||||
this.transactions$ = new BehaviorSubject<TransactionStripped[]>(null);
|
this.transactions$ = new BehaviorSubject<TransactionStripped[]>(null);
|
||||||
this.blocksSubject$.next([]);
|
this.blocksSubject$.next([]);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class WebsocketService {
|
|||||||
private isTrackingAccelerations: boolean = false;
|
private isTrackingAccelerations: boolean = false;
|
||||||
private isTrackingWallet: boolean = false;
|
private isTrackingWallet: boolean = false;
|
||||||
private trackingWalletName: string;
|
private trackingWalletName: string;
|
||||||
|
private isTrackingStratum: string | number | false = false;
|
||||||
private trackingMempoolBlock: number;
|
private trackingMempoolBlock: number;
|
||||||
private trackingMempoolBlockNetwork: string;
|
private trackingMempoolBlockNetwork: string;
|
||||||
private stoppingTrackMempoolBlock: any | null = null;
|
private stoppingTrackMempoolBlock: any | null = null;
|
||||||
@@ -143,6 +144,9 @@ export class WebsocketService {
|
|||||||
if (this.isTrackingWallet) {
|
if (this.isTrackingWallet) {
|
||||||
this.startTrackingWallet(this.trackingWalletName);
|
this.startTrackingWallet(this.trackingWalletName);
|
||||||
}
|
}
|
||||||
|
if (this.isTrackingStratum !== false) {
|
||||||
|
this.startTrackStratum(this.isTrackingStratum);
|
||||||
|
}
|
||||||
this.stateService.connectionState$.next(2);
|
this.stateService.connectionState$.next(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +293,18 @@ export class WebsocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTrackStratum(pool: number | string) {
|
||||||
|
this.websocketSubject.next({ 'track-stratum': pool });
|
||||||
|
this.isTrackingStratum = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTrackStratum() {
|
||||||
|
if (this.isTrackingStratum) {
|
||||||
|
this.websocketSubject.next({ 'track-stratum': null });
|
||||||
|
this.isTrackingStratum = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchStatistics(historicalDate: string) {
|
fetchStatistics(historicalDate: string) {
|
||||||
this.websocketSubject.next({ historicalDate });
|
this.websocketSubject.next({ historicalDate });
|
||||||
}
|
}
|
||||||
@@ -512,6 +528,14 @@ export class WebsocketService {
|
|||||||
this.stateService.previousRetarget$.next(response.previousRetarget);
|
this.stateService.previousRetarget$.next(response.previousRetarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.stratumJobs) {
|
||||||
|
this.stateService.stratumJobUpdate$.next({ state: response.stratumJobs });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.stratumJob) {
|
||||||
|
this.stateService.stratumJobUpdate$.next({ job: response.stratumJob });
|
||||||
|
}
|
||||||
|
|
||||||
if (response['tomahawk']) {
|
if (response['tomahawk']) {
|
||||||
this.stateService.serverHealth$.next(response['tomahawk']);
|
this.stateService.serverHealth$.next(response['tomahawk']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && replaced">
|
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && replaced">
|
||||||
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.replaced|Transaction replaced state">Replaced</button>
|
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.replaced|Transaction replaced state">Replaced</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && removed">
|
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && (removed || cached)">
|
||||||
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.audit.removed|Transaction removed state">Removed</button>
|
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.audit.removed|Transaction removed state">Removed</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template [ngIf]="!hideUnconfirmed && chainTip != null && !confirmations && !replaced && !removed">
|
<ng-template [ngIf]="!hideUnconfirmed && chainTip != null && !confirmations && !replaced && !(removed || cached)">
|
||||||
<button type="button" class="btn btn-sm btn-danger no-cursor {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
|
<button type="button" class="btn btn-sm btn-danger no-cursor {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -12,6 +12,7 @@ export class ConfirmationsComponent implements OnChanges {
|
|||||||
@Input() height: number;
|
@Input() height: number;
|
||||||
@Input() replaced: boolean = false;
|
@Input() replaced: boolean = false;
|
||||||
@Input() removed: boolean = false;
|
@Input() removed: boolean = false;
|
||||||
|
@Input() cached: boolean = false;
|
||||||
@Input() hideUnconfirmed: boolean = false;
|
@Input() hideUnconfirmed: boolean = false;
|
||||||
@Input() buttonClass: string = '';
|
@Input() buttonClass: string = '';
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,6 @@
|
|||||||
<p><a [routerLink]="['/blocks' | relativeUrl]" i18n="dashboard.recent-blocks">Recent Blocks</a></p>
|
<p><a [routerLink]="['/blocks' | relativeUrl]" i18n="dashboard.recent-blocks">Recent Blocks</a></p>
|
||||||
<p><a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a></p>
|
<p><a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a></p>
|
||||||
<p><a [routerLink]="['/tx/test' | relativeUrl]" i18n="shared.test-transaction|Test Transaction">Test Transaction</a></p>
|
<p><a [routerLink]="['/tx/test' | relativeUrl]" i18n="shared.test-transaction|Test Transaction">Test Transaction</a></p>
|
||||||
<p><a [routerLink]="['/tx/preview' | relativeUrl]" i18n="shared.preview-transaction|Preview Transaction">Preview Transaction</a></p>
|
|
||||||
<p *ngIf="officialMempoolSpace"><a [routerLink]="['/lightning/group/the-mempool-open-source-project' | relativeUrl]" i18n="footer.connect-to-our-nodes">Connect to our Nodes</a></p>
|
<p *ngIf="officialMempoolSpace"><a [routerLink]="['/lightning/group/the-mempool-open-source-project' | relativeUrl]" i18n="footer.connect-to-our-nodes">Connect to our Nodes</a></p>
|
||||||
<p><a [routerLink]="['/docs/api' | relativeUrl]" i18n="footer.api-documentation">API Documentation</a></p>
|
<p><a [routerLink]="['/docs/api' | relativeUrl]" i18n="footer.api-documentation">API Documentation</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<span class="truncate" [style.max-width]="maxWidth ? maxWidth + 'px' : null" [style.justify-content]="textAlign" [class.inline]="inline">
|
<span class="truncate" [style.max-width]="maxWidth ? maxWidth + 'px' : null" [style.justify-content]="textAlign" [class.inline]="inline">
|
||||||
<ng-container *ngIf="link">
|
<ng-container *ngIf="link">
|
||||||
<a [routerLink]="link" [queryParams]="queryParams" class="truncate-link" [target]="external ? '_blank' : '_self'" [class.disabled]="disabled">
|
<a [routerLink]="link" [queryParams]="queryParams" class="truncate-link" [target]="external ? '_blank' : '_self'">
|
||||||
<ng-container *ngIf="rtl; then rtlTruncated; else ltrTruncated;"></ng-container>
|
<ng-container *ngIf="rtl; then rtlTruncated; else ltrTruncated;"></ng-container>
|
||||||
</a>
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -37,12 +37,6 @@
|
|||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.8;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 567px) {
|
@media (max-width: 567px) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export class TruncateComponent {
|
|||||||
@Input() maxWidth: number = null;
|
@Input() maxWidth: number = null;
|
||||||
@Input() inline: boolean = false;
|
@Input() inline: boolean = false;
|
||||||
@Input() textAlign: 'start' | 'end' = 'start';
|
@Input() textAlign: 'start' | 'end' = 'start';
|
||||||
@Input() disabled: boolean = false;
|
|
||||||
rtl: boolean;
|
rtl: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra
|
|||||||
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
||||||
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
|
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
|
||||||
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faClock, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
|
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faClock, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
|
||||||
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 } from '@fortawesome/free-solid-svg-icons';
|
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 } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
|
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
|
||||||
import { MenuComponent } from '@components/menu/menu.component';
|
import { MenuComponent } from '@components/menu/menu.component';
|
||||||
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
|
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
|
||||||
@@ -80,6 +83,7 @@ import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe';
|
|||||||
import { DifficultyAdjustmentsTable } from '@components/difficulty-adjustments-table/difficulty-adjustments-table.components';
|
import { DifficultyAdjustmentsTable } from '@components/difficulty-adjustments-table/difficulty-adjustments-table.components';
|
||||||
import { BlocksList } from '@components/blocks-list/blocks-list.component';
|
import { BlocksList } from '@components/blocks-list/blocks-list.component';
|
||||||
import { RbfList } from '@components/rbf-list/rbf-list.component';
|
import { RbfList } from '@components/rbf-list/rbf-list.component';
|
||||||
|
import { StratumList } from '@components/stratum/stratum-list/stratum-list.component';
|
||||||
import { RewardStatsComponent } from '@components/reward-stats/reward-stats.component';
|
import { RewardStatsComponent } from '@components/reward-stats/reward-stats.component';
|
||||||
import { DataCyDirective } from '@app/data-cy.directive';
|
import { DataCyDirective } from '@app/data-cy.directive';
|
||||||
import { LoadingIndicatorComponent } from '@components/loading-indicator/loading-indicator.component';
|
import { LoadingIndicatorComponent } from '@components/loading-indicator/loading-indicator.component';
|
||||||
@@ -198,6 +202,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/
|
|||||||
DifficultyAdjustmentsTable,
|
DifficultyAdjustmentsTable,
|
||||||
BlocksList,
|
BlocksList,
|
||||||
RbfList,
|
RbfList,
|
||||||
|
StratumList,
|
||||||
DataCyDirective,
|
DataCyDirective,
|
||||||
RewardStatsComponent,
|
RewardStatsComponent,
|
||||||
LoadingIndicatorComponent,
|
LoadingIndicatorComponent,
|
||||||
@@ -342,6 +347,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/
|
|||||||
AmountShortenerPipe,
|
AmountShortenerPipe,
|
||||||
DifficultyAdjustmentsTable,
|
DifficultyAdjustmentsTable,
|
||||||
BlocksList,
|
BlocksList,
|
||||||
|
StratumList,
|
||||||
DataCyDirective,
|
DataCyDirective,
|
||||||
RewardStatsComponent,
|
RewardStatsComponent,
|
||||||
LoadingIndicatorComponent,
|
LoadingIndicatorComponent,
|
||||||
@@ -451,5 +457,8 @@ export class SharedModule {
|
|||||||
library.addIcons(faTimeline);
|
library.addIcons(faTimeline);
|
||||||
library.addIcons(faCircleXmark);
|
library.addIcons(faCircleXmark);
|
||||||
library.addIcons(faCalendarCheck);
|
library.addIcons(faCalendarCheck);
|
||||||
|
library.addIcons(faMoneyBillTrendUp);
|
||||||
|
library.addIcons(faRobot);
|
||||||
|
library.addIcons(faShareNodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { TransactionFlags } from '@app/shared/filters.utils';
|
import { TransactionFlags } from '@app/shared/filters.utils';
|
||||||
import { getVarIntLength, parseMultisigScript, isPoint } from '@app/shared/script.utils';
|
import { getVarIntLength, opcodes, parseMultisigScript, isPoint } from '@app/shared/script.utils';
|
||||||
import { Transaction, Vin } from '@interfaces/electrs.interface';
|
import { Transaction } from '@interfaces/electrs.interface';
|
||||||
import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface';
|
import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface';
|
||||||
import { StateService } from '@app/services/state.service';
|
import { StateService } from '@app/services/state.service';
|
||||||
import { Hash } from './sha256';
|
|
||||||
|
|
||||||
// Bitcoin Core default policy settings
|
// Bitcoin Core default policy settings
|
||||||
const MAX_STANDARD_TX_WEIGHT = 400_000;
|
const MAX_STANDARD_TX_WEIGHT = 400_000;
|
||||||
@@ -589,762 +588,3 @@ export function identifyPrioritizedTransactions(transactions: TransactionStrippe
|
|||||||
|
|
||||||
return { prioritized, deprioritized };
|
return { prioritized, deprioritized };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adapted from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L254
|
|
||||||
// Converts hex bitcoin script to ASM
|
|
||||||
function convertScriptSigAsm(hex: string): string {
|
|
||||||
|
|
||||||
const buf = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < buf.length; i++) {
|
|
||||||
buf[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
const b = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < buf.length) {
|
|
||||||
const op = buf[i];
|
|
||||||
if (op >= 0x01 && op <= 0x4e) {
|
|
||||||
i++;
|
|
||||||
let push;
|
|
||||||
if (op === 0x4c) {
|
|
||||||
push = buf[i];
|
|
||||||
b.push('OP_PUSHDATA1');
|
|
||||||
i += 1;
|
|
||||||
} else if (op === 0x4d) {
|
|
||||||
push = buf[i] | (buf[i + 1] << 8);
|
|
||||||
b.push('OP_PUSHDATA2');
|
|
||||||
i += 2;
|
|
||||||
} else if (op === 0x4e) {
|
|
||||||
push = buf[i] | (buf[i + 1] << 8) | (buf[i + 2] << 16) | (buf[i + 3] << 24);
|
|
||||||
b.push('OP_PUSHDATA4');
|
|
||||||
i += 4;
|
|
||||||
} else {
|
|
||||||
push = op;
|
|
||||||
b.push('OP_PUSHBYTES_' + push);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = buf.slice(i, i + push);
|
|
||||||
if (data.length !== push) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
b.push(uint8ArrayToHexString(data));
|
|
||||||
i += data.length;
|
|
||||||
} else {
|
|
||||||
if (op === 0x00) {
|
|
||||||
b.push('OP_0');
|
|
||||||
} else if (op === 0x4f) {
|
|
||||||
b.push('OP_PUSHNUM_NEG1');
|
|
||||||
} else if (op === 0xb1) {
|
|
||||||
b.push('OP_CLTV');
|
|
||||||
} else if (op === 0xb2) {
|
|
||||||
b.push('OP_CSV');
|
|
||||||
} else if (op === 0xba) {
|
|
||||||
b.push('OP_CHECKSIGADD');
|
|
||||||
} else {
|
|
||||||
const opcode = opcodes[op];
|
|
||||||
if (opcode) {
|
|
||||||
b.push(opcode);
|
|
||||||
} else {
|
|
||||||
b.push('OP_RETURN_' + op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L327
|
|
||||||
/**
|
|
||||||
* This function must only be called when we know the witness we are parsing
|
|
||||||
* is a taproot witness.
|
|
||||||
* @param witness An array of hex strings that represents the witness stack of
|
|
||||||
* the input.
|
|
||||||
* @returns null if the witness is not a script spend, and the hex string of
|
|
||||||
* the script item if it is a script spend.
|
|
||||||
*/
|
|
||||||
function witnessToP2TRScript(witness: string[]): string | null {
|
|
||||||
if (witness.length < 2) return null;
|
|
||||||
// Note: see BIP341 for parsing details of witness stack
|
|
||||||
|
|
||||||
// If there are at least two witness elements, and the first byte of the
|
|
||||||
// last element is 0x50, this last element is called annex a and
|
|
||||||
// is removed from the witness stack.
|
|
||||||
const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50';
|
|
||||||
// If there are at least two witness elements left, script path spending is used.
|
|
||||||
// Call the second-to-last stack element s, the script.
|
|
||||||
// (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
|
|
||||||
if (hasAnnex && witness.length < 3) return null;
|
|
||||||
const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2;
|
|
||||||
return witness[positionOfScript];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L227
|
|
||||||
// Fills inner_redeemscript_asm and inner_witnessscript_asm fields of fetched prevouts for decoded transactions
|
|
||||||
export function addInnerScriptsToVin(vin: Vin): void {
|
|
||||||
if (!vin.prevout) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
|
||||||
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
|
||||||
vin.inner_redeemscript_asm = convertScriptSigAsm(redeemScript);
|
|
||||||
if (vin.witness && vin.witness.length > 2) {
|
|
||||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
|
||||||
vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
|
|
||||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
|
||||||
vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) {
|
|
||||||
const witnessScript = witnessToP2TRScript(vin.witness);
|
|
||||||
if (witnessScript !== null) {
|
|
||||||
vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adapted from bitcoinjs-lib at https://github.com/bitcoinjs/bitcoinjs-lib/blob/32e08aa57f6a023e995d8c4f0c9fbdc5f11d1fa0/ts_src/transaction.ts#L78
|
|
||||||
// Reads buffer of raw transaction data
|
|
||||||
function fromBuffer(buffer: Uint8Array, network: string): Transaction {
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
function readInt8(): number {
|
|
||||||
if (offset + 1 > buffer.length) {
|
|
||||||
throw new Error('Buffer out of bounds');
|
|
||||||
}
|
|
||||||
return buffer[offset++];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readInt16() {
|
|
||||||
if (offset + 2 > buffer.length) {
|
|
||||||
throw new Error('Buffer out of bounds');
|
|
||||||
}
|
|
||||||
const value = buffer[offset] | (buffer[offset + 1] << 8);
|
|
||||||
offset += 2;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readInt32(unsigned = false): number {
|
|
||||||
if (offset + 4 > buffer.length) {
|
|
||||||
throw new Error('Buffer out of bounds');
|
|
||||||
}
|
|
||||||
const value = buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24);
|
|
||||||
offset += 4;
|
|
||||||
if (unsigned) {
|
|
||||||
return value >>> 0;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readInt64(): bigint {
|
|
||||||
if (offset + 8 > buffer.length) {
|
|
||||||
throw new Error('Buffer out of bounds');
|
|
||||||
}
|
|
||||||
const low = BigInt(buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24));
|
|
||||||
const high = BigInt(buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24));
|
|
||||||
offset += 8;
|
|
||||||
return (high << 32n) | (low & 0xffffffffn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readVarInt(): bigint {
|
|
||||||
const first = readInt8();
|
|
||||||
if (first < 0xfd) {
|
|
||||||
return BigInt(first);
|
|
||||||
} else if (first === 0xfd) {
|
|
||||||
return BigInt(readInt16());
|
|
||||||
} else if (first === 0xfe) {
|
|
||||||
return BigInt(readInt32(true));
|
|
||||||
} else if (first === 0xff) {
|
|
||||||
return readInt64();
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid VarInt prefix");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSlice(n: number | bigint): Uint8Array {
|
|
||||||
const length = Number(n);
|
|
||||||
if (offset + length > buffer.length) {
|
|
||||||
throw new Error('Cannot read slice out of bounds');
|
|
||||||
}
|
|
||||||
const slice = buffer.slice(offset, offset + length);
|
|
||||||
offset += length;
|
|
||||||
return slice;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readVarSlice(): Uint8Array {
|
|
||||||
return readSlice(readVarInt());
|
|
||||||
}
|
|
||||||
|
|
||||||
function readVector(): Uint8Array[] {
|
|
||||||
const count = readVarInt();
|
|
||||||
const vector = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
vector.push(readVarSlice());
|
|
||||||
}
|
|
||||||
return vector;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse raw transaction
|
|
||||||
const tx = {
|
|
||||||
status: {
|
|
||||||
confirmed: null,
|
|
||||||
block_height: null,
|
|
||||||
block_hash: null,
|
|
||||||
block_time: null,
|
|
||||||
}
|
|
||||||
} as Transaction;
|
|
||||||
|
|
||||||
tx.version = readInt32();
|
|
||||||
|
|
||||||
const marker = readInt8();
|
|
||||||
const flag = readInt8();
|
|
||||||
|
|
||||||
let hasWitnesses = false;
|
|
||||||
if (
|
|
||||||
marker === 0x00 &&
|
|
||||||
flag === 0x01
|
|
||||||
) {
|
|
||||||
hasWitnesses = true;
|
|
||||||
} else {
|
|
||||||
offset -= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vinLen = readVarInt();
|
|
||||||
tx.vin = [];
|
|
||||||
for (let i = 0; i < vinLen; ++i) {
|
|
||||||
const txid = uint8ArrayToHexString(readSlice(32).reverse());
|
|
||||||
const vout = readInt32(true);
|
|
||||||
const scriptsig = uint8ArrayToHexString(readVarSlice());
|
|
||||||
const sequence = readInt32(true);
|
|
||||||
const is_coinbase = txid === '0'.repeat(64);
|
|
||||||
const scriptsig_asm = convertScriptSigAsm(scriptsig);
|
|
||||||
tx.vin.push({ txid, vout, scriptsig, sequence, is_coinbase, scriptsig_asm, prevout: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const voutLen = readVarInt();
|
|
||||||
tx.vout = [];
|
|
||||||
for (let i = 0; i < voutLen; ++i) {
|
|
||||||
const value = Number(readInt64());
|
|
||||||
const scriptpubkeyArray = readVarSlice();
|
|
||||||
const scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray)
|
|
||||||
const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey);
|
|
||||||
const toAddress = scriptPubKeyToAddress(scriptpubkey, network);
|
|
||||||
const scriptpubkey_type = toAddress.type;
|
|
||||||
const scriptpubkey_address = toAddress?.address;
|
|
||||||
tx.vout.push({ value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address });
|
|
||||||
}
|
|
||||||
|
|
||||||
let witnessSize = 0;
|
|
||||||
if (hasWitnesses) {
|
|
||||||
const startOffset = offset;
|
|
||||||
for (let i = 0; i < vinLen; ++i) {
|
|
||||||
tx.vin[i].witness = readVector().map(uint8ArrayToHexString);
|
|
||||||
}
|
|
||||||
witnessSize = offset - startOffset + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.locktime = readInt32(true);
|
|
||||||
|
|
||||||
if (offset !== buffer.length) {
|
|
||||||
throw new Error('Transaction has unexpected data');
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.size = buffer.length;
|
|
||||||
tx.weight = (tx.size - witnessSize) * 3 + tx.size;
|
|
||||||
|
|
||||||
tx.txid = txid(tx);
|
|
||||||
|
|
||||||
return tx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeRawTransaction(rawtx: string, network: string): Transaction {
|
|
||||||
if (!rawtx.length || rawtx.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(rawtx)) {
|
|
||||||
throw new Error('Invalid hex string');
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = new Uint8Array(rawtx.length / 2);
|
|
||||||
for (let i = 0; i < rawtx.length; i += 2) {
|
|
||||||
buffer[i / 2] = parseInt(rawtx.substring(i, i + 2), 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fromBuffer(buffer, network);
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeTransaction(tx: Transaction): Uint8Array {
|
|
||||||
const result: number[] = [];
|
|
||||||
|
|
||||||
// Add version
|
|
||||||
result.push(...intToBytes(tx.version, 4));
|
|
||||||
|
|
||||||
// Add input count and inputs
|
|
||||||
result.push(...varIntToBytes(tx.vin.length));
|
|
||||||
for (const input of tx.vin) {
|
|
||||||
result.push(...hexStringToUint8Array(input.txid).reverse());
|
|
||||||
result.push(...intToBytes(input.vout, 4));
|
|
||||||
const scriptSig = hexStringToUint8Array(input.scriptsig);
|
|
||||||
result.push(...varIntToBytes(scriptSig.length));
|
|
||||||
result.push(...scriptSig);
|
|
||||||
result.push(...intToBytes(input.sequence, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add output count and outputs
|
|
||||||
result.push(...varIntToBytes(tx.vout.length));
|
|
||||||
for (const output of tx.vout) {
|
|
||||||
result.push(...bigIntToBytes(BigInt(output.value), 8));
|
|
||||||
const scriptPubKey = hexStringToUint8Array(output.scriptpubkey);
|
|
||||||
result.push(...varIntToBytes(scriptPubKey.length));
|
|
||||||
result.push(...scriptPubKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add locktime
|
|
||||||
result.push(...intToBytes(tx.locktime, 4));
|
|
||||||
|
|
||||||
return new Uint8Array(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function txid(tx: Transaction): string {
|
|
||||||
const serializedTx = serializeTransaction(tx);
|
|
||||||
const hash1 = new Hash().update(serializedTx).digest();
|
|
||||||
const hash2 = new Hash().update(hash1).digest();
|
|
||||||
return uint8ArrayToHexString(hash2.reverse());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L177
|
|
||||||
export function countSigops(transaction: Transaction): number {
|
|
||||||
let sigops = 0;
|
|
||||||
|
|
||||||
for (const input of transaction.vin) {
|
|
||||||
if (input.scriptsig_asm) {
|
|
||||||
sigops += countScriptSigops(input.scriptsig_asm, true);
|
|
||||||
}
|
|
||||||
if (input.prevout) {
|
|
||||||
switch (true) {
|
|
||||||
case input.prevout.scriptpubkey_type === 'p2sh' && input.witness?.length === 2 && input.scriptsig && input.scriptsig.startsWith('160014'):
|
|
||||||
case input.prevout.scriptpubkey_type === 'v0_p2wpkh':
|
|
||||||
sigops += 1;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case input.prevout?.scriptpubkey_type === 'p2sh' && input.witness?.length && input.scriptsig && input.scriptsig.startsWith('220020'):
|
|
||||||
case input.prevout.scriptpubkey_type === 'v0_p2wsh':
|
|
||||||
if (input.witness?.length) {
|
|
||||||
sigops += countScriptSigops(convertScriptSigAsm(input.witness[input.witness.length - 1]), false, true);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case input.prevout.scriptpubkey_type === 'p2sh':
|
|
||||||
if (input.inner_redeemscript_asm) {
|
|
||||||
sigops += countScriptSigops(input.inner_redeemscript_asm);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const output of transaction.vout) {
|
|
||||||
if (output.scriptpubkey_asm) {
|
|
||||||
sigops += countScriptSigops(output.scriptpubkey_asm, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sigops;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scriptPubKeyToAddress(scriptPubKey: string, network: string): { address: string, type: string } {
|
|
||||||
// P2PKH
|
|
||||||
if (/^76a914[0-9a-f]{40}88ac$/.test(scriptPubKey)) {
|
|
||||||
return { address: p2pkh(scriptPubKey.substring(6, 6 + 40), network), type: 'p2pkh' };
|
|
||||||
}
|
|
||||||
// P2PK
|
|
||||||
if (/^21[0-9a-f]{66}ac$/.test(scriptPubKey) || /^41[0-9a-f]{130}ac$/.test(scriptPubKey)) {
|
|
||||||
return { address: null, type: 'p2pk' };
|
|
||||||
}
|
|
||||||
// P2SH
|
|
||||||
if (/^a914[0-9a-f]{40}87$/.test(scriptPubKey)) {
|
|
||||||
return { address: p2sh(scriptPubKey.substring(4, 4 + 40), network), type: 'p2sh' };
|
|
||||||
}
|
|
||||||
// P2WPKH
|
|
||||||
if (/^0014[0-9a-f]{40}$/.test(scriptPubKey)) {
|
|
||||||
return { address: p2wpkh(scriptPubKey.substring(4, 4 + 40), network), type: 'v0_p2wpkh' };
|
|
||||||
}
|
|
||||||
// P2WSH
|
|
||||||
if (/^0020[0-9a-f]{64}$/.test(scriptPubKey)) {
|
|
||||||
return { address: p2wsh(scriptPubKey.substring(4, 4 + 64), network), type: 'v0_p2wsh' };
|
|
||||||
}
|
|
||||||
// P2TR
|
|
||||||
if (/^5120[0-9a-f]{64}$/.test(scriptPubKey)) {
|
|
||||||
return { address: p2tr(scriptPubKey.substring(4, 4 + 64), network), type: 'v1_p2tr' };
|
|
||||||
}
|
|
||||||
// multisig
|
|
||||||
if (/^[0-9a-f]+ae$/.test(scriptPubKey)) {
|
|
||||||
return { address: null, type: 'multisig' };
|
|
||||||
}
|
|
||||||
// anchor
|
|
||||||
if (scriptPubKey === '51024e73') {
|
|
||||||
return { address: p2a(network), type: 'anchor' };
|
|
||||||
}
|
|
||||||
// op_return
|
|
||||||
if (/^6a/.test(scriptPubKey)) {
|
|
||||||
return { address: null, type: 'op_return' };
|
|
||||||
}
|
|
||||||
return { address: null, type: 'unknown' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function p2pkh(pubKeyHash: string, network: string): string {
|
|
||||||
const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
|
|
||||||
const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0x6f : 0x00;
|
|
||||||
const versionedPayload = Uint8Array.from([version, ...pubkeyHashArray]);
|
|
||||||
const hash1 = new Hash().update(versionedPayload).digest();
|
|
||||||
const hash2 = new Hash().update(hash1).digest();
|
|
||||||
const checksum = hash2.slice(0, 4);
|
|
||||||
const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]);
|
|
||||||
const bitcoinAddress = base58Encode(finalPayload);
|
|
||||||
return bitcoinAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
function p2sh(scriptHash: string, network: string): string {
|
|
||||||
const scriptHashArray = hexStringToUint8Array(scriptHash);
|
|
||||||
const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0xc4 : 0x05;
|
|
||||||
const versionedPayload = Uint8Array.from([version, ...scriptHashArray]);
|
|
||||||
const hash1 = new Hash().update(versionedPayload).digest();
|
|
||||||
const hash2 = new Hash().update(hash1).digest();
|
|
||||||
const checksum = hash2.slice(0, 4);
|
|
||||||
const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]);
|
|
||||||
const bitcoinAddress = base58Encode(finalPayload);
|
|
||||||
return bitcoinAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
function p2wpkh(pubKeyHash: string, network: string): string {
|
|
||||||
const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
|
|
||||||
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
|
||||||
const version = 0;
|
|
||||||
const words = [version].concat(toWords(pubkeyHashArray));
|
|
||||||
const bech32Address = bech32Encode(hrp, words);
|
|
||||||
return bech32Address;
|
|
||||||
}
|
|
||||||
|
|
||||||
function p2wsh(scriptHash: string, network: string): string {
|
|
||||||
const scriptHashArray = hexStringToUint8Array(scriptHash);
|
|
||||||
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
|
||||||
const version = 0;
|
|
||||||
const words = [version].concat(toWords(scriptHashArray));
|
|
||||||
const bech32Address = bech32Encode(hrp, words);
|
|
||||||
return bech32Address;
|
|
||||||
}
|
|
||||||
|
|
||||||
function p2tr(pubKeyHash: string, network: string): string {
|
|
||||||
const pubkeyHashArray = hexStringToUint8Array(pubKeyHash);
|
|
||||||
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
|
||||||
const version = 1;
|
|
||||||
const words = [version].concat(toWords(pubkeyHashArray));
|
|
||||||
const bech32Address = bech32Encode(hrp, words, 0x2bc830a3);
|
|
||||||
return bech32Address;
|
|
||||||
}
|
|
||||||
|
|
||||||
function p2a(network: string): string {
|
|
||||||
const pubkeyHashArray = hexStringToUint8Array('4e73');
|
|
||||||
const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc';
|
|
||||||
const version = 1;
|
|
||||||
const words = [version].concat(toWords(pubkeyHashArray));
|
|
||||||
const bech32Address = bech32Encode(hrp, words, 0x2bc830a3);
|
|
||||||
return bech32Address;
|
|
||||||
}
|
|
||||||
|
|
||||||
// base58 encoding
|
|
||||||
function base58Encode(data: Uint8Array): string {
|
|
||||||
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
||||||
|
|
||||||
let hexString = Array.from(data)
|
|
||||||
.map(byte => byte.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
let num = BigInt("0x" + hexString);
|
|
||||||
|
|
||||||
let encoded = "";
|
|
||||||
while (num > 0) {
|
|
||||||
const remainder = Number(num % 58n);
|
|
||||||
num = num / 58n;
|
|
||||||
encoded = BASE58_ALPHABET[remainder] + encoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let byte of data) {
|
|
||||||
if (byte === 0) {
|
|
||||||
encoded = "1" + encoded;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// bech32 encoding
|
|
||||||
// Adapted from https://github.com/bitcoinjs/bech32/blob/5ceb0e3d4625561a459c85643ca6947739b2d83c/src/index.ts
|
|
||||||
function bech32Encode(prefix: string, words: number[], constant: number = 1) {
|
|
||||||
const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
||||||
|
|
||||||
const checksum = createChecksum(prefix, words, constant);
|
|
||||||
const combined = words.concat(checksum);
|
|
||||||
let result = prefix + '1';
|
|
||||||
for (let i = 0; i < combined.length; ++i) {
|
|
||||||
result += BECH32_ALPHABET.charAt(combined[i]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function polymodStep(pre) {
|
|
||||||
const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
||||||
const b = pre >> 25;
|
|
||||||
return (
|
|
||||||
((pre & 0x1ffffff) << 5) ^
|
|
||||||
((b & 1 ? GENERATORS[0] : 0) ^
|
|
||||||
(b & 2 ? GENERATORS[1] : 0) ^
|
|
||||||
(b & 4 ? GENERATORS[2] : 0) ^
|
|
||||||
(b & 8 ? GENERATORS[3] : 0) ^
|
|
||||||
(b & 16 ? GENERATORS[4] : 0))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefixChk(prefix) {
|
|
||||||
let chk = 1;
|
|
||||||
for (let i = 0; i < prefix.length; ++i) {
|
|
||||||
const c = prefix.charCodeAt(i);
|
|
||||||
chk = polymodStep(chk) ^ (c >> 5);
|
|
||||||
}
|
|
||||||
chk = polymodStep(chk);
|
|
||||||
for (let i = 0; i < prefix.length; ++i) {
|
|
||||||
const c = prefix.charCodeAt(i);
|
|
||||||
chk = polymodStep(chk) ^ (c & 0x1f);
|
|
||||||
}
|
|
||||||
return chk;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createChecksum(prefix: string, words: number[], constant: number) {
|
|
||||||
const POLYMOD_CONST = constant;
|
|
||||||
let chk = prefixChk(prefix);
|
|
||||||
for (let i = 0; i < words.length; ++i) {
|
|
||||||
const x = words[i];
|
|
||||||
chk = polymodStep(chk) ^ x;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 6; ++i) {
|
|
||||||
chk = polymodStep(chk);
|
|
||||||
}
|
|
||||||
chk ^= POLYMOD_CONST;
|
|
||||||
|
|
||||||
const checksum = [];
|
|
||||||
for (let i = 0; i < 6; ++i) {
|
|
||||||
checksum.push((chk >> (5 * (5 - i))) & 31);
|
|
||||||
}
|
|
||||||
return checksum;
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertBits(data, fromBits, toBits, pad) {
|
|
||||||
let acc = 0;
|
|
||||||
let bits = 0;
|
|
||||||
const ret = [];
|
|
||||||
const maxV = (1 << toBits) - 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; ++i) {
|
|
||||||
const value = data[i];
|
|
||||||
if (value < 0 || value >> fromBits) throw new Error('Invalid value');
|
|
||||||
acc = (acc << fromBits) | value;
|
|
||||||
bits += fromBits;
|
|
||||||
while (bits >= toBits) {
|
|
||||||
bits -= toBits;
|
|
||||||
ret.push((acc >> bits) & maxV);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pad) {
|
|
||||||
if (bits > 0) {
|
|
||||||
ret.push((acc << (toBits - bits)) & maxV);
|
|
||||||
}
|
|
||||||
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxV)) {
|
|
||||||
throw new Error('Invalid data');
|
|
||||||
}
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toWords(bytes) {
|
|
||||||
return convertBits(bytes, 8, 5, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
function uint8ArrayToHexString(uint8Array: Uint8Array): string {
|
|
||||||
return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexStringToUint8Array(hex: string): Uint8Array {
|
|
||||||
const buf = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < buf.length; i++) {
|
|
||||||
buf[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
||||||
}
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
function intToBytes(value: number, byteLength: number): number[] {
|
|
||||||
const bytes = [];
|
|
||||||
for (let i = 0; i < byteLength; i++) {
|
|
||||||
bytes.push((value >> (8 * i)) & 0xff);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bigIntToBytes(value: bigint, byteLength: number): number[] {
|
|
||||||
const bytes = [];
|
|
||||||
for (let i = 0; i < byteLength; i++) {
|
|
||||||
bytes.push(Number((value >> BigInt(8 * i)) & 0xffn));
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function varIntToBytes(value: number | bigint): number[] {
|
|
||||||
const bytes = [];
|
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
if (value < 0xfd) {
|
|
||||||
bytes.push(value);
|
|
||||||
} else if (value <= 0xffff) {
|
|
||||||
bytes.push(0xfd, value & 0xff, (value >> 8) & 0xff);
|
|
||||||
} else if (value <= 0xffffffff) {
|
|
||||||
bytes.push(0xfe, ...intToBytes(value, 4));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (value < 0xfdn) {
|
|
||||||
bytes.push(Number(value));
|
|
||||||
} else if (value <= 0xffffn) {
|
|
||||||
bytes.push(0xfd, Number(value & 0xffn), Number((value >> 8n) & 0xffn));
|
|
||||||
} else if (value <= 0xffffffffn) {
|
|
||||||
bytes.push(0xfe, ...intToBytes(Number(value), 4));
|
|
||||||
} else {
|
|
||||||
bytes.push(0xff, ...bigIntToBytes(value, 8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inversed the opcodes object from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/utils/bitcoin-script.ts#L1
|
|
||||||
const opcodes = {
|
|
||||||
0: 'OP_0',
|
|
||||||
76: 'OP_PUSHDATA1',
|
|
||||||
77: 'OP_PUSHDATA2',
|
|
||||||
78: 'OP_PUSHDATA4',
|
|
||||||
79: 'OP_PUSHNUM_NEG1',
|
|
||||||
80: 'OP_RESERVED',
|
|
||||||
81: 'OP_PUSHNUM_1',
|
|
||||||
82: 'OP_PUSHNUM_2',
|
|
||||||
83: 'OP_PUSHNUM_3',
|
|
||||||
84: 'OP_PUSHNUM_4',
|
|
||||||
85: 'OP_PUSHNUM_5',
|
|
||||||
86: 'OP_PUSHNUM_6',
|
|
||||||
87: 'OP_PUSHNUM_7',
|
|
||||||
88: 'OP_PUSHNUM_8',
|
|
||||||
89: 'OP_PUSHNUM_9',
|
|
||||||
90: 'OP_PUSHNUM_10',
|
|
||||||
91: 'OP_PUSHNUM_11',
|
|
||||||
92: 'OP_PUSHNUM_12',
|
|
||||||
93: 'OP_PUSHNUM_13',
|
|
||||||
94: 'OP_PUSHNUM_14',
|
|
||||||
95: 'OP_PUSHNUM_15',
|
|
||||||
96: 'OP_PUSHNUM_16',
|
|
||||||
97: 'OP_NOP',
|
|
||||||
98: 'OP_VER',
|
|
||||||
99: 'OP_IF',
|
|
||||||
100: 'OP_NOTIF',
|
|
||||||
101: 'OP_VERIF',
|
|
||||||
102: 'OP_VERNOTIF',
|
|
||||||
103: 'OP_ELSE',
|
|
||||||
104: 'OP_ENDIF',
|
|
||||||
105: 'OP_VERIFY',
|
|
||||||
106: 'OP_RETURN',
|
|
||||||
107: 'OP_TOALTSTACK',
|
|
||||||
108: 'OP_FROMALTSTACK',
|
|
||||||
109: 'OP_2DROP',
|
|
||||||
110: 'OP_2DUP',
|
|
||||||
111: 'OP_3DUP',
|
|
||||||
112: 'OP_2OVER',
|
|
||||||
113: 'OP_2ROT',
|
|
||||||
114: 'OP_2SWAP',
|
|
||||||
115: 'OP_IFDUP',
|
|
||||||
116: 'OP_DEPTH',
|
|
||||||
117: 'OP_DROP',
|
|
||||||
118: 'OP_DUP',
|
|
||||||
119: 'OP_NIP',
|
|
||||||
120: 'OP_OVER',
|
|
||||||
121: 'OP_PICK',
|
|
||||||
122: 'OP_ROLL',
|
|
||||||
123: 'OP_ROT',
|
|
||||||
124: 'OP_SWAP',
|
|
||||||
125: 'OP_TUCK',
|
|
||||||
126: 'OP_CAT',
|
|
||||||
127: 'OP_SUBSTR',
|
|
||||||
128: 'OP_LEFT',
|
|
||||||
129: 'OP_RIGHT',
|
|
||||||
130: 'OP_SIZE',
|
|
||||||
131: 'OP_INVERT',
|
|
||||||
132: 'OP_AND',
|
|
||||||
133: 'OP_OR',
|
|
||||||
134: 'OP_XOR',
|
|
||||||
135: 'OP_EQUAL',
|
|
||||||
136: 'OP_EQUALVERIFY',
|
|
||||||
137: 'OP_RESERVED1',
|
|
||||||
138: 'OP_RESERVED2',
|
|
||||||
139: 'OP_1ADD',
|
|
||||||
140: 'OP_1SUB',
|
|
||||||
141: 'OP_2MUL',
|
|
||||||
142: 'OP_2DIV',
|
|
||||||
143: 'OP_NEGATE',
|
|
||||||
144: 'OP_ABS',
|
|
||||||
145: 'OP_NOT',
|
|
||||||
146: 'OP_0NOTEQUAL',
|
|
||||||
147: 'OP_ADD',
|
|
||||||
148: 'OP_SUB',
|
|
||||||
149: 'OP_MUL',
|
|
||||||
150: 'OP_DIV',
|
|
||||||
151: 'OP_MOD',
|
|
||||||
152: 'OP_LSHIFT',
|
|
||||||
153: 'OP_RSHIFT',
|
|
||||||
154: 'OP_BOOLAND',
|
|
||||||
155: 'OP_BOOLOR',
|
|
||||||
156: 'OP_NUMEQUAL',
|
|
||||||
157: 'OP_NUMEQUALVERIFY',
|
|
||||||
158: 'OP_NUMNOTEQUAL',
|
|
||||||
159: 'OP_LESSTHAN',
|
|
||||||
160: 'OP_GREATERTHAN',
|
|
||||||
161: 'OP_LESSTHANOREQUAL',
|
|
||||||
162: 'OP_GREATERTHANOREQUAL',
|
|
||||||
163: 'OP_MIN',
|
|
||||||
164: 'OP_MAX',
|
|
||||||
165: 'OP_WITHIN',
|
|
||||||
166: 'OP_RIPEMD160',
|
|
||||||
167: 'OP_SHA1',
|
|
||||||
168: 'OP_SHA256',
|
|
||||||
169: 'OP_HASH160',
|
|
||||||
170: 'OP_HASH256',
|
|
||||||
171: 'OP_CODESEPARATOR',
|
|
||||||
172: 'OP_CHECKSIG',
|
|
||||||
173: 'OP_CHECKSIGVERIFY',
|
|
||||||
174: 'OP_CHECKMULTISIG',
|
|
||||||
175: 'OP_CHECKMULTISIGVERIFY',
|
|
||||||
176: 'OP_NOP1',
|
|
||||||
177: 'OP_CHECKLOCKTIMEVERIFY',
|
|
||||||
178: 'OP_CHECKSEQUENCEVERIFY',
|
|
||||||
179: 'OP_NOP4',
|
|
||||||
180: 'OP_NOP5',
|
|
||||||
181: 'OP_NOP6',
|
|
||||||
182: 'OP_NOP7',
|
|
||||||
183: 'OP_NOP8',
|
|
||||||
184: 'OP_NOP9',
|
|
||||||
185: 'OP_NOP10',
|
|
||||||
186: 'OP_CHECKSIGADD',
|
|
||||||
253: 'OP_PUBKEYHASH',
|
|
||||||
254: 'OP_PUBKEY',
|
|
||||||
255: 'OP_INVALIDOPCODE',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -154,5 +154,9 @@
|
|||||||
"WALLETS": {
|
"WALLETS": {
|
||||||
"ENABLED": true,
|
"ENABLED": true,
|
||||||
"WALLETS": ["BITB", "3350"]
|
"WALLETS": ["BITB", "3350"]
|
||||||
|
},
|
||||||
|
"STRATUM": {
|
||||||
|
"ENABLED": true,
|
||||||
|
"API": "http://127.0.0.1:81/api/v1/stratum/ws"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
"TESTNET4_ENABLED": true,
|
"TESTNET4_ENABLED": true,
|
||||||
"LIQUID_ENABLED": false,
|
"LIQUID_ENABLED": false,
|
||||||
"LIQUID_TESTNET_ENABLED": false,
|
"LIQUID_TESTNET_ENABLED": false,
|
||||||
"BISQ_ENABLED": true,
|
"STRATUM_ENABLED": true,
|
||||||
"BISQ_SEPARATE_BACKEND": true,
|
|
||||||
"SIGNET_ENABLED": true,
|
"SIGNET_ENABLED": true,
|
||||||
"MEMPOOL_WEBSITE_URL": "https://mempool.space",
|
"MEMPOOL_WEBSITE_URL": "https://mempool.space",
|
||||||
"LIQUID_WEBSITE_URL": "https://liquid.network",
|
"LIQUID_WEBSITE_URL": "https://liquid.network",
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ location @mempool-api-v1-cache-normal {
|
|||||||
proxy_cache_valid 200 2s;
|
proxy_cache_valid 200 2s;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
expires 2s;
|
# cache for 2 seconds on server, but send expires -1 so browser doesn't cache
|
||||||
|
expires -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
location @mempool-api-v1-cache-disabled {
|
location @mempool-api-v1-cache-disabled {
|
||||||
|
|||||||
Reference in New Issue
Block a user