# From Pasted Text to a Plan: Inside the Five-Step Engine of Bunker

> Follow one address through the Bunker engine: the parser, 17 detectors in a fixed order, a frozen verdict, the status rules and the ordered plan, with the real code.

Canonical: https://nosistech.com/bunker-detector-pipeline-architecture/
Published: 2026-10-27
Last updated: 2026-10-27
Author: Carlos Paredes, NosisTech LLC

You paste four lines into Bunker and select Scan. Each line gets a network name, a result and a place in a plan. One address takes less than one millisecond.

A lot happens in that millisecond. The engine looks for secrets, splits the lines, finds the network, verifies the checksum, rates the address and builds the plan.

In this article, I follow the text through the engine, one step at a time. Each step is a small module with one job. I show the real code of each step, and then a real run that shows the code at work.

The code is from Bunker version 1.2.1. Every excerpt links to its file on GitHub, so you can read the full context.

---

## The five steps at a glance

This diagram is from the project documentation. Read it from top to bottom:

```
input text
   |
   v
1. guard.js       Is there a secret?  -> yes: clear the input and stop
   |
   v
2. parse.js       Split into lines, labels and duplicates
   |
   v
3. classify.js    For each address: run the detectors in order
   |
   v
4. model.js       Model + your answer -> card status
   |
   v
5. plan.js        Status -> ordered plan
```

| Step | Module | Job | Example |
| --- | --- | --- | --- |
| 1 | `guard.js` | Find seed phrases and private keys. | A pasted 12-word phrase stops everything. |
| 2 | `parse.js` | Split the text into addresses and labels. | `bc1q... Savings` gives one address and the label "Savings". |
| 3 | `classify.js` | Find the network and its model. | `bc1q...` is Bitcoin Native SegWit, model `hidden-until-used`. |
| 4 | `model.js` | Combine the model and your answer. | "No, it never sent" gives Lock hidden. |
| 5 | `plan.js` | Put every address into an ordered plan. | Lock hidden goes to "Keep these locks hidden". |

The guard has its own article in this series, the sixth one. This article starts at step 2.

## Five layers, one direction

The code has five layers. A layer can use the layers below it. It cannot use the layers above it.

| Layer | Folder | What it knows |
| --- | --- | --- |
| 5. Interfaces | `src/ui/`, `cli/`, `mcp/` | The web page, the command-line tool and the MCP server for AI agents. |
| 4. Report | `src/agent/`, `src/i18n/` | One JSON shape for machines, and the Spanish translation. |
| 3. Pipeline | `parse.js`, `guard.js`, `classify.js`, `plan.js` | The order of the steps. It writes English only. |
| 2. Networks | `src/networks/`, `model.js` | Address formats and rules. Nothing about the page. |
| 1. Core | `src/core/` | Hashes and encodings. Nothing about networks. |

This rule has a practical result. The page, the command-line tool and the MCP server share one engine. A fix in one detector fixes all three at once.

The modules are also small. Every core, network and pipeline module has fewer than 100 lines. The largest one, the Bech32 decoder, has 97.

## Step 2: from text to entries

The parser in [src/parse.js](https://github.com/nosistech/nosis-bunker/blob/main/src/parse.js) turns the text box into a list of entries. It follows five rules:

1. It reads one address on each line.
2. It takes the text after the first space as the label.
3. It cuts each label to 40 characters and removes invisible characters.
4. It skips an address that is already in the list.
5. It reads at most 500 lines and 100,000 characters, so the page stays fast.

I tested these rules with four lines of input. The first line has a long label. The second line has the same address in capital letters. The third line is empty. The fourth line has extra spaces and a comma after the address.

```
bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 My savings for the house and the car and the boat
BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4 Same address in capitals

   1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa,   Genesis
```

The command-line tool returned two entries, not four:

| Address | Label | Type |
| --- | --- | --- |
| `bc1qw508...v8f3t4` | My savings for the house and the car and | Native SegWit (bc1q) |
| `1A1zP1eP...DivfNa` | Genesis | Legacy (1...) |

The label stops at 40 characters. The address in capitals is the same Bech32 address, so the parser skipped it. The empty line disappeared, and the comma did not become part of the address.

The parser also calls the next step inside a `try` block. If one detector has a bug, that line shows "This tool could not read this line." The rest of the page keeps working.

## Step 3: one address in, one answer out

The function `classify()` in [src/classify.js](https://github.com/nosistech/nosis-bunker/blob/main/src/classify.js) decides what an address is. This is the whole function:

```
export function classify(address) {
  const a = String(address).trim();
  if (!a || a.length > MAX_ADDRESS_LENGTH) {
    return { ...verdict(null, 'Not recognized', MODEL.UNKNOWN, 'The text is empty or too long to be an address.'), detector: 'none' };
  }
  for (const d of DETECTORS) {
    const v = d.detect(a);
    if (v) return { ...v, detector: d.name };
  }
  return { ...verdict(null, 'Not recognized', MODEL.UNKNOWN, 'This tool does not know this format. Look for a typo or a missing character.'), detector: 'none' };
}
```

Read it in three parts:

1. **The size check.** The function rejects empty text and text longer than 4,000 characters. This happens before any decoding starts.
2. **The loop.** The function asks each detector in a fixed list. A detector returns an answer or `null`, which means "not mine". The first answer wins.
3. **The fallback.** If no detector answers, the result is "Not recognized". The message tells the user to look for a typo.

A detector is a small function that recognizes one family of address formats. It checks the prefix, the length and the characters. Then it verifies the checksum, if the format has one.

Here is a real call with the Native SegWit address from the BIP-173 test vectors:

```
$ node -e "import('./src/classify.js').then(m => console.log(m.classify('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4')))"
{
  chain: 'btc',
  type: 'Native SegWit (bc1q)',
  model: 'hidden-until-used',
  why: 'Only a hash of the key is on chain until this address spends.',
  detector: 'bech32'
}
```

Twelve detectors said "not mine" before the Bech32 detector answered. The last field, `detector`, names the one that answered. The tests use this field to prove the order.

## Why the order of the detectors matters

Many address formats overlap. A token contract is a valid Ethereum address. A Lightning invoice is valid Bech32 text. If the wrong detector answers first, the user gets a wrong result.

So the order is part of the design. It lives in one list in [src/networks/index.js](https://github.com/nosistech/nosis-bunker/blob/main/src/networks/index.js), and each line carries the reason for its place:

```
export const DETECTORS = Object.freeze([
  { name: 'known-contracts', detect: detectKnownContract },   // first: a token contract can look like any wallet address
  { name: 'identifiers', detect: detectIdentifier },          // xpubs, names and handles are not addresses
  { name: 'evm', detect: detectEvm },                         // 0x + 40 hex, with EIP-55 checksum
  { name: 'move-starknet', detect: detectMoveOrStarknet },    // other 0x lengths
  { name: 'lightning', detect: detectLightning },             // before Bech32: invoices also decode as Bech32
// ...
  { name: 'bech32', detect: detectBech32 },                   // Bitcoin SegWit, Cosmos, Cardano, Zcash and more
// ...
  { name: 'base58', detect: detectBase58 },                   // last: the most general format
]);
```

The full list has 17 detectors. This table gives each one with the exact comment from the code:

| # | Detector | Comment in the code |
| --- | --- | --- |
| 1 | known-contracts | first: a token contract can look like any wallet address |
| 2 | identifiers | xpubs, names and handles are not addresses |
| 3 | evm | 0x + 40 hex, with EIP-55 checksum |
| 4 | move-starknet | other 0x lengths |
| 5 | lightning | before Bech32: invoices also decode as Bech32 |
| 6 | text-accounts | NEAR, Hedera, Stellar, Algorand, TON, XRP |
| 7 | canton | party IDs contain "::" |
| 8 | stacks | before Base58: SP... uses its own c32 alphabet |
| 9 | icp | 64-hex account IDs and dashed principals |
| 10 | cashaddr | Bitcoin Cash, eCash, Kaspa |
| 11 | filecoin | f0 to f4 |
| 12 | liquid | before Bech32: lq1 uses Blech32 |
| 13 | bech32 | Bitcoin SegWit, Cosmos, Cardano, Zcash and more |
| 14 | avalanche | X-avax1 / P-avax1 |
| 15 | monero | before Base58: Monero uses its own block encoding |
| 16 | substrate | before generic Base58: SS58 has a BLAKE2b checksum |
| 17 | base58 | last: the most general format |

Specific formats come first, and general formats come last. Base58 is the most general format, so it closes the list.

To show what the order prevents, I called single detectors on their own, outside the pipeline. Then I ran the same input through the full pipeline:

```
evm detector alone, QNT contract: EVM account
full pipeline, QNT contract:      Token contract: QNT (Quant)
bech32 detector alone, invoice:   Unknown Bech32 address (lnbc)
full pipeline, invoice:           Lightning invoice
```

The QNT token contract is a valid `0x` address. On its own, the EVM detector calls it a wallet. In the pipeline, the contract list answers first and tells the user to paste their own wallet address instead.

The invoice is the BOLT 11 test vector from the project fixtures. The Bech32 detector alone sees only an unknown prefix. In the pipeline, the Lightning detector answers first and gives the correct result.

A test locks this order in place. Its name is "each detector answers for the expected inputs (pipeline order)". If someone moves a detector, the test fails.

## The Verdict: one frozen answer

Every detector returns the same shape, called a Verdict. It has four main fields:

| Field | Meaning | Example |
| --- | --- | --- |
| `chain` | The network key, or null. | `'btc'` |
| `type` | A short label for the card. | `'Taproot (bc1p)'` |
| `model` | How the network shows the public key. | `'visible'` |
| `why` | One or two sentences for the user. | "Taproot puts the public key in the address." |

Optional fields carry extras, such as the list of networks for a shared format or the plan text for a Zcash pool. The model is the key field. It describes the network, not the person.

All detectors build their answer with one helper in [src/model.js](https://github.com/nosistech/nosis-bunker/blob/main/src/model.js). The helper has one line of code: `return Object.freeze(Object.assign({ chain, type, model, why }, extra));`

`Object.freeze` makes the object read-only. Later steps never edit a Verdict. They copy its fields into a new object and add their own, such as the label and your answer. I tested the freeze with a small script that tries to change the model of a real Verdict:

```
frozen: true
TypeError: Cannot assign to read only property 'model' of object '#<Object>'
model: hidden-until-used
```

The change failed with an error, and the model stayed the same. No later step can change the answer of a detector in place.

## Step 4: from model to status

A model describes the network. A status is what the card shows to you. The function `statusOf()` in [src/model.js](https://github.com/nosistech/nosis-bunker/blob/main/src/model.js) turns one into the other:

```
export function statusOf(entry) {
  switch (entry.model) {
    case MODEL.VISIBLE: return STATUS.EXPOSED;
    case MODEL.MOVE_POOL: return STATUS.EXPOSED;
    case MODEL.HASH_SAFE: return STATUS.PROTECTED;
    case MODEL.XPUB: return STATUS.WARNING;
    case MODEL.ASK_PROVIDER: return STATUS.CHECK;
    case MODEL.HIDDEN_UNTIL_USED:
      if (entry.sent === 'yes') return STATUS.EXPOSED;
      if (entry.sent === 'no') return STATUS.HIDDEN;
      return STATUS.CHECK;
    default: return STATUS.UNKNOWN;
  }
}
```

Every model except one gives a fixed status. For example, a `visible` model always gives `exposed`, which the card shows as Lock visible. Your answer cannot change that, because the public key, which Bunker calls the lock, is in the address itself.

One model depends on you. For `hidden-until-used`, the card asks "Has it ever sent a transaction or signed a message?"

| Your answer | Value of `sent` | Status | Card |
| --- | --- | --- | --- |
| Yes | `'yes'` | `exposed` | Lock visible |
| No | `'no'` | `hidden` | Lock hidden |
| Not sure, or no answer yet | `'unsure'` | `check` | Check needed |

The default answer is `'unsure'`. The parser sets it for every new entry. So an address never looks safe until you say that it never signed.

## Step 5: the plan, in a fixed order

The last step, `buildPlan()` in [src/plan.js](https://github.com/nosistech/nosis-bunker/blob/main/src/plan.js), puts every entry into a group. It starts and ends the same way every time:

```
export function buildPlan(entries) {
  const byStatus = s => entries.filter(e => statusOf(e) === s).sort((a, b) => (b.value || 0) - (a.value || 0));
  const exposed = byStatus(STATUS.EXPOSED);
  /** @type {PlanGroup[]} */
  const groups = [{ key: 'rules', title: 'Ground rules', items: GROUND_RULES.map(text => ({ text })) }];
// ...
  groups.push({ key: 'watch', title: 'Watch for the real fixes', items: WATCH_LIST.map(text => ({ text })) });
  return groups;
}
```

Three details make the plan predictable:

1. **The helper `byStatus` sorts by value.** If you type a value for each address, the largest amount comes first in each group.
2. **The ground rules always come first.** The list is a constant, so no input can remove it.
3. **The watch list always comes last.** It names the real fixes to follow, such as BIP-360 for Bitcoin.

Between them, the code adds a group only when it has entries. This is the complete order:

| Order | Group | Which entries go here |
| --- | --- | --- |
| 1 | Ground rules | Always present. |
| 2 | Move to new addresses | Lock visible, and a new address or a safer pool can hide the key. |
| 3 | Visible with no hidden option yet | Lock visible by design, for example Taproot or Solana. |
| 4 | Check or ask | Check needed: no answer yet, or a question for a provider. |
| 5 | Stop sharing extended public keys | Extended public keys (xpubs). |
| 6 | Keep these locks hidden | Lock hidden. |
| 7 | Watch for the real fixes | Always present. |

I ran a real plan with four addresses. Three are the examples from the first article: Ordinals, Savings and Daily wallet. The fourth is the P2SH example from the Bitcoin wiki, with the label "Multisig".

The savings address got the answer "No". The daily wallet got "Yes". The multisig address got no answer:

```
$ node cli/bunker.js --text --sent bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4=no --sent 0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed=yes addresses.txt
BUNKER PLAN

1. Ground rules
   - Do not hurry. A bad migration can cost more than the risk that you try to avoid.
   - (four more rules)

2. Move to new addresses
   - 0x5aAeb6...1BeAed (EVM networks | Daily wallet): Add a new account on your hardware wallet. Use it only to receive. Move coins and tokens on every EVM network where this address holds them.

3. Visible with no hidden option yet
   - bc1p5cyx...kedrcr (Bitcoin | Ordinals): Taproot has no hidden option yet. Keep inscriptions here. Move plain coins to a new SegWit address. Be ready to move to BIP-360 outputs when they activate.

4. Check or ask
   - 3J98t1Wp...RhWNLy (Bitcoin | Multisig): Look for an outgoing transaction or a signed message on a block explorer. If you find one, select "Yes".

5. Keep these locks hidden
   - bc1qw508...v8f3t4 (Bitcoin | Savings): Use it only to receive. When you spend, your wallet sends the change to a new address, and the rest stays hidden.

6. Watch for the real fixes
   - Bitcoin: activation of BIP-360 (quantum-safe outputs), and the BIP-361 discussion about a freeze of coins that do not move.
   - (three more networks)
```

I shortened two lists to keep the output short. The plan has six groups, because no input was an xpub. The multisig address landed in "Check or ask", because the tool does not guess what you did not answer.

## How to add a network

The pipeline makes a new network a small change. The project documentation uses a fictional "Example Chain" with `exm1...` addresses, a Bech32 hash of the key. These are the steps:

1. Read the official documentation of the network. Write down the prefix, the encoding, the checksum and the length.
2. Find a real address in an official test vector or on a public explorer page.
3. For a Bech32 hash address, add one line to `BECH32_RULES` in `src/networks/bech32-family.js`.
4. If you can verify the explorer link on a real page, add it.
5. Add the real address to `test/fixtures/addresses.json`, with its source.
6. Run `npm run check`. All tests must pass.
7. Add the network to `docs/03-networks.md`.

Step 3 is one line of code. This is the line from the documentation:

```
exm: ['hashacct', 'Example Chain account', MODEL.HIDDEN_UNTIL_USED, TEXT.HASH_HIDDEN, { chainLabel: 'Example Chain' }],
```

The line gives the network key, the card label, the model, the explanation and the display name. The Bech32 detector already decodes the address and verifies the checksum. For a new encoding, you write one new detector file and add one line to the list, with a comment for its place.

## The build: many modules, one file

The source code is many small modules. The user gets one HTML file. The build script, [tools/build.js](https://github.com/nosistech/nosis-bunker/blob/main/tools/build.js), joins them. It has 185 lines and uses only the modules that come with Node.js. It works in this order:

1. It starts at `src/ui/app.js` and follows the `import` lines to find every module.
2. It puts each module after the modules that it uses.
3. It removes the `import` lines and the `export` keywords.
4. It stops with an error if two modules declare the same top-level name.
5. It wraps all the code in one function, so nothing goes into the global scope.
6. It computes the hashes for the Content Security Policy and writes the file.

The script also refuses risky syntax. A dynamic `import()` or an `export default` stops the build with a clear error.

The build gives the same file every time. For this series, I built the page from a fresh copy of the source code. The new file had the same SHA-256 hash as the live site. The security side of the build has its own articles later in the series.

## Read the code

- The detector list: [src/networks/index.js](https://github.com/nosistech/nosis-bunker/blob/main/src/networks/index.js)
- The models and the status rules: [src/model.js](https://github.com/nosistech/nosis-bunker/blob/main/src/model.js)
- The plan: [src/plan.js](https://github.com/nosistech/nosis-bunker/blob/main/src/plan.js)
- The architecture document: [docs/04-architecture.md](https://github.com/nosistech/nosis-bunker/blob/main/docs/04-architecture.md)
- The decision record for the pipeline: [ADR-006](https://github.com/nosistech/nosis-bunker/blob/main/docs/decisions/ADR-006-detector-pipeline.md)
- The tool itself: [bunker.nosistech.com](https://bunker.nosistech.com)

In the next article, I look closely at one job of the detectors: the checksum. You will see how one wrong character gets caught, and what the typo test found in the code.

Have a question or feedback about Bunker? [Contact NosisTech](https://nosistech.com/connect/).

### Keep reading: Bunker by NosisTech

Previous article: [Bitcoin, Ethereum, Solana and 100 More: How Each Network Shows the Public Key](https://nosistech.com/crypto-networks-public-key-exposure-rules/)

Next article: [One Wrong Character: How Bunker Catches Typos in Crypto Addresses](https://nosistech.com/bunker-checksum-validation-typo-test/)

All articles: [NosisTech Writing](https://nosistech.com/blog/)

*This post is general information, not financial, legal or security advice. Bunker is an educational tool with no warranty. Examine each address on a block explorer, and send a small test amount before you move funds.*
