arkiv-images 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +29 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +197 -0
- package/docs/testnet-evidence.json +111 -0
- package/docs/verification.md +50 -0
- package/package.json +52 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Integrating arkiv-images
|
|
2
|
+
|
|
3
|
+
This is the consumer guide; it is included in npm. Read [README.md](README.md) explicitly for the public API, runnable examples, dependency versions, limits, data model and recovery behavior. This guide does not replace it.
|
|
4
|
+
|
|
5
|
+
## Ask the developer
|
|
6
|
+
|
|
7
|
+
- Local inspection, existing-image retrieval, or a new public upload? The first needs no network; retrieval needs the image key and explicit testnet; only writes need a signing wallet and test funds.
|
|
8
|
+
- Which testnet/RPC and Entity Expiration in blocks? Use the actually tested combination in README. Never infer production or mainnet compatibility.
|
|
9
|
+
- Are the **original image bytes, filename and metadata** safe to make public? The tool keeps EXIF/GPS unchanged. If privacy is required, stop: encryption is a different tool, and this one does not solve it.
|
|
10
|
+
- For retrieval, ask for an image key and optionally an independently trusted SHA-256. Do not confuse an image entity key with its file-manifest key.
|
|
11
|
+
- For browser writes, ask the developer to connect their own MetaMask/Rabby account and confirm transactions. For Node, ask them to configure their key locally as described in README; never request a private key in chat. Provider access keys come from the provider/Hub links there.
|
|
12
|
+
|
|
13
|
+
Local `inspectImage` and `planImage` can be integrated immediately while legitimate network/product questions remain open.
|
|
14
|
+
|
|
15
|
+
## Preserve these invariants
|
|
16
|
+
|
|
17
|
+
- Import only the public package API; do not copy its storage, image validation or chunking implementation into the app. Use the released npm version in consumers.
|
|
18
|
+
- Binary image bytes belong in the payload; queryable metadata belongs in typed attributes. The package schema and dependency own these decisions. Do not search inside payloads or substitute SQL for SDK query syntax.
|
|
19
|
+
- Keep the testnet checks, size limits and integrity validation. No partial image, no silent lossy conversion, no pretending expiration erases history.
|
|
20
|
+
- A browser consumer must fully decode before upload and after retrieval, then render only the allowed raster MIME as an image. Manage/revoke Blob URLs. Filenames are text, not markup.
|
|
21
|
+
- Confirm completion only after `storeImage` resolves. Preserve confirmed hashes/manifest keys on failure, serialize writes, and do not automatically retry ambiguous writes. Check wallet account/network before every signature, including the final root write.
|
|
22
|
+
- Keep secrets out of source, logs, packages and browser bundles. Do not display raw SDK/provider errors. No server signer for the sample.
|
|
23
|
+
- No independent mutation/extension/ownership transfer of the parts. Root existence alone does not prove its referenced chunks are still available.
|
|
24
|
+
|
|
25
|
+
## Verify the integration
|
|
26
|
+
|
|
27
|
+
Follow the README in a new consumer. Import the public TypeScript types, run the exact local example, then test the requested network flow. Compare original and retrieved bytes and digest. Test unsupported/oversized files, wrong network, absent key, rejected writes, missing/corrupt chunks and browser decode errors. Distinguish mocked RPC, packed candidate and installed npm release. For UI, render small/medium/large widths and inspect loading, empty and error states. Record versions, network, actual results and unverified items.
|
|
28
|
+
|
|
29
|
+
Source maintainers: keep this guide, CLAUDE.md and verification in the publication allowlist. Sample instructions and agent guide live in `sample/`, linked from README; explicitly give them to consumer agents. No new skills, MCP, generator, encryption or media-management features belong to this tool.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Read [AGENTS.md](AGENTS.md) for the consumer integration guide and invariants.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Santiago Trujillo Zuluaga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# arkiv-images
|
|
2
|
+
|
|
3
|
+
Store a public PNG or JPEG as queryable Arkiv entities, then retrieve and display its original bytes. Small images use one entity. Larger images use the existing `arkiv-chunking` package: a discoverable image entity points to a file manifest, which links the ordered chunks. Retrieval checks size, format, dimensions and SHA-256 before returning the bytes.
|
|
4
|
+
|
|
5
|
+
**These packages are intended for testnet use.**
|
|
6
|
+
|
|
7
|
+
Version **0.1.0**. See [verification](docs/verification.md) for release evidence and [the npm registry](https://www.npmjs.com/package/arkiv-images/v/0.1.0) for availability. A locally packed artifact is not a published release.
|
|
8
|
+
|
|
9
|
+
## Install and inspect an image
|
|
10
|
+
|
|
11
|
+
ESM, Node **22.10+** with Web Crypto, or a current browser on HTTPS/localhost. The tested dependency combination is SDK **0.8.0**, viem **2.56.3**, `arkiv-chunking` **0.1.0**, Node **22.22.3**. SDK 0.7 and older are incompatible. Other SDK versions, runtimes and networks are not claimed tested; see the evidence matrix below.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
mkdir image-consumer
|
|
15
|
+
cd image-consumer
|
|
16
|
+
npm init -y
|
|
17
|
+
npm pkg set type=module
|
|
18
|
+
npm install --save-exact arkiv-images@0.1.0 @arkiv-network/sdk@0.8.0 viem@2.56.3
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Create `inspect.mjs`:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { inspectImage, planImage } from 'arkiv-images';
|
|
25
|
+
|
|
26
|
+
// Synthetic 1x1 PNG, no personal image or credentials.
|
|
27
|
+
const bytes = new Uint8Array(Buffer.from(
|
|
28
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4V+L2HwAG5AK4X5zdSAAAAABJRU5ErkJggg==', 'base64'));
|
|
29
|
+
console.log(inspectImage(bytes));
|
|
30
|
+
console.log(planImage(bytes.length));
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
node inspect.mjs
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Expected: PNG, width `1`, height `1`, byte length `70`; mode `inline`, `entityCount: 1`, `transactionCount: 1`. This inspection is local: no wallet, RPC, funds or access key. Base64 is used only to embed this tiny example; the package stores binary bytes without base64 overhead.
|
|
38
|
+
|
|
39
|
+
## Store and retrieve on Tiramisu
|
|
40
|
+
|
|
41
|
+
Use **Tiramisu testnet, chain ID 7738577**, exported by `@arkiv-network/sdk/chains`. [Network connection details](https://hub.arkiv.network/networks), [test GLM faucet](https://hub.arkiv.network/faucet) and [access keys](https://hub.arkiv.network/api-keys) are provided by the Hub. The RPC used below allows anonymous reads; write access and quotas depend on the provider. A provider access key does not replace a signing wallet. Never put an administrative provider key in a browser.
|
|
42
|
+
|
|
43
|
+
The [sample](https://github.com/SantiagoDevRel/arkiv-images/blob/feat/image-storage/sample/README.md) uses **MetaMask or Rabby** to sign with the visitor's own wallet. No shared server signer or private key is needed. For a Node integration, configure a dedicated test wallet key **locally**, outside the checkout. Do not send private keys to an agent or put them in chat.
|
|
44
|
+
|
|
45
|
+
In the consumer above, save `roundtrip.mjs`:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { readFile } from 'node:fs/promises';
|
|
49
|
+
import { createPublicClient, createWalletClient } from '@arkiv-network/sdk';
|
|
50
|
+
import { tiramisu } from '@arkiv-network/sdk/chains';
|
|
51
|
+
import { http } from 'viem';
|
|
52
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
53
|
+
import { inspectImage, planImage, storeImage, retrieveImage } from 'arkiv-images';
|
|
54
|
+
|
|
55
|
+
if (!process.env.ARKIV_PRIVATE_KEY) throw new Error('Configure ARKIV_PRIVATE_KEY locally.');
|
|
56
|
+
const bytes = new Uint8Array(await readFile('example.png'));
|
|
57
|
+
console.log(inspectImage(bytes), planImage(bytes.length));
|
|
58
|
+
const transport = () => http(process.env.ARKIV_RPC_URL ?? tiramisu.rpcUrls.default.http[0], {
|
|
59
|
+
retryCount: 0, timeout: 30_000, fetchOptions: { cache: 'no-store' },
|
|
60
|
+
});
|
|
61
|
+
const publicClient = createPublicClient({ chain: tiramisu, transport: transport() });
|
|
62
|
+
const walletClient = createWalletClient({ chain: tiramisu, transport: transport(),
|
|
63
|
+
account: privateKeyToAccount(process.env.ARKIV_PRIVATE_KEY) });
|
|
64
|
+
const saved = await storeImage({ publicClient, walletClient, bytes, filename: 'example.png',
|
|
65
|
+
expirationBlocks: 43_200 });
|
|
66
|
+
const image = await retrieveImage({ publicClient, imageKey: saved.imageKey,
|
|
67
|
+
expectedSha256: saved.sha256 });
|
|
68
|
+
const identical = image.bytes.length === bytes.length && bytes.every((b,i) => b === image.bytes[i]);
|
|
69
|
+
if (!identical) throw new Error('Image bytes differ');
|
|
70
|
+
console.log({ imageKey: saved.imageKey, sha256: image.sha256, mode: image.mode,
|
|
71
|
+
bytes: image.bytes.length, identical, expiresAt: String(image.expiresAt) });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Place a **non-sensitive PNG** at `example.png`. Then run:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
node --env-file=/absolute/path/to/your-local-secret.env roundtrip.mjs
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The local secret file uses ordinary `NAME=value` entries for `ARKIV_PRIVATE_KEY` and optionally `ARKIV_RPC_URL`. Supply your own values; no secrets are included here. If those variables already exist in the process environment, use `node roundtrip.mjs`.
|
|
81
|
+
|
|
82
|
+
Expected: a new 32-byte `imageKey`, SHA-256, the selected storage mode, byte count, `identical: true` and a queried expiration block. Writes spend **test GLM**. Keep the returned image key for retrieval after restarting. A read needs only `publicClient` and `imageKey`, not the wallet. To display the recovered bytes in a browser:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
const image = await retrieveImage({ publicClient, imageKey });
|
|
86
|
+
const blob = new Blob([new Uint8Array(image.bytes)], { type: image.contentType });
|
|
87
|
+
const bitmap = await createImageBitmap(blob); // full browser decode; may reject corrupt pixels
|
|
88
|
+
bitmap.close();
|
|
89
|
+
const url = URL.createObjectURL(blob);
|
|
90
|
+
document.querySelector('img').src = url;
|
|
91
|
+
// When replacing the image or unmounting: URL.revokeObjectURL(url).
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Read an existing image without a wallet
|
|
95
|
+
|
|
96
|
+
In the same clean consumer, save `read.mjs`:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { writeFile } from 'node:fs/promises';
|
|
100
|
+
import { createPublicClient } from '@arkiv-network/sdk';
|
|
101
|
+
import { tiramisu } from '@arkiv-network/sdk/chains';
|
|
102
|
+
import { http } from 'viem';
|
|
103
|
+
import { retrieveImage } from 'arkiv-images';
|
|
104
|
+
|
|
105
|
+
const publicClient = createPublicClient({ chain: tiramisu,
|
|
106
|
+
transport: http(tiramisu.rpcUrls.default.http[0], { retryCount: 0 }) });
|
|
107
|
+
const image = await retrieveImage({ publicClient, imageKey: process.argv[2] });
|
|
108
|
+
// Use a fixed local output path, not the untrusted stored filename.
|
|
109
|
+
const output = image.contentType === 'image/png' ? 'recovered.png' : 'recovered.jpg';
|
|
110
|
+
await writeFile(output, image.bytes, { flag: 'wx' });
|
|
111
|
+
console.log({ output, bytes: image.byteLength, sha256: image.sha256 });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Run `node read.mjs YOUR_IMAGE_ENTITY_KEY` with a key returned by a completed upload. Expected: a recovered image file and its byte count/digest. No signing wallet, funds or private key are needed. The output uses exclusive creation: if it already exists, choose another filename rather than silently overwriting it. Entities in the verification evidence can expire, so they are evidence, not permanent fixtures.
|
|
115
|
+
|
|
116
|
+
## Data model and queries
|
|
117
|
+
|
|
118
|
+
| Location | Contents |
|
|
119
|
+
|---|---|
|
|
120
|
+
| Image entity attributes | `type: str('arkiv-images/v1')`, `mode: str('inline'|'chunked')`, `mime: str(...)`, `filename: str(...)`, `sha256: bytes32(...)`, `size/width/height: u64(...)`; chunked mode adds `manifest: key(...)` |
|
|
121
|
+
| Inline payload | Original PNG/JPEG binary bytes; content type `image/png` or `image/jpeg` |
|
|
122
|
+
| Chunked image payload | Empty; the `manifest` attribute links to the file, rather than pretending the image is contained in this one payload |
|
|
123
|
+
| File manifest and chunks | The versioned `arkiv-chunking@0.1.0` format; the dependency owns splitting, sequencing, snapshot pagination, completion and integrity checks |
|
|
124
|
+
|
|
125
|
+
The image entity is created read-only. Filenames are public, indexed attributes; the package does not infer author/version from the image. Queries operate on attributes, never image pixels or payload contents. To discover PNG images:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
import { str } from '@arkiv-network/sdk/attr';
|
|
129
|
+
import { eq } from '@arkiv-network/sdk/query';
|
|
130
|
+
import { IMAGE_TYPE, retrieveImage } from 'arkiv-images';
|
|
131
|
+
|
|
132
|
+
const query = publicClient.select({ key: true })
|
|
133
|
+
.where(eq('type', str(IMAGE_TYPE)), eq('mime', str('image/png')))
|
|
134
|
+
.limit(200);
|
|
135
|
+
for await (const entity of query) {
|
|
136
|
+
// Narrow by owner or another indexed attribute for a real application.
|
|
137
|
+
// A single image's retrieval does not enumerate the global catalogue.
|
|
138
|
+
const image = await retrieveImage({ publicClient, imageKey: entity.key });
|
|
139
|
+
console.log(entity.key, image.byteLength);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
SDK types matter: `u64(2)` is different from `i32(2)`. The actual query expression for this example is `type = str('arkiv-images/v1') AND mime = str('image/png')`, not SQL `SELECT *`.
|
|
144
|
+
|
|
145
|
+
## Public API
|
|
146
|
+
|
|
147
|
+
All functions and TypeScript types are exported from `arkiv-images`. Do not import internal build paths.
|
|
148
|
+
|
|
149
|
+
| Export | Contract |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `inspectImage(bytes, expectedContentType?)` | Returns `ImageInfo`: detected MIME, width, height, byteLength. Validates size, signature and basic container structure; not a full pixel decoder. No network. |
|
|
152
|
+
| `planImage(byteLength)` | Returns `ImagePlan`: inline/chunked mode, bytes, chunk/entity/transaction counts. No network. |
|
|
153
|
+
| `storeImage(options)` | Resolves `StoredImage` after observed confirmations: imageKey, optional manifestKey, digest, dimensions, size, mode, transactionHashes. Required clients, bytes, filename, expirationBlocks; optional contentType/onProgress. |
|
|
154
|
+
| `retrieveImage(options)` | Resolves `RetrievedImage`: original bytes, filename, MIME, dimensions, digest, mode, queried expiresAt. Required publicClient/imageKey; optional expectedSha256/onProgress. Never returns a partial image. |
|
|
155
|
+
| `ImageStorageError`, `ChunkingError` | Typed error codes. Upload errors preserve available manifestKey and confirmed transactionHashes. Raw `cause` is for local diagnostics and may contain provider secrets: never render or log it in the UI. |
|
|
156
|
+
| Types | `ImageInfo`, `ImagePlan`, `StoreImageOptions`, `StoredImage`, `RetrieveImageOptions`, `RetrievedImage`, `ImageContentType`, `ImageErrorCode`, `Progress` |
|
|
157
|
+
| Constants | `VERSION`, `IMAGE_TYPE`, `INLINE_MAX_BYTES`, `MAX_IMAGE_BYTES`, `MAX_IMAGE_PIXELS` |
|
|
158
|
+
|
|
159
|
+
`onProgress` is the chunk dependency's observational callback: phase `manifest`, `chunks` or `finalize`, completed/total and optional manifestKey. Its total counts **file chunks**, not the final image entity transaction. Inline writes do not emit chunk progress. Only a resolved `storeImage` means the whole upload finished; callback exceptions cannot change a confirmed upload into a failure.
|
|
160
|
+
|
|
161
|
+
## Limits, visibility and failure semantics
|
|
162
|
+
|
|
163
|
+
| Policy | Value |
|
|
164
|
+
|---|---|
|
|
165
|
+
| Formats | Static PNG; baseline/progressive JPEG. No SVG, GIF, APNG, WebP, HEIC, PDF or video |
|
|
166
|
+
| Maximum file | **26,214,400 bytes (25 MiB)**, measured before upload; in memory, no streaming |
|
|
167
|
+
| Maximum dimensions | Positive width/height, product at most **40,000,000 pixels** |
|
|
168
|
+
| Inline image | **1–120,000 bytes**, one entity and one transaction |
|
|
169
|
+
| Chunked image | **120,001–26,214,400 bytes**, 100,000-byte chunks; N+2 entities, N+3 confirmed transactions |
|
|
170
|
+
| At 120,001 bytes | 2 chunks + manifest + image entity: 4 entities, 5 transactions |
|
|
171
|
+
| At 1,000,000 bytes | 10 chunks + manifest + image entity: 12 entities, 13 transactions |
|
|
172
|
+
| At 25 MiB | 263 chunks, 265 entities, 266 transactions; this is a policy ceiling, not a claim that uploading this size is quick or economical |
|
|
173
|
+
| Filename | Basename only, 1–128 UTF-8 bytes (SDK `str` attribute limit), no path separators or control characters |
|
|
174
|
+
| Expiration | Explicit whole blocks: `max(64, transactionCount + 32)` through 15,768,000 |
|
|
175
|
+
|
|
176
|
+
The inline threshold leaves room for attributes and transaction framing. The commonly cited 131,072-byte payload ceiling is **not** an image budget: the complete transaction also has a size limit. [Verification](docs/verification.md) records actual encoded sizes and which boundaries were submitted on Tiramisu. JPEG/PNG are already compressed image formats; converting to PDF or base64 does not remove the storage constraint. A photo's size varies with dimensions, detail and encoding; the sample measures the selected file rather than guessing from megapixels.
|
|
177
|
+
|
|
178
|
+
**No encryption, metadata stripping, resizing or recompression.** The tool preserves the exact original bytes, including EXIF/GPS, embedded thumbnails and names. Everyone with read access to the public network can retrieve them. The sample requires acknowledgement before uploading. Use synthetic/non-sensitive images. Expiration removes entities from current queries; it does **not** erase historical bytes or copies. This tool offers no read authorization and does not claim confidentiality.
|
|
179
|
+
|
|
180
|
+
The package performs bounded container checks, not a complete PNG CRC/JPEG entropy decode, antivirus scan or content moderation. A file can pass header checks and still fail full decoding. JPEGs must end at their end-of-image marker; files with trailing padding are rejected even if some decoders accept them. Export such files as a standard JPEG before uploading. The sample calls `createImageBitmap` both before writes and after retrieval. Consumers rendering images must also handle decode failures. Never serve uploaded bytes as HTML or SVG, and never trust a filename as markup.
|
|
181
|
+
|
|
182
|
+
Chunking is required only for images above the inline threshold and is **reused, not reimplemented**. The dependency is installed with the package. An inline read uses one entity query plus the chain check. A chunked read first loads the image entity, then the file manifest and every required chunk page; it is one JavaScript API call, **not one RPC query**. File pages share the manifest's snapshot block. The initial image query and file query are separate snapshots. A trusted `expectedSha256` pins bytes independently of mutable manifests; a digest proves integrity, not the publisher's identity.
|
|
183
|
+
|
|
184
|
+
Parts receive relative expiration at their own creation. The manifest is older than the final image entity and can expire first. The root may therefore still exist when the file can no longer be retrieved; retrieval fails clearly. `RetrievedImage.expiresAt` is the earlier queried root/manifest expiration, not a fixed-time availability promise. Do not independently extend, transfer or change parts; these are not supported workflows.
|
|
185
|
+
|
|
186
|
+
No multi-transaction atomicity, automatic write retries/resume, deduplication, deletion or rollback. If the final image transaction fails, confirmed file entities remain: preserve `manifestKey` and receipts, inspect them, then decide whether to start a new upload. A timeout can follow a successful transaction; the returned list includes only confirmations observed by this call. Serialize writes from the same account. In browser integrations recheck account/network before **every** signing request; the sample provides the implementation.
|
|
187
|
+
|
|
188
|
+
## Troubleshooting
|
|
189
|
+
|
|
190
|
+
| Symptom | Resolution |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `INVALID_IMAGE` / browser decode error | Use a valid static PNG or baseline/progressive JPEG within the pixel limit. Changing an extension does not convert a file. |
|
|
193
|
+
| `TOO_LARGE` | Choose a smaller file. The tool does not silently resize it. |
|
|
194
|
+
| `INVALID_INPUT` | Check 32-byte key/hash, filename rules and whole-block expiration. |
|
|
195
|
+
| `NETWORK_MISMATCH` | Configure matching explicit testnet clients; the sample supports only Tiramisu. Check RPC `eth_chainId` too. |
|
|
196
|
+
| Wallet missing, rejected or changed | Enable/connect MetaMask or Rabby; resolve pending requests. Reconnect after changing account and inspect confirmed writes before retrying. |
|
|
197
|
+
| Not enough GLM | Obtain test GLM for the actual signing account on Tiramisu; plan the number of transaction approvals before uploading a large photo. |
|
|
198
|
+
| RPC 401/403/429, `READ_FAILED` | Check your network-specific provider access key/quota/connection. Never retry ambiguous writes automatically. |
|
|
199
|
+
| `NOT_FOUND` | Verify the **image** key and network; entity may have expired. A chunk manifest key is a different type. |
|
|
200
|
+
| `INCOMPLETE_UPLOAD`, `MISSING_CHUNK` | Inspect upload receipts and expiration. Partial images are not returned. |
|
|
201
|
+
| `INVALID_ENTITY`, `INVALID_MANIFEST`, `INVALID_CHUNK`, `HASH_MISMATCH` | Treat the result as invalid; do not bypass checks. A manifest may have changed or content may be damaged. |
|
|
202
|
+
| npm E404 | Check the publication status in verification; do not call a local tarball published. |
|
|
203
|
+
| CommonJS import failure | Use ESM `import`; there is no CommonJS export. |
|
|
204
|
+
|
|
205
|
+
## Sample, agent guides and development
|
|
206
|
+
|
|
207
|
+
- [Sample README](https://github.com/SantiagoDevRel/arkiv-images/blob/feat/image-storage/sample/README.md): source checkout, localhost commands and visitor-wallet flow.
|
|
208
|
+
- [Consumer AGENTS.md](AGENTS.md) and [sample AGENTS.md](https://github.com/SantiagoDevRel/arkiv-images/blob/feat/image-storage/sample/AGENTS.md). Give these guides to your agent explicitly. It will not necessarily read files under `node_modules` automatically. Each CLAUDE.md points to the corresponding AGENTS.md.
|
|
209
|
+
- [Verification](docs/verification.md): exact runtimes, dependency versions, simulated vs real checks, publication and unresolved gates.
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
git clone --branch feat/image-storage https://github.com/SantiagoDevRel/arkiv-images.git
|
|
213
|
+
cd arkiv-images
|
|
214
|
+
npm ci
|
|
215
|
+
npm test
|
|
216
|
+
npm run typecheck
|
|
217
|
+
npm pack --pack-destination /absolute/path/to/output
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
For an unpublished candidate only, replace the npm package argument in a clean consumer with the absolute `.tgz` path. This tests the packed artifact, not registry publication. There are no runtime aliases to sibling repositories.
|
|
221
|
+
|
|
222
|
+
Browser regression checks use Playwright with a locally installed Chrome. First start the sample dev server in another terminal, then run `npm run test:browser` from the package root. They exercise the actual SDK/package with a controlled RPC and injected wallet, not live funds. Screenshots and results default to `~/Downloads/arkiv-image-storage-98/browser`; set `ARTIFACTS_DIR` to another output directory or `SAMPLE_URL` to another localhost port. These checks are separate from `npm test`.
|
|
223
|
+
|
|
224
|
+
Provenance: image workflow and PNG/JPEG/25 MiB policy from [Arkiv-Network/usecases-imagedb](https://github.com/Arkiv-Network/usecases-imagedb); public-byte/metadata cautions and transaction budget from the existing Arkiv Cookbook; chunk implementation is the separately published `arkiv-chunking` dependency. The old service's quotas, shared signer and RPC protocol are not added to this package. Test RPC harness and sample token treatments are adapted from arkiv-chunking, with Arkiv UI's documented open font fallbacks.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type ChunkingPublicClient, type ChunkingWalletClient, type Progress } from 'arkiv-chunking';
|
|
2
|
+
import type { Hex } from 'viem';
|
|
3
|
+
export declare const VERSION = "0.1.0";
|
|
4
|
+
export declare const IMAGE_TYPE = "arkiv-images/v1";
|
|
5
|
+
export declare const INLINE_MAX_BYTES = 120000;
|
|
6
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
7
|
+
export declare const MAX_IMAGE_PIXELS = 40000000;
|
|
8
|
+
export type ImageContentType = 'image/png' | 'image/jpeg';
|
|
9
|
+
export type ImageErrorCode = 'INVALID_IMAGE' | 'TOO_LARGE' | 'INVALID_INPUT' | 'NETWORK_MISMATCH' | 'NOT_FOUND' | 'INVALID_ENTITY' | 'HASH_MISMATCH' | 'UPLOAD_FAILED' | 'READ_FAILED';
|
|
10
|
+
export declare class ImageStorageError extends Error {
|
|
11
|
+
readonly code: ImageErrorCode;
|
|
12
|
+
readonly manifestKey?: Hex;
|
|
13
|
+
readonly transactionHashes: readonly Hex[];
|
|
14
|
+
constructor(code: ImageErrorCode, message: string, options?: {
|
|
15
|
+
cause?: unknown;
|
|
16
|
+
manifestKey?: Hex;
|
|
17
|
+
transactionHashes?: readonly Hex[];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export { ChunkingError } from 'arkiv-chunking';
|
|
21
|
+
export type { Progress } from 'arkiv-chunking';
|
|
22
|
+
export interface ImageInfo {
|
|
23
|
+
contentType: ImageContentType;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
byteLength: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ImagePlan {
|
|
29
|
+
mode: 'inline' | 'chunked';
|
|
30
|
+
bytes: number;
|
|
31
|
+
chunkCount: number;
|
|
32
|
+
entityCount: number;
|
|
33
|
+
transactionCount: number;
|
|
34
|
+
}
|
|
35
|
+
export interface StoreImageOptions {
|
|
36
|
+
publicClient: ChunkingPublicClient;
|
|
37
|
+
walletClient: ChunkingWalletClient;
|
|
38
|
+
bytes: Uint8Array;
|
|
39
|
+
filename: string;
|
|
40
|
+
contentType?: ImageContentType;
|
|
41
|
+
expirationBlocks: number;
|
|
42
|
+
onProgress?: (progress: Progress) => void | Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export interface StoredImage extends ImageInfo {
|
|
45
|
+
imageKey: Hex;
|
|
46
|
+
manifestKey?: Hex;
|
|
47
|
+
sha256: Hex;
|
|
48
|
+
mode: 'inline' | 'chunked';
|
|
49
|
+
transactionHashes: Hex[];
|
|
50
|
+
}
|
|
51
|
+
export interface RetrieveImageOptions {
|
|
52
|
+
publicClient: ChunkingPublicClient;
|
|
53
|
+
imageKey: Hex;
|
|
54
|
+
expectedSha256?: Hex;
|
|
55
|
+
onProgress?: StoreImageOptions['onProgress'];
|
|
56
|
+
}
|
|
57
|
+
export interface RetrievedImage extends ImageInfo {
|
|
58
|
+
bytes: Uint8Array;
|
|
59
|
+
filename: string;
|
|
60
|
+
sha256: Hex;
|
|
61
|
+
mode: 'inline' | 'chunked';
|
|
62
|
+
expiresAt: bigint;
|
|
63
|
+
}
|
|
64
|
+
/** Validate size, signature, container dimensions. This is not a full pixel decoder or malware scanner. */
|
|
65
|
+
export declare function inspectImage(bytes: Uint8Array, expectedContentType?: ImageContentType): ImageInfo;
|
|
66
|
+
export declare function planImage(byteLength: number): ImagePlan;
|
|
67
|
+
/** Preserve original file bytes. All bytes, metadata and wallet addresses are public. Never retries writes. */
|
|
68
|
+
export declare function storeImage(options: StoreImageOptions): Promise<StoredImage>;
|
|
69
|
+
/** One SDK call for an inline image; chunked images additionally load and verify the referenced file. */
|
|
70
|
+
export declare function retrieveImage({ publicClient, imageKey, expectedSha256, onProgress }: RetrieveImageOptions): Promise<RetrievedImage>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { ExpirationTime } from '@arkiv-network/sdk';
|
|
2
|
+
import { bytes32, key, str, u64 } from '@arkiv-network/sdk/attr';
|
|
3
|
+
import { eq } from '@arkiv-network/sdk/query';
|
|
4
|
+
import { uploadFile, downloadFile, hashFile, ChunkingError, DEFAULT_CHUNK_BYTES } from 'arkiv-chunking';
|
|
5
|
+
export const VERSION = '0.1.0';
|
|
6
|
+
export const IMAGE_TYPE = 'arkiv-images/v1';
|
|
7
|
+
export const INLINE_MAX_BYTES = 120_000;
|
|
8
|
+
export const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
9
|
+
export const MAX_IMAGE_PIXELS = 40_000_000;
|
|
10
|
+
export class ImageStorageError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
manifestKey;
|
|
13
|
+
transactionHashes;
|
|
14
|
+
constructor(code, message, options = {}) {
|
|
15
|
+
super(message, { cause: options.cause });
|
|
16
|
+
this.name = 'ImageStorageError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.manifestKey = options.manifestKey;
|
|
19
|
+
this.transactionHashes = [...(options.transactionHashes ?? [])];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export { ChunkingError } from 'arkiv-chunking';
|
|
23
|
+
function fail(code, message) { throw new ImageStorageError(code, message); }
|
|
24
|
+
const hex32 = (v) => typeof v === 'string' && /^0x[\da-f]{64}$/i.test(v);
|
|
25
|
+
/** Validate size, signature, container dimensions. This is not a full pixel decoder or malware scanner. */
|
|
26
|
+
export function inspectImage(bytes, expectedContentType) {
|
|
27
|
+
if (!(bytes instanceof Uint8Array))
|
|
28
|
+
fail('INVALID_INPUT', 'Provide image bytes as Uint8Array.');
|
|
29
|
+
if (bytes.length > MAX_IMAGE_BYTES)
|
|
30
|
+
fail('TOO_LARGE', `Images must be at most ${MAX_IMAGE_BYTES} bytes.`);
|
|
31
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
32
|
+
let contentType, width = 0, height = 0;
|
|
33
|
+
if (bytes.length >= 33 && [137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => bytes[i] === v)) {
|
|
34
|
+
contentType = 'image/png';
|
|
35
|
+
if (view.getUint32(8) !== 13 || view.getUint32(12) !== 0x49484452)
|
|
36
|
+
fail('INVALID_IMAGE', 'PNG must start with an IHDR chunk.');
|
|
37
|
+
width = view.getUint32(16);
|
|
38
|
+
height = view.getUint32(20);
|
|
39
|
+
let offset = 8, hasData = false, ended = false;
|
|
40
|
+
while (offset + 12 <= bytes.length) {
|
|
41
|
+
const length = view.getUint32(offset), type = view.getUint32(offset + 4);
|
|
42
|
+
if (length > bytes.length - offset - 12)
|
|
43
|
+
fail('INVALID_IMAGE', 'Truncated PNG chunk.');
|
|
44
|
+
if (type === 0x6163544c)
|
|
45
|
+
fail('INVALID_IMAGE', 'Animated PNG is not supported.');
|
|
46
|
+
if (type === 0x49444154)
|
|
47
|
+
hasData = true;
|
|
48
|
+
offset += length + 12;
|
|
49
|
+
if (type === 0x49454e44) {
|
|
50
|
+
ended = length === 0 && offset === bytes.length;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!hasData || !ended)
|
|
55
|
+
fail('INVALID_IMAGE', 'PNG is missing image data or its final chunk.');
|
|
56
|
+
}
|
|
57
|
+
else if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes.at(-2) === 255 && bytes.at(-1) === 217) {
|
|
58
|
+
contentType = 'image/jpeg';
|
|
59
|
+
let offset = 2;
|
|
60
|
+
while (offset + 4 <= bytes.length) {
|
|
61
|
+
if (bytes[offset++] !== 255)
|
|
62
|
+
fail('INVALID_IMAGE', 'Invalid JPEG marker.');
|
|
63
|
+
while (bytes[offset] === 255)
|
|
64
|
+
offset++;
|
|
65
|
+
const marker = bytes[offset++];
|
|
66
|
+
if (marker === undefined || marker === 0xda || marker === 0xd9)
|
|
67
|
+
break;
|
|
68
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
|
|
69
|
+
continue;
|
|
70
|
+
if (offset + 2 > bytes.length)
|
|
71
|
+
fail('INVALID_IMAGE', 'Truncated JPEG.');
|
|
72
|
+
const length = view.getUint16(offset);
|
|
73
|
+
if (length < 2 || offset + length > bytes.length)
|
|
74
|
+
fail('INVALID_IMAGE', 'Truncated JPEG segment.');
|
|
75
|
+
if (marker === 0xc0 || marker === 0xc2) {
|
|
76
|
+
if (length < 8)
|
|
77
|
+
fail('INVALID_IMAGE', 'Invalid JPEG dimensions.');
|
|
78
|
+
height = view.getUint16(offset + 3);
|
|
79
|
+
width = view.getUint16(offset + 5);
|
|
80
|
+
}
|
|
81
|
+
offset += length;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
return fail('INVALID_IMAGE', 'Use a static PNG or baseline/progressive JPEG. Other formats are not supported.');
|
|
86
|
+
}
|
|
87
|
+
if (!width || !height || width * height > MAX_IMAGE_PIXELS)
|
|
88
|
+
fail('INVALID_IMAGE', `Image dimensions must be positive and at most ${MAX_IMAGE_PIXELS} pixels.`);
|
|
89
|
+
if (expectedContentType !== undefined && expectedContentType !== contentType)
|
|
90
|
+
fail('INVALID_IMAGE', 'Declared content type does not match the image bytes.');
|
|
91
|
+
return { contentType, width, height, byteLength: bytes.length };
|
|
92
|
+
}
|
|
93
|
+
export function planImage(byteLength) {
|
|
94
|
+
if (!Number.isSafeInteger(byteLength) || byteLength < 1)
|
|
95
|
+
fail('INVALID_INPUT', 'Image byte length must be a positive integer.');
|
|
96
|
+
if (byteLength > MAX_IMAGE_BYTES)
|
|
97
|
+
fail('TOO_LARGE', `Images must be at most ${MAX_IMAGE_BYTES} bytes.`);
|
|
98
|
+
if (byteLength <= INLINE_MAX_BYTES)
|
|
99
|
+
return { mode: 'inline', bytes: byteLength, chunkCount: 0, entityCount: 1, transactionCount: 1 };
|
|
100
|
+
const chunkCount = Math.ceil(byteLength / DEFAULT_CHUNK_BYTES);
|
|
101
|
+
return { mode: 'chunked', bytes: byteLength, chunkCount, entityCount: chunkCount + 2, transactionCount: chunkCount + 3 };
|
|
102
|
+
}
|
|
103
|
+
function filenameValid(filename) {
|
|
104
|
+
if (typeof filename !== 'string' || !filename || new TextEncoder().encode(filename).length > 128 || /[\x00-\x1f\x7f/\\]/.test(filename) || filename === '.' || filename === '..')
|
|
105
|
+
fail('INVALID_INPUT', 'Use a basename of 1–128 UTF-8 bytes, without control characters.');
|
|
106
|
+
}
|
|
107
|
+
async function checkNetwork(client, wallet) {
|
|
108
|
+
if (!client.chain?.testnet || await client.getChainId() !== client.chain.id)
|
|
109
|
+
fail('NETWORK_MISMATCH', 'Configure an explicit testnet matching the RPC.');
|
|
110
|
+
if (wallet && (!wallet.account || !wallet.chain?.testnet || wallet.chain.id !== client.chain.id || Number(await wallet.transport.request({ method: 'eth_chainId' })) !== client.chain.id))
|
|
111
|
+
fail('NETWORK_MISMATCH', 'The wallet and RPC must use the same explicit testnet.');
|
|
112
|
+
}
|
|
113
|
+
/** Preserve original file bytes. All bytes, metadata and wallet addresses are public. Never retries writes. */
|
|
114
|
+
export async function storeImage(options) {
|
|
115
|
+
const { publicClient, walletClient, filename, expirationBlocks, onProgress } = options;
|
|
116
|
+
filenameValid(filename);
|
|
117
|
+
const info = inspectImage(options.bytes, options.contentType), plan = planImage(info.byteLength);
|
|
118
|
+
if (!Number.isSafeInteger(expirationBlocks) || expirationBlocks < Math.max(64, plan.transactionCount + 32) || expirationBlocks > 15_768_000)
|
|
119
|
+
fail('INVALID_INPUT', 'Choose a whole-block expiration from max(64, transactionCount + 32) to 15768000.');
|
|
120
|
+
const bytes = new Uint8Array(options.bytes);
|
|
121
|
+
let manifestKey;
|
|
122
|
+
const transactionHashes = [];
|
|
123
|
+
try {
|
|
124
|
+
await checkNetwork(publicClient, walletClient);
|
|
125
|
+
const sha256 = await hashFile(bytes);
|
|
126
|
+
if (plan.mode === 'chunked') {
|
|
127
|
+
const stored = await uploadFile({ publicClient, walletClient, bytes, filename, contentType: info.contentType, expirationBlocks, onProgress });
|
|
128
|
+
manifestKey = stored.manifestKey;
|
|
129
|
+
transactionHashes.push(...stored.transactionHashes);
|
|
130
|
+
}
|
|
131
|
+
await checkNetwork(publicClient, walletClient);
|
|
132
|
+
const attributes = { type: str(IMAGE_TYPE), mode: str(plan.mode), mime: str(info.contentType), filename: str(filename), sha256: bytes32(sha256), size: u64(bytes.length), width: u64(info.width), height: u64(info.height), ...(manifestKey ? { manifest: key(manifestKey) } : {}) };
|
|
133
|
+
const stored = await walletClient.createEntity({ attributes, payload: manifestKey ? new Uint8Array() : bytes, contentType: manifestKey ? 'application/octet-stream' : info.contentType, expires: ExpirationTime.fromBlocks(expirationBlocks), flags: { readonly: true } });
|
|
134
|
+
transactionHashes.push(stored.txHash);
|
|
135
|
+
return { ...info, imageKey: stored.entityKey, manifestKey, mode: plan.mode, sha256, transactionHashes };
|
|
136
|
+
}
|
|
137
|
+
catch (cause) {
|
|
138
|
+
if (cause instanceof ChunkingError) {
|
|
139
|
+
manifestKey = cause.manifestKey ?? manifestKey;
|
|
140
|
+
transactionHashes.push(...cause.transactionHashes);
|
|
141
|
+
}
|
|
142
|
+
if (cause instanceof ImageStorageError && !transactionHashes.length)
|
|
143
|
+
throw cause;
|
|
144
|
+
throw new ImageStorageError('UPLOAD_FAILED', 'Upload did not complete. Inspect the wallet and available transaction receipts before retrying; any previously written entities remain.', { cause, manifestKey, transactionHashes });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function attr(attributes, name, type) {
|
|
148
|
+
const a = attributes[name];
|
|
149
|
+
if (!a || typeof a !== 'object' || !('type' in a) || a.type !== type || !('value' in a))
|
|
150
|
+
return fail('INVALID_ENTITY', `Missing or invalid ${name} attribute.`);
|
|
151
|
+
return a.value;
|
|
152
|
+
}
|
|
153
|
+
/** One SDK call for an inline image; chunked images additionally load and verify the referenced file. */
|
|
154
|
+
export async function retrieveImage({ publicClient, imageKey, expectedSha256, onProgress }) {
|
|
155
|
+
if (!hex32(imageKey) || (expectedSha256 !== undefined && !hex32(expectedSha256)))
|
|
156
|
+
fail('INVALID_INPUT', 'Use a 32-byte image key and, optionally, a 32-byte SHA-256.');
|
|
157
|
+
try {
|
|
158
|
+
await checkNetwork(publicClient);
|
|
159
|
+
const page = await publicClient.select({ payload: true, expiresAt: true, attributes: { type: true, mode: true, mime: true, filename: true, sha256: true, size: true, width: true, height: true, manifest: true } }).where(eq('$key', key(imageKey))).limit(1).fetch();
|
|
160
|
+
const entity = page.entities[0];
|
|
161
|
+
if (!entity)
|
|
162
|
+
fail('NOT_FOUND', 'Image entity not found. Check key, network and Entity Expiration.');
|
|
163
|
+
const a = entity.attributes;
|
|
164
|
+
if (attr(a, 'type', 'str') !== IMAGE_TYPE)
|
|
165
|
+
fail('INVALID_ENTITY', 'This is not an arkiv-images v1 entity.');
|
|
166
|
+
const mode = attr(a, 'mode', 'str'), mime = attr(a, 'mime', 'str'), filename = attr(a, 'filename', 'str'), digest = attr(a, 'sha256', 'bytes32'), size = attr(a, 'size', 'u64');
|
|
167
|
+
if ((mode !== 'inline' && mode !== 'chunked') || (mime !== 'image/png' && mime !== 'image/jpeg') || !hex32(digest) || typeof size !== 'bigint' || size < 1n || size > BigInt(MAX_IMAGE_BYTES))
|
|
168
|
+
fail('INVALID_ENTITY', 'Invalid image storage metadata.');
|
|
169
|
+
filenameValid(filename);
|
|
170
|
+
if (expectedSha256 && digest.toLowerCase() !== expectedSha256.toLowerCase())
|
|
171
|
+
fail('HASH_MISMATCH', 'Image digest differs from the trusted SHA-256.');
|
|
172
|
+
let bytes = entity.payload, expiresAt = entity.expiresAt;
|
|
173
|
+
if (mode === 'chunked') {
|
|
174
|
+
const manifestKey = attr(a, 'manifest', 'key');
|
|
175
|
+
if (!hex32(manifestKey))
|
|
176
|
+
fail('INVALID_ENTITY', 'Invalid chunk manifest reference.');
|
|
177
|
+
const result = await downloadFile({ publicClient, manifestKey, expectedSha256: digest, onProgress });
|
|
178
|
+
bytes = result.bytes;
|
|
179
|
+
expiresAt = result.expiresAt < expiresAt ? result.expiresAt : expiresAt;
|
|
180
|
+
if (result.contentType !== mime || result.filename !== filename)
|
|
181
|
+
fail('INVALID_ENTITY', 'Image and file metadata disagree.');
|
|
182
|
+
}
|
|
183
|
+
else if (bytes.length > INLINE_MAX_BYTES)
|
|
184
|
+
fail('INVALID_ENTITY', 'Inline payload exceeds the image format limit.');
|
|
185
|
+
const info = inspectImage(bytes, mime);
|
|
186
|
+
if (BigInt(bytes.length) !== size || BigInt(info.width) !== attr(a, 'width', 'u64') || BigInt(info.height) !== attr(a, 'height', 'u64'))
|
|
187
|
+
fail('INVALID_ENTITY', 'Image dimensions or size disagree with attributes.');
|
|
188
|
+
if (await hashFile(bytes) !== digest.toLowerCase())
|
|
189
|
+
fail('HASH_MISMATCH', 'Recovered image bytes failed SHA-256 verification.');
|
|
190
|
+
return { ...info, bytes, filename, sha256: digest.toLowerCase(), mode, expiresAt };
|
|
191
|
+
}
|
|
192
|
+
catch (cause) {
|
|
193
|
+
if (cause instanceof ImageStorageError || cause instanceof ChunkingError)
|
|
194
|
+
throw cause;
|
|
195
|
+
throw new ImageStorageError('READ_FAILED', 'Image retrieval failed. Check the RPC connection and access policy.', { cause });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
{
|
|
2
|
+
"package": "arkiv-images@0.1.0",
|
|
3
|
+
"sdk": "0.8.0",
|
|
4
|
+
"viem": "2.56.3",
|
|
5
|
+
"chunking": "0.1.0",
|
|
6
|
+
"node": "v22.22.3",
|
|
7
|
+
"chainId": 7738577,
|
|
8
|
+
"account": "0xa618a2736431f24c26f1c8dac9ca00ecc845a1c6",
|
|
9
|
+
"wallet": "Rabby / Arkiv Wallet",
|
|
10
|
+
"cases": [
|
|
11
|
+
{
|
|
12
|
+
"mode": "inline",
|
|
13
|
+
"format": "image/png",
|
|
14
|
+
"bytes": 120000,
|
|
15
|
+
"imageKey": "0x21a2b016bbcc1c7195401ad1fb93ad857d42956ec6e77ec3f3c63a800ecfc312",
|
|
16
|
+
"sha256": "0x31c2782dc1ad3ce9a983c9743ed67337125b433dc08e532da315ed58e63203a4",
|
|
17
|
+
"width": 320,
|
|
18
|
+
"height": 200,
|
|
19
|
+
"transactions": [
|
|
20
|
+
{
|
|
21
|
+
"hash": "0x5376fd09019268aff4d79399df49ccaf697c4c674d9b29a2b6c6ad438e20ae56",
|
|
22
|
+
"blockNumber": "197767",
|
|
23
|
+
"gasUsed": "4843780",
|
|
24
|
+
"calldataBytes": 122308,
|
|
25
|
+
"signedBytes": 122418,
|
|
26
|
+
"status": "success"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"browserByteExact": true,
|
|
30
|
+
"byteExact": true,
|
|
31
|
+
"expiresAt": "240967"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"mode": "chunked",
|
|
35
|
+
"format": "image/png",
|
|
36
|
+
"bytes": 120001,
|
|
37
|
+
"imageKey": "0x85fb5d3f3a337e5cbb832c9a15afbcf5b9bb53ae2e8b62fd5fe13c0931543011",
|
|
38
|
+
"sha256": "0xc99a39198cd5767d1c5443044e09d554cbf6eddea0fb4f3d89c9bee346cfd0e6",
|
|
39
|
+
"width": 320,
|
|
40
|
+
"height": 200,
|
|
41
|
+
"transactions": [
|
|
42
|
+
{
|
|
43
|
+
"hash": "0x2f36b48cb3fca6f0d0caffd0e714bfd357b878e891288b4e69e8efc3d7dc0161",
|
|
44
|
+
"blockNumber": "197844",
|
|
45
|
+
"gasUsed": "111184",
|
|
46
|
+
"calldataBytes": 1988,
|
|
47
|
+
"signedBytes": 2096,
|
|
48
|
+
"status": "success"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"hash": "0x9687de326762655033de634206fcd19a1acd5f6de0144d4c4913741f2d92bf82",
|
|
52
|
+
"blockNumber": "197863",
|
|
53
|
+
"gasUsed": "4032200",
|
|
54
|
+
"calldataBytes": 101348,
|
|
55
|
+
"signedBytes": 101458,
|
|
56
|
+
"status": "success"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"hash": "0x570e64c58a45350bff5f4695a8413a1f0a4acc615a0d4fe2ab074f3fe7b075fe",
|
|
60
|
+
"blockNumber": "197869",
|
|
61
|
+
"gasUsed": "839840",
|
|
62
|
+
"calldataBytes": 21380,
|
|
63
|
+
"signedBytes": 21488,
|
|
64
|
+
"status": "success"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"hash": "0x26ffd70c7bb100591faf7270981478c79ec685a75b447c164fbc3235439a25b8",
|
|
68
|
+
"blockNumber": "197875",
|
|
69
|
+
"gasUsed": "45016",
|
|
70
|
+
"calldataBytes": 516,
|
|
71
|
+
"signedBytes": 624,
|
|
72
|
+
"status": "success"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"hash": "0x8ecb8c9be61e67da24b59e87b8c3605a6545bec71033c4710e7ebe9fa0219ae7",
|
|
76
|
+
"blockNumber": "197880",
|
|
77
|
+
"gasUsed": "125384",
|
|
78
|
+
"calldataBytes": 2500,
|
|
79
|
+
"signedBytes": 2608,
|
|
80
|
+
"status": "success"
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"browserByteExact": true,
|
|
84
|
+
"byteExact": true,
|
|
85
|
+
"expiresAt": "241044"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"mode": "inline",
|
|
89
|
+
"format": "image/jpeg",
|
|
90
|
+
"bytes": 2977,
|
|
91
|
+
"imageKey": "0xef4c88b48ca2f3f0f9a8069d339dff3f894d6ab7e707c3f4741d0e3c2937f472",
|
|
92
|
+
"sha256": "0x6505b34edf4866c89d73791c5b775ee8588ba62ba54249051891d7833d3b1684",
|
|
93
|
+
"width": 320,
|
|
94
|
+
"height": 200,
|
|
95
|
+
"transactions": [
|
|
96
|
+
{
|
|
97
|
+
"hash": "0xa2915a6a40e4ecaa7309193d60d48dbcb8cb51e588ff264806b6ebf06b43639e",
|
|
98
|
+
"blockNumber": "198040",
|
|
99
|
+
"gasUsed": "167792",
|
|
100
|
+
"calldataBytes": 5316,
|
|
101
|
+
"signedBytes": 5424,
|
|
102
|
+
"status": "success"
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"browserByteExact": true,
|
|
106
|
+
"byteExact": true,
|
|
107
|
+
"expiresAt": "241240"
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"lastReadVerifiedAt": "2026-09-08T21:55:43.748Z"
|
|
111
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Verification — arkiv-images 0.1.0
|
|
2
|
+
|
|
3
|
+
Verification date: 2026-09-08. This report distinguishes real Tiramisu transactions, controlled RPC tests and release gates. The npm archive contains the verification snapshot taken before publication; the [source report](https://github.com/SantiagoDevRel/arkiv-images/blob/feat/image-storage/docs/verification.md) records subsequent registry installation evidence.
|
|
4
|
+
|
|
5
|
+
## Tested combination
|
|
6
|
+
|
|
7
|
+
| Component | Actually tested |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Node / npm | 22.22.3 / 10.9.8 |
|
|
10
|
+
| TypeScript | 5.9.3; public consumer types compiled with strict NodeNext, without skipLibCheck |
|
|
11
|
+
| Arkiv SDK / viem | 0.8.0 / 2.56.3 |
|
|
12
|
+
| Chunk dependency | arkiv-chunking 0.1.0 from npm registry |
|
|
13
|
+
| Sample tooling | Vite 8.2.2 |
|
|
14
|
+
| Network | Tiramisu, RPC-confirmed chain ID 7738577 |
|
|
15
|
+
| Live signer | Rabby, explicitly authorized Arkiv Wallet; address in the receipt evidence |
|
|
16
|
+
| Browser automation | Playwright 1.62.1, installed Google Chrome; native pixel decoding |
|
|
17
|
+
|
|
18
|
+
Other SDK versions, networks, Firefox/Safari, mobile wallet browsers and a real MetaMask extension signing flow are not claimed tested. MetaMask-compatible EIP-1193 requests and EIP-6963 discovery were exercised with an injected test wallet; Rabby performed the real writes.
|
|
19
|
+
|
|
20
|
+
## Real network, original bytes verified
|
|
21
|
+
|
|
22
|
+
[Machine-readable receipts and image keys](testnet-evidence.json) include SHA-256, creation blocks, queried expiration, gas, calldata and signed transaction sizes. `node scripts/verify-network.mjs` reads Tiramisu again, compares the recovered bytes with the synthetic source fixtures, and checks all receipts, signer, target, zero native-token value and chain. It performs no writes and will fail when the temporary entities expire.
|
|
23
|
+
|
|
24
|
+
| Synthetic image | Flow | Confirmed transactions | Largest signed transaction |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| PNG, 120,000 bytes, 320 × 200 | Inline write → query → render → byte equality | 1 | 122,418 bytes |
|
|
27
|
+
| PNG, 120,001 bytes, 320 × 200 | Manifest + 2 chunks + finalize + image entity → reconstruction → render → byte equality | 5 | 101,458 bytes |
|
|
28
|
+
| JPEG, 2,977 bytes, 320 × 200 | Inline write → query → render → byte equality | 1 | 5,424 bytes |
|
|
29
|
+
|
|
30
|
+
An independent Node reader verified the same original bytes, digests and transaction receipts. Real RPC tests also confirmed missing image (`NOT_FOUND`) and configured-network mismatch (`NETWORK_MISMATCH`). Test GLM was spent only on Tiramisu. No personal photo or private credential is part of the fixtures or published files.
|
|
31
|
+
|
|
32
|
+
## Controlled regression checks
|
|
33
|
+
|
|
34
|
+
`npm test`: 13 passing tests with the actual SDK encoder/query decoder and a controlled RPC. Coverage includes validation, threshold planning, inline and chunked byte equality, JPEG, a maximum-length 128-byte filename, rejected writes, wrong chain, absent image, corrupted bytes/metadata, missing chunks and preserved manifest/receipts when final image creation fails. The full 25 MiB roundtrip exercises 263 chunks, 266 transactions and pagination beyond 200 entities. **25 MiB is simulated, not a live-network upload claim.**
|
|
35
|
+
|
|
36
|
+
`npm run typecheck` and the sample's `npm run build` pass. No lint command exists in this repository.
|
|
37
|
+
|
|
38
|
+
`npm run test:browser`: isolated browser context, injected wallet and controlled RPC, real SDK/package and pixel decoder. PNG inline, JPEG and chunked PNG upload/retrieve/download pass; download bytes match. Reload-and-read works without the original file or connected wallet. Empty, loading, missing-entity, RPC failure, rejected-signature, wrong-chain and invalid-file states were exercised.
|
|
39
|
+
|
|
40
|
+
Rendered widths: 390, 599, 600, 601, 768 and 1440 CSS pixels. DOM checks found no horizontal page overflow; computed typography and font loading matched the documented contract. Screenshots at 390/768/1440 also show the real-network JPEG result. Reflow was inspected at 200% CSS zoom; native browser-menu zoom and a physical touch device have not been separately verified. Screenshots are local test artifacts, not a deployed demo.
|
|
41
|
+
|
|
42
|
+
## Consumer and audit gates
|
|
43
|
+
|
|
44
|
+
A fresh agent with only the package/sample READMEs, consumer guides and an integration objective installed the packed candidate in a new directory. It ran the exact minimal example, compiled every public type against actual SDK clients, and read the real 120,000-byte image from Tiramisu without a wallet. Invalid format, excessive size, invalid/missing key, unexpected digest and wrong network failed as documented. No blocking documentation gaps were found; its suggestion of a complete anonymous-read example was incorporated.
|
|
45
|
+
|
|
46
|
+
Claude completed its source/security/logic/browser review after resuming a timed-out session. It independently retrieved all three live images and confirmed the recorded byte equality. It identified stale sample dependency contents and missing npm publication as blocking release gates. Those gates, clean registry consumption and source checkout verification are pending in this pre-publication snapshot; see the source report for their final status. Other observations were addressed through accurate error wording and documented JPEG limitations. The source repository includes the review resolution table in docs/claude-review.md.
|
|
47
|
+
|
|
48
|
+
## Preview and scope
|
|
49
|
+
|
|
50
|
+
The sample is available locally at http://127.0.0.1:3082 for developer review before deployment. No public demo deployment is claimed. The Arkiv Hub catalogue entry remains pending because its live-demo prerequisite cannot be met until that review/deploy step. No encryption, PDFs, new skills, MCP integration, scaffolding or unrelated Hub changes were added.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "arkiv-images",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Store public PNG and JPEG images as Arkiv entities and retrieve verified original bytes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"AGENTS.md",
|
|
17
|
+
"CLAUDE.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"docs/verification.md",
|
|
20
|
+
"docs/testnet-evidence.json"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.10.0"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/SantiagoDevRel/arkiv-images.git"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
35
|
+
"test:browser": "node test/browser-smoke.mjs",
|
|
36
|
+
"prepack": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"arkiv-chunking": "0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@arkiv-network/sdk": "0.8.0",
|
|
43
|
+
"viem": "^2.56.3"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@arkiv-network/sdk": "0.8.0",
|
|
47
|
+
"@types/node": "22.19.13",
|
|
48
|
+
"playwright": "1.62.1",
|
|
49
|
+
"typescript": "5.9.3",
|
|
50
|
+
"viem": "2.56.3"
|
|
51
|
+
}
|
|
52
|
+
}
|