Bulk import
The bulk import page (/records/import, Bulk import on the dashboard and in the Issue menu) issues many credentials from one export. It runs entirely in the issuer's browser with the issuer's wallet, and every credential goes through the same CredentialService.issue path as the single issue form. The files never leave the computer. The file formats are in Bulk import files; the handbook guide is docs/import/README.md.
Preconditions
- v0.3, an active issuer on a plain wallet, the system not paused.
- Pinata and session settings on the server, when any row has a document.
- A browser that keeps
localStorage(progress is kept there) and, ideally, supports the Web Locks API (to stop two tabs running the same import).
The stepper
The page header reads Bulk import, Import credentials: "Issue a whole cohort from one export. Every row is checked before your wallet is asked for anything." A four-step bar shows Choose file, Check, Issue, Results.
1. Choose your files
Section Choose your files ("Export the cohort from your student records system, then choose the file here."):
| Field | Purpose |
|---|---|
| Export format | csv, json or template (the adapter) |
| Records file (CSV or JSON) | "One row per credential. Nothing is signed until you start." Larger than 10 MB is refused. |
| Earlier result file (optional) | "Carries on a run that was started in another browser." |
| Document folder (optional) | "Holds the documents the records file names." |
The page notes: "The files are read on this computer and are not uploaded. Each document is encrypted in your browser before it is stored." and "Your wallet asks for one signature for every credential, so keep the page open while it runs."
2. Check
The adapter parses the file, then validateRecords checks every row against the issuer's record types (read from the chain) and the folder contents. The review section ("Checked in this browser", headed with the file name) shows either:
- "N records, no problems." and "X with a document, Y data only. Your wallet will ask for N signatures and one more to protect the documents, and N transactions will be sent.", or
- "N problems to fix in the file. Nothing was signed." with each problem as
Row 12, studentId: ...and "Correct these in your spreadsheet or records system, export the file again and choose it here."
Holder details are never shown in the review. Warnings (unused columns, a document shared by several rows, documents not checked) are listed but do not block. When an earlier result file was given: "N rows from the earlier result file will be recognised once the chain confirms them for your account." Changed rows (an external id issued before with different details) are listed: "N rows changed since they were issued. They will be issued as new credentials, and the earlier ones stay valid until revoked or replaced."
Options
Section Before you start ("Both are off unless you tick them."):
| Option | Effect |
|---|---|
| Replace the earlier credential of each changed row | "Marks the earlier one as superseded in the same transaction." (issueReplacing). On a set without atomic replace: "Not available: this contract set cannot replace in one transaction. Revoke the earlier ones from the dashboard." |
| Add a selective disclosure signature to each credential | "One more wallet prompt for every credential." A refused or failed signature never undoes an issued credential and is noted in the result. |
3. Issue
Start issuing begins the run (disabled with "Fix the file above and choose it again to start." while there are problems). Section Issuing: "Confirm each request in your wallet and keep this page open until the run ends." The latest rows appear with their outcome. Stop after this row asks the run to stop once the current row is done ("The run stops once the current row is finished.").
4. Results
Section The run is complete or The run stopped, with counts per outcome and a table Record, Outcome, Credential or reason (each credential id links to its verify page).
| Outcome badge | State | Meaning |
|---|---|---|
| Issued | issued | Issued in this run; its package is in the archive |
| Already done | skipped | Issued in an earlier run and confirmed on chain |
| Found on chain | recovered | An earlier try that the progress record had as pending is on chain after all |
| Unconfirmed | unconfirmed | An earlier try may still land; not sent again automatically |
| Failed | failed | With the reason |
| Not run | not-run | The run stopped before this row |
Buttons: Download packages (N) (credential-packages.zip), Download result file (import-result.csv), Issue the unconfirmed rows again when there are any, and Finish. Finish stays locked until the archive is downloaded ("Finish unlocks once the packages are downloaded."), and the browser asks to confirm before leaving the page while packages are unsaved. Notice Keep the archive private: each package holds the key that opens its holder's document, so send each holder only their own; the packages "cannot be rebuilt from the chain".
One row, step by step
The executor is a PinnedIssuerExecutor wrapping a SingleIssueExecutor (src/services/import/). The pinned wrapper refuses every row once the wallet account differs from the one the run began with ("The wallet account changed during the import. Switch back to the issuing account and start the run again."). The single executor uploads the row's document (or anchors the SHA-256 of zero bytes for a data-only row), calls CredentialService.issue, and adds the disclosure signature only when the option is on.
Why a run is repeat-safe
Rows are keyed by what they would issue (name, id, type, programme, award, date and document name), hashed, never by the external id. The progress record is kept in localStorage under certichain.import.<key>, where the key is a hash of the issuer address and chain id. It holds only hashed row keys, credential ids, states and transaction nonces.
runImport (src/lib/import/run.ts) applies these rules for each row:
| Situation | What happens |
|---|---|
| Stored as issued, and the chain holds that credential | Already done (skipped); nothing is sent |
| Stored as pending, and the chain holds one of the recorded credential ids | Found on chain (recovered); the row is marked issued |
| Stored as pending, not on chain, and the account's confirmed nonce has passed every recorded try | None can land any more, so the row is issued again automatically |
| Stored as pending, not on chain, a try may still land | Unconfirmed, with "An earlier try may have been sent and is not on the chain yet. Wait a few minutes and run again, or choose to issue it again." or, when the transaction is stuck, "An earlier try is still waiting in the network. Wait for it, or speed it up or cancel it in the wallet, then run again." |
| Not stored | Issued |
The credential id of every try (and, once sent, its transaction nonce) is recorded before the run waits for the transaction. A run that dies in between is therefore recognised from the chain and never issues a row twice by itself. Issue the unconfirmed rows again overrides the wait; the page warns: "Issuing the unconfirmed rows again is safe only if their first try never lands: a late first try could then make a second credential."
Carrying on elsewhere
Choosing the same records file again in the same browser carries the run on. In another browser, or after site data was cleared, the issuer adds the earlier result file as Earlier result file. Its rows are read as hints only: every credential id is still confirmed on the chain for this issuer before a row is skipped, so an edited or copied result file cannot make a row look done.
When a run stops by itself
| Stop | Message |
|---|---|
| A rejected wallet request | "The request was rejected in your wallet. The import stopped; start it again to continue." |
| Wrong network, a contract wallet, or a changed account | The specific error |
NotIssuer, SystemPaused or InvalidSignature | The matching issuing message; every later row would fail the same way |
| Three failed rows in a row | "The run stopped after three failures in a row. Fix the cause, then start it again with the same file." |
| Stop after this row | "The run was stopped. Start it again with the same file to carry on." |
| Damaged progress record | "The saved progress of this import is damaged, so the run cannot tell which rows were issued. Nothing was sent." |
| Another tab runs the same import | "This import is already running in another tab or window. Finish or close that one first." |
Wallet prompts
| When | Kind | Count |
|---|---|---|
| First document row | Signature × 2 (EncryptionKey), plus a sign-in message if needed | Once per run |
| Each row | Signature (Credential) | One per row |
| Each row | Transaction (issue or issueReplacing) | One per row |
| Each row, option ticked | Signature (disclosure attestation) | One per row |
No student-salt signature is asked for, so imported credentials use a fresh subject salt and are not delivered to linked student wallets. Holders receive their package from the archive.
Checking a file without a wallet
npm run import:check -- records.csv
npm run import:check -- export.txt --adapter template --folder ./documents --types "Short Course, Transcript Addendum"
It runs the same parsing and validation, prints counts, warnings and every problem with its row and field (never holder details), and exits 0 (clean), 1 (problems) or 2 (wrong arguments or unreadable file).
Limits
- One wallet prompt sequence per row; a large cohort on one account takes a long time. Batch anchoring is planned behind the same
IssueExecutorinterface. - Without the result file, choosing the same file in another browser or after clearing site data would issue the rows again. Keep the result file with the packages.
- A try that was never sent (the wallet prompt was closed) stays unconfirmed until the issuer chooses to issue it again.
- 5,000 rows and 10 MB per file.
Code map
| Concern | File |
|---|---|
| Page | src/app/records/import/ImportPage.tsx, ImportWorkspace.tsx, ImportSteps.tsx, ChooseFiles.tsx, ImportReview.tsx, ImportOptions.tsx, ImportProgress.tsx, ImportResults.tsx, OutcomeBadge.tsx, folder-files.ts, use-import.ts |
| Reading and checking | src/lib/import/adapters.ts, csv-adapter.ts, json-adapter.ts, csv.ts, validate.ts, prepare.ts, changed.ts |
| Plan and run | src/lib/import/plan.ts, run.ts, store.ts, lock.ts, errors.ts, progress.ts |
| Outputs | src/lib/import/outputs.ts, zip.ts |
| Executors | src/services/import/single-issue-executor.ts, pinned-issuer-executor.ts |
| Command line | src/lib/import/check-cli.ts, scripts/import-check.ts |
Tests
src/lib/import/run.test.ts,store.test.ts,lock.test.ts,errors.test.ts,plan.test.ts,validate.test.ts,prepare.test.ts,changed.test.ts,progress.test.ts,outputs.test.ts,adapters.test.ts,csv.test.ts,check-cli.test.tssrc/services/import/single-issue-executor.test.ts,pinned-issuer-executor.test.tssrc/app/records/import/ImportWorkspace.test.tsx,ImportResults.test.tsx,use-import.test.tsx,folder-files.test.ts- E2E:
e2e/journeys/bulk-import.journey.ts(a run that is stopped and resumed)