Skip to content

Commit 89d3174

Browse files
committed
feat, small refactoring + helper functions
1 parent b7bce6c commit 89d3174

File tree

10 files changed

+297
-35
lines changed

10 files changed

+297
-35
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@axlabs/neofs-sdk-ts-core",
3-
"version": "0.0.1",
3+
"version": "0.0.2",
44
"description": "Shared core code for NeoFS TypeScript SDK",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/crypto/ecdsa/signer.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { sha256, sha512 } from '../../utils/crypto';
22
import { randomBytes } from '../../utils/buffer';
3+
import { base58Decode } from '../../utils/base58';
34
import { ec as EC } from 'elliptic';
45
import { Scheme, PublicKey } from '../scheme';
56
import { Signer } from '../signer';
@@ -49,8 +50,7 @@ export class ECDSASigner implements Signer {
4950
static decodeWIF(wif: string): Uint8Array {
5051
if (!wif) throw new Error('WIF cannot be null');
5152

52-
// Decode Base58
53-
const data = this.base58Decode(wif);
53+
const data = base58Decode(wif);
5454

5555
// Neo WIF format: 0x80 + 32 bytes private key + checksum
5656
if (data.length < 33 || data[0] !== 0x80) {
@@ -64,38 +64,6 @@ export class ECDSASigner implements Signer {
6464
return privateKey;
6565
}
6666

67-
/**
68-
* Base58 decoding implementation.
69-
*/
70-
private static base58Decode(encoded: string): Uint8Array {
71-
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
72-
let value = 0n;
73-
74-
// Decode Base58 string to BigInteger
75-
for (let i = 0; i < encoded.length; i++) {
76-
const digit = alphabet.indexOf(encoded[i]);
77-
if (digit < 0) {
78-
throw new Error(`Invalid Base58 character '${encoded[i]}' at position ${i}`);
79-
}
80-
value = value * 58n + BigInt(digit);
81-
}
82-
83-
// Convert BigInteger to byte array
84-
const leadingZeros = encoded.match(/^1+/)?.[0].length || 0;
85-
const bytes = [];
86-
87-
while (value > 0n) {
88-
bytes.unshift(Number(value & 0xFFn));
89-
value = value >> 8n;
90-
}
91-
92-
// Add leading zeros
93-
const result = new Uint8Array(leadingZeros + bytes.length);
94-
result.set(bytes, leadingZeros);
95-
96-
return result;
97-
}
98-
9967
/**
10068
* Generates a new random ECDSA key pair.
10169
*/

src/crypto/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './scheme';
22
export * from './signer';
3+
export * from './signer-factory';
34
export * from './signature';
45
export * from './ecdsa';
56
export * from './util';

src/crypto/signer-factory.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Unified signer factory that auto-detects key format.
3+
*/
4+
5+
import { hexToBytes } from '../utils/buffer';
6+
import { Signer } from './signer';
7+
import { ECDSASigner } from './ecdsa';
8+
9+
/**
10+
* Create an {@link ECDSASigner} from a private key string.
11+
*
12+
* Accepts two formats:
13+
*
14+
* - **WIF** (Wallet Import Format): a Base58Check-encoded string
15+
* (e.g. `L1aW4aubDFB7yfras2S1mN3bqg9nwySY8nkoLmJebSLD5BWv3ENZ`)
16+
* - **Hex**: a 64-character hex string with optional `0x` prefix
17+
* (e.g. `0xabcdef0123456789...` or `abcdef0123456789...`)
18+
*
19+
* @throws Error if the key cannot be parsed in either format
20+
*
21+
* @example
22+
* ```ts
23+
* import { createSigner } from '@axlabs/neofs-sdk-ts-core';
24+
*
25+
* const signer = createSigner(process.env.NEOFS_PRIVATE_KEY!);
26+
* const client = new NeoFSClient({ endpoint, signer });
27+
* ```
28+
*/
29+
export function createSigner(privateKey: string): Signer {
30+
const trimmed = privateKey.trim();
31+
const hex = trimmed.toLowerCase().replace(/^0x/, '');
32+
33+
// Try hex first (64 hex chars = 32 bytes)
34+
if (/^[0-9a-f]+$/i.test(hex) && hex.length === 64) {
35+
return ECDSASigner.fromPrivateKeyBytes(hexToBytes(hex));
36+
}
37+
38+
// Try WIF
39+
try {
40+
return ECDSASigner.fromWIF(trimmed);
41+
} catch {
42+
// fall through
43+
}
44+
45+
throw new Error(
46+
'Invalid private key: expected 64-char hex (with optional 0x prefix) or WIF (Base58Check).',
47+
);
48+
}

src/types/enums.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* NeoFS protocol enums derived from the protobuf definitions.
3+
* These are re-exported by platform-specific SDKs (node, react-native).
4+
*/
5+
6+
/**
7+
* Search filter match types for object attribute queries.
8+
*
9+
* Values correspond to the NeoFS protobuf `MatchType` enum.
10+
* IMPORTANT: `UNSPECIFIED` (0) is the protobuf default and is NOT serialized
11+
* on the wire — using it in a search filter effectively disables the filter.
12+
* Always use an explicit match type like `STRING_EQUAL`.
13+
*/
14+
export enum MatchType {
15+
UNSPECIFIED = 0,
16+
STRING_EQUAL = 1,
17+
STRING_NOT_EQUAL = 2,
18+
NOT_PRESENT = 3,
19+
COMMON_PREFIX = 4,
20+
NUM_GT = 5,
21+
NUM_GE = 6,
22+
NUM_LT = 7,
23+
NUM_LE = 8,
24+
}
25+
26+
/**
27+
* Checksum algorithm identifiers used in object headers.
28+
*
29+
* Values correspond to the NeoFS protobuf `ChecksumType` enum.
30+
*/
31+
export enum ChecksumType {
32+
CHECKSUM_TYPE_UNSPECIFIED = 0,
33+
/** Tillich-Zémor homomorphic hash */
34+
TZ = 1,
35+
/** SHA-256 */
36+
SHA256 = 2,
37+
}
38+
39+
/**
40+
* Object types in NeoFS.
41+
*
42+
* Values correspond to the NeoFS protobuf `ObjectType` enum.
43+
*/
44+
export enum ObjectType {
45+
REGULAR = 0,
46+
TOMBSTONE = 1,
47+
STORAGE_GROUP = 2,
48+
LOCK = 3,
49+
LINK = 4,
50+
}

src/types/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Decimal } from './decimal';
2+
export * from './enums';
23

34
export interface ContainerID {
45
value: Uint8Array;
@@ -12,3 +13,22 @@ export interface Address {
1213
containerId: ContainerID;
1314
objectId: ObjectID;
1415
}
16+
17+
/** Object header attribute as returned by the NeoFS API. */
18+
export interface ObjectAttribute {
19+
key: string;
20+
value: string;
21+
}
22+
23+
/** Object header as returned by head / get operations. */
24+
export interface ObjectHeader {
25+
containerId?: ContainerID;
26+
ownerId?: Uint8Array;
27+
attributes?: ObjectAttribute[];
28+
payloadLength?: number;
29+
payloadHash?: { type: number; sum: Uint8Array };
30+
homomorphicHash?: { type: number; sum: Uint8Array };
31+
objectType?: number;
32+
version?: { major: number; minor: number };
33+
creationEpoch?: number;
34+
}

src/utils/base58.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Base58 encoding/decoding (Bitcoin alphabet).
3+
* Extracted from ECDSASigner so it can be shared across the SDK.
4+
*/
5+
6+
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
7+
8+
/**
9+
* Decodes a Base58-encoded string to bytes.
10+
*
11+
* @throws Error if the string contains invalid Base58 characters
12+
*/
13+
export function base58Decode(encoded: string): Uint8Array {
14+
let value = 0n;
15+
16+
for (let i = 0; i < encoded.length; i++) {
17+
const digit = ALPHABET.indexOf(encoded[i]);
18+
if (digit < 0) {
19+
throw new Error(`Invalid Base58 character '${encoded[i]}' at position ${i}`);
20+
}
21+
value = value * 58n + BigInt(digit);
22+
}
23+
24+
const leadingZeros = encoded.match(/^1+/)?.[0].length || 0;
25+
const bytes: number[] = [];
26+
27+
while (value > 0n) {
28+
bytes.unshift(Number(value & 0xFFn));
29+
value = value >> 8n;
30+
}
31+
32+
const result = new Uint8Array(leadingZeros + bytes.length);
33+
result.set(bytes, leadingZeros);
34+
35+
return result;
36+
}
37+
38+
/**
39+
* Encodes bytes to a Base58 string.
40+
*/
41+
export function base58Encode(bytes: Uint8Array): string {
42+
let value = 0n;
43+
for (const b of bytes) {
44+
value = value * 256n + BigInt(b);
45+
}
46+
47+
let encoded = '';
48+
while (value > 0n) {
49+
encoded = ALPHABET[Number(value % 58n)] + encoded;
50+
value = value / 58n;
51+
}
52+
53+
for (const b of bytes) {
54+
if (b !== 0) break;
55+
encoded = '1' + encoded;
56+
}
57+
58+
return encoded || '1';
59+
}

src/utils/checksum.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Payload checksum helpers for NeoFS object headers.
3+
*/
4+
5+
import { ChecksumType } from '../types/enums';
6+
import { sha256 } from './crypto';
7+
import { tzHash } from '../crypto/tz';
8+
9+
/**
10+
* Compute both SHA-256 and Tillich-Zémor (TZ) checksums for an object payload.
11+
*
12+
* NeoFS object headers require `payloadHash` (SHA-256) and `homomorphicHash` (TZ).
13+
* This helper produces both in the format expected by the `ObjectClient.put()` header.
14+
*
15+
* @example
16+
* ```ts
17+
* const checksums = payloadChecksums(payload);
18+
* await objectClient.put({
19+
* header: { containerId, ownerId, ...checksums },
20+
* payload,
21+
* });
22+
* ```
23+
*/
24+
export function payloadChecksums(payload: Uint8Array): {
25+
payloadHash: { type: number; sum: Uint8Array };
26+
homomorphicHash: { type: number; sum: Uint8Array };
27+
} {
28+
return {
29+
payloadHash: {
30+
type: ChecksumType.SHA256,
31+
sum: sha256(payload),
32+
},
33+
homomorphicHash: {
34+
type: ChecksumType.TZ,
35+
sum: tzHash(payload),
36+
},
37+
};
38+
}

src/utils/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
export * from './buffer';
2+
export * from './base58';
23
export * from './crypto';
4+
export * from './checksum';
5+
export * from './neofs';

src/utils/neofs.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* NeoFS-specific utility helpers.
3+
*/
4+
5+
import { ContainerID, ObjectAttribute } from '../types';
6+
import { hexToBytes } from './buffer';
7+
import { base58Decode } from './base58';
8+
9+
/**
10+
* Parse a container ID from a hex string (with optional `0x` prefix) or
11+
* a Base58-encoded string into a {@link ContainerID}.
12+
*
13+
* @example
14+
* ```ts
15+
* const cid = toContainerId('CYzn4HPHXRmGW5cwvrqHtosd79kjBtVYAP3ykH6RCCAa');
16+
* const cid2 = toContainerId('0xabcdef...');
17+
* ```
18+
*
19+
* @throws Error if the string is not valid hex or Base58
20+
*/
21+
export function toContainerId(containerId: string): ContainerID {
22+
const raw = containerId.trim();
23+
const hex = raw.toLowerCase().replace(/^0x/, '');
24+
if (/^[0-9a-f]+$/i.test(hex) && hex.length > 0 && hex.length % 2 === 0) {
25+
return { value: hexToBytes(hex) };
26+
}
27+
try {
28+
const bytes = base58Decode(raw);
29+
return { value: bytes };
30+
} catch {
31+
throw new Error(
32+
`Invalid container ID: expected hex (even length, optional 0x prefix) or Base58. Got: "${raw}"`,
33+
);
34+
}
35+
}
36+
37+
/**
38+
* Convert an array of `{ key, value }` object attributes (as returned by the
39+
* NeoFS API) into a plain `Record<string, string>` for easier lookup.
40+
*
41+
* @example
42+
* ```ts
43+
* const head = await objectClient.head({ address, raw: false });
44+
* const attrs = decodeAttributes(head.attributes);
45+
* console.log(attrs['FileName']);
46+
* ```
47+
*/
48+
export function decodeAttributes(
49+
attrs?: ObjectAttribute[] | Array<{ key: string; value: string }>,
50+
): Record<string, string> {
51+
const out: Record<string, string> = {};
52+
if (attrs) {
53+
for (const a of attrs) out[a.key] = a.value;
54+
}
55+
return out;
56+
}
57+
58+
/**
59+
* Classify a NeoFS gRPC / SDK error as retryable.
60+
*
61+
* Returns `true` for transient failures such as expired sessions,
62+
* authentication issues that may self-resolve after session renewal,
63+
* and timeout / deadline errors.
64+
*/
65+
export function isRetryableNeoFSError(err: unknown): boolean {
66+
const msg = String((err as any)?.message || (err as any)?.details || '');
67+
return (
68+
msg.includes('session') ||
69+
msg.includes('expired') ||
70+
msg.includes('UNAUTHENTICATED') ||
71+
msg.includes('permission') ||
72+
msg.includes('deadline') ||
73+
msg.includes('timeout')
74+
);
75+
}

0 commit comments

Comments
 (0)