@ldgr/client 0.3.0 → 0.4.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/README.md CHANGED
@@ -35,6 +35,38 @@ console.log(serviceWallet.address); // safe to identify the service wallet
35
35
  This is for backend/Bun service wallets only. Websites should use
36
36
  `@ldgr/wallet-sdk` and an extension-confirmed customer wallet instead.
37
37
 
38
+ ## Send a native or user-issued asset
39
+
40
+ `transfers.send()` resolves the asset metadata, fetches the current nonce,
41
+ builds and signs the transfer, submits it to the selected network, and retries
42
+ once if another process consumed the nonce first. Use
43
+ `LEDRA_TESTNET_ASSET_ID` / `LEDRA_MAINNET_ASSET_ID` from `@ldgr/protocol` for
44
+ the native asset or pass a user-token code.
45
+
46
+ ```ts
47
+ import { createLdgrClient } from "@ldgr/client";
48
+ import { LEDRA_MAINNET_ASSET_ID } from "@ldgr/protocol";
49
+
50
+ const client = createLdgrClient({ network: "mainnet" });
51
+ const result = await client.transfers.send({
52
+ wallet: serviceWallet,
53
+ asset: LEDRA_MAINNET_ASSET_ID,
54
+ amount: "2.50",
55
+ to: recipientAddress,
56
+ memo: "Order 1234",
57
+ });
58
+
59
+ if (result.status === "settled") {
60
+ console.log(result.transactionHash, result.fee, result.feeAsset);
61
+ } else {
62
+ console.error(result.code, result.error);
63
+ }
64
+ ```
65
+
66
+ Settlement failures return `status: "failed"`; they do not have a transaction
67
+ hash or fee because the ledger rejected them before appending a transaction.
68
+ Transport and unexpected response errors still throw.
69
+
38
70
  ```ts
39
71
  import { createLdgrClient } from "@ldgr/client";
40
72
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LdgrTransferResult, type SendTransferInput } from "./transfer";
1
2
  import { type Ldgr402Requirement, type LedgerId } from "@ldgr/protocol";
2
3
  import { type LdgrAccountTransactionPage, type LdgrClientOptions, type LdgrAsset, type Ldgr402Verification, type LdgrTransaction, type PaymentExpectation, type PaymentVerification, type SignedSubmission } from "./types";
3
4
  export declare function createLdgrClient(options: LdgrClientOptions): {
@@ -93,6 +94,9 @@ export declare function createLdgrClient(options: LdgrClientOptions): {
93
94
  readonly nowMs?: number;
94
95
  }) => Promise<Ldgr402Verification>;
95
96
  };
97
+ transfers: {
98
+ send: (input: SendTransferInput) => Promise<LdgrTransferResult>;
99
+ };
96
100
  submitSigned: <T extends {
97
101
  readonly transactionHash?: string;
98
102
  }>(path: string, signed: SignedSubmission) => Promise<T>;
@@ -102,3 +106,5 @@ export { decodeMerchantLdgr402Payment, parseMerchantLdgr402Requirement } from ".
102
106
  export type { LdgrAccountTransaction, LdgrAccountTransactionPage, Ldgr402Requirement, Ldgr402Verification, LdgrAsset, LdgrClientOptions, LdgrNetwork, LdgrTransaction, PaymentExpectation, PaymentVerification, SignedSubmission } from "./types";
103
107
  export { createWebCryptoSigner, generateLocalWallet, loadLocalWallet } from "./signer";
104
108
  export type { LdgrSigner, LocalLdgrWallet } from "./signer";
109
+ export { buildTransferSubmission } from "./transfer";
110
+ export type { BuildTransferSubmissionInput, LdgrTransferResult, SendTransferInput } from "./transfer";
package/dist/index.js CHANGED
@@ -77,9 +77,57 @@ function queryPath(path, query) {
77
77
  return encoded ? `${path}?${encoded}` : path;
78
78
  }
79
79
 
80
+ // src/transfer.ts
81
+ import {
82
+ canonicalizeLedgerAddress,
83
+ assertEncryptedMessageEnvelope,
84
+ formatAtomicAmount,
85
+ parseDecimalAmount
86
+ } from "@ldgr/protocol";
87
+ async function buildTransferSubmission(input) {
88
+ const recipient = canonicalizeLedgerAddress(input.to, input.wallet.ledgerId);
89
+ const amount = parseDecimalAmount(input.amount.trim(), input.assetDefinition);
90
+ if (amount < 0n || amount === 0n && (input.asset !== "dng" || input.encryptedMessage === undefined)) {
91
+ throw new RangeError("Amount must be greater than zero unless sending an encrypted Ledra message");
92
+ }
93
+ if (input.encryptedMessage !== undefined)
94
+ assertEncryptedMessageEnvelope(input.encryptedMessage);
95
+ if (!/^(0|[1-9][0-9]*)$/.test(input.nextNonce)) {
96
+ throw new TypeError("Ledger returned an invalid next nonce");
97
+ }
98
+ const memo = input.memo?.trim();
99
+ const payload = {
100
+ version: 1,
101
+ action: "transfer",
102
+ chain: "ldgr-v1",
103
+ ledgerId: input.wallet.ledgerId,
104
+ asset: input.asset,
105
+ from: input.wallet.address,
106
+ to: recipient,
107
+ amount,
108
+ nonce: BigInt(input.nextNonce),
109
+ timestamp: BigInt((input.now ?? Date.now)()),
110
+ publicKey: input.wallet.publicKey,
111
+ ...memo ? { memo } : {},
112
+ ...input.metadata === undefined ? {} : { metadata: input.metadata },
113
+ ...input.encryptedMessage === undefined ? {} : { encryptedMessage: input.encryptedMessage }
114
+ };
115
+ const signature = await input.wallet.sign(payload);
116
+ return {
117
+ payload: {
118
+ ...payload,
119
+ amount: formatAtomicAmount(amount, input.assetDefinition),
120
+ nonce: payload.nonce.toString(),
121
+ timestamp: payload.timestamp.toString()
122
+ },
123
+ signature
124
+ };
125
+ }
126
+
80
127
  // src/index.ts
81
128
  import {
82
129
  decodeLdgr402Payment as decodeLdgr402Payment3,
130
+ nativeAssetDefinitionForLedger,
83
131
  parseLdgr402Requirement as parseLdgr402Requirement3,
84
132
  resolveLdgrNetwork as resolveLdgrNetwork2,
85
133
  resolvePublicAssetCode
@@ -174,6 +222,18 @@ function paymentReference(metadata) {
174
222
  function transactionFields(transaction) {
175
223
  return typeof transaction.from === "string" && typeof transaction.to === "string" && typeof transaction.asset === "string" && typeof transaction.amount === "string" ? { from: transaction.from, to: transaction.to, asset: transaction.asset, amount: transaction.amount } : null;
176
224
  }
225
+ function isNonceMismatch(error) {
226
+ return error instanceof LdgrClientError && error.code === "NONCE_MISMATCH";
227
+ }
228
+ function transferFailure(error) {
229
+ if (!(error instanceof LdgrClientError) || error.status !== 400 && error.status !== 409)
230
+ return;
231
+ return {
232
+ status: "failed",
233
+ error: error.message,
234
+ ...error.code === undefined ? {} : { code: error.code }
235
+ };
236
+ }
177
237
  function createLdgrClient(options) {
178
238
  const transport = createTransport(options);
179
239
  const ledgerId = options.network === undefined ? undefined : resolveLdgrNetwork2(options.network);
@@ -224,6 +284,57 @@ function createLdgrClient(options) {
224
284
  reference: requirement.challenge
225
285
  });
226
286
  };
287
+ const sendTransfer = async (input) => {
288
+ if (ledgerId !== undefined && ledgerId !== input.wallet.ledgerId) {
289
+ throw new TypeError("signer ledgerId does not match client network");
290
+ }
291
+ const requestedAsset = input.asset.trim();
292
+ if (!requestedAsset)
293
+ throw new TypeError("asset is required");
294
+ const asset = resolvePublicAssetCode(requestedAsset, input.wallet.ledgerId);
295
+ const assetDefinition = asset === "dng" ? nativeAssetDefinitionForLedger(input.wallet.ledgerId) : await transport.get(`/api/v1/assets/${encodePathSegment(asset)}`);
296
+ const submit = async () => {
297
+ const nonce = await transport.get(`/api/v1/nonces/${encodePathSegment(input.wallet.address)}/${encodePathSegment(asset)}`);
298
+ const submission = await buildTransferSubmission({
299
+ ...input,
300
+ asset,
301
+ assetDefinition,
302
+ nextNonce: nonce.nextNonce
303
+ });
304
+ const transaction = await transport.post("/api/v1/transfers", {
305
+ ...submission,
306
+ submittedAtMs: Date.now()
307
+ });
308
+ if (typeof transaction.transactionHash !== "string" || typeof transaction.fee !== "string" || typeof transaction.feeAsset !== "string") {
309
+ throw new TypeError("LDGR transfer response is missing transaction hash or fee");
310
+ }
311
+ return {
312
+ status: "settled",
313
+ transactionHash: transaction.transactionHash,
314
+ fee: transaction.fee,
315
+ feeAsset: transaction.feeAsset,
316
+ transaction
317
+ };
318
+ };
319
+ try {
320
+ return await submit();
321
+ } catch (error) {
322
+ if (isNonceMismatch(error)) {
323
+ try {
324
+ return await submit();
325
+ } catch (retryError) {
326
+ const failure2 = transferFailure(retryError);
327
+ if (failure2 !== undefined)
328
+ return failure2;
329
+ throw retryError;
330
+ }
331
+ }
332
+ const failure = transferFailure(error);
333
+ if (failure !== undefined)
334
+ return failure;
335
+ throw error;
336
+ }
337
+ };
227
338
  return {
228
339
  ledger: {
229
340
  get: () => transport.get("/api/v1/ledger"),
@@ -293,11 +404,13 @@ function createLdgrClient(options) {
293
404
  listOwned: (address, options2) => transport.get(queryPath(`/api/v1/usernames/owned-by/${encodePathSegment(address)}`, options2 ?? {}))
294
405
  },
295
406
  payments: { verifyTransfer, verifyLdgr402 },
407
+ transfers: { send: sendTransfer },
296
408
  submitSigned: (path, signed) => transport.post(path, { ...signed, submittedAtMs: Date.now() })
297
409
  };
298
410
  }
299
411
  export {
300
412
  LdgrClientError,
413
+ buildTransferSubmission,
301
414
  createLdgrClient,
302
415
  createWebCryptoSigner,
303
416
  decodeMerchantLdgr402Payment,
@@ -0,0 +1,42 @@
1
+ import { type AssetDefinition, type JsonValue } from "@ldgr/protocol";
2
+ import type { LdgrSigner } from "./signer";
3
+ import type { LdgrTransaction } from "./types";
4
+ export type BuildTransferSubmissionInput = {
5
+ readonly wallet: LdgrSigner;
6
+ readonly asset: string;
7
+ readonly assetDefinition: AssetDefinition;
8
+ readonly amount: string;
9
+ readonly to: string;
10
+ readonly nextNonce: string;
11
+ readonly memo?: string;
12
+ readonly metadata?: JsonValue;
13
+ readonly encryptedMessage?: string;
14
+ readonly now?: () => number;
15
+ };
16
+ export type SendTransferInput = {
17
+ readonly wallet: LdgrSigner;
18
+ /** Public native id (`t0`/`t1`) or wire asset code. */
19
+ readonly asset: string;
20
+ readonly amount: string;
21
+ readonly to: string;
22
+ readonly memo?: string;
23
+ readonly metadata?: JsonValue;
24
+ readonly encryptedMessage?: string;
25
+ readonly now?: () => number;
26
+ };
27
+ export type LdgrTransferResult = {
28
+ readonly status: "settled";
29
+ readonly transactionHash: string;
30
+ readonly fee: string;
31
+ readonly feeAsset: string;
32
+ readonly transaction: LdgrTransaction;
33
+ } | {
34
+ readonly status: "failed";
35
+ /** Settlement failures happen before append, so there is no hash or fee. */
36
+ readonly error: string;
37
+ readonly code?: string;
38
+ };
39
+ export declare function buildTransferSubmission(input: BuildTransferSubmissionInput): Promise<{
40
+ readonly payload: Record<string, unknown>;
41
+ readonly signature: string;
42
+ }>;
package/dist/types.d.ts CHANGED
@@ -7,6 +7,8 @@ export type LdgrTransaction = {
7
7
  readonly to?: string;
8
8
  readonly asset?: string;
9
9
  readonly amount?: string;
10
+ readonly fee?: string;
11
+ readonly feeAsset?: string;
10
12
  readonly metadata?: unknown;
11
13
  readonly sequence?: string;
12
14
  readonly transactionHash: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ldgr/client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -12,7 +12,7 @@
12
12
  "files": ["dist", "README.md"],
13
13
  "publishConfig": { "access": "public" },
14
14
  "engines": { "node": ">=20.19.0" },
15
- "dependencies": { "@ldgr/protocol": "^0.3.0" },
15
+ "dependencies": { "@ldgr/protocol": "^0.4.0" },
16
16
  "scripts": {
17
17
  "build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
18
18
  "prepack": "bun run build",