@ldgr/client 0.3.0 → 0.5.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 +81 -0
- package/dist/index.d.ts +19 -23
- package/dist/index.js +275 -23
- package/dist/signer.d.ts +11 -1
- package/dist/transfer.d.ts +42 -0
- package/dist/types.d.ts +55 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -35,6 +35,87 @@ 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
|
+
|
|
70
|
+
## Silent payments
|
|
71
|
+
|
|
72
|
+
`silentPayments.send()` accepts a reusable LDGR payment code and a
|
|
73
|
+
`LocalLdgrWallet`. It signs and publishes the wallet's immutable sender-DH
|
|
74
|
+
public key, fetches the asset nonce, derives a fresh one-time destination, and
|
|
75
|
+
submits an ordinary transfer. A `NONCE_MISMATCH` causes one fresh derivation;
|
|
76
|
+
an ambiguous transport failure retries the exact signed request instead.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import {
|
|
80
|
+
createLdgrClient,
|
|
81
|
+
deriveLocalSilentPaymentIdentity,
|
|
82
|
+
loadLocalWallet,
|
|
83
|
+
} from "@ldgr/client";
|
|
84
|
+
|
|
85
|
+
const client = createLdgrClient({ network: "testnet" });
|
|
86
|
+
const senderWallet = await loadLocalWallet({
|
|
87
|
+
ledgerId: 0,
|
|
88
|
+
recoveryKey: await secretStore.read("ldgr-testnet-sender"),
|
|
89
|
+
});
|
|
90
|
+
// The receiver shares this code out of band; their recovery key never leaves
|
|
91
|
+
// their own wallet.
|
|
92
|
+
const receiver = deriveLocalSilentPaymentIdentity({
|
|
93
|
+
ledgerId: 0,
|
|
94
|
+
recoveryKey: await receiverSecretStore.read("ldgr-testnet-receiver"),
|
|
95
|
+
});
|
|
96
|
+
const result = await client.silentPayments.send({
|
|
97
|
+
wallet: senderWallet,
|
|
98
|
+
paymentCode: receiver.paymentCode,
|
|
99
|
+
asset: "t0",
|
|
100
|
+
amount: "2.50",
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
If two byte-identical transport attempts both remain ambiguous,
|
|
105
|
+
`SilentPaymentSubmissionUnknownError` exposes the exact `submission`. Pass it
|
|
106
|
+
to `client.silentPayments.retry(error.submission)`; do not start a fresh send,
|
|
107
|
+
which would use the next nonce and could duplicate a payment that already
|
|
108
|
+
settled.
|
|
109
|
+
|
|
110
|
+
`silentPayments.scan(after, limit)` returns a bounded ascending page, its raw
|
|
111
|
+
ledger-sequence `nextAfter` checkpoint, and `moreAvailable`. Matching requires
|
|
112
|
+
the receiver scan secret and belongs in wallet code such as
|
|
113
|
+
`@ldgr/wallet-core`, never on a server that does not own the recovery key. This
|
|
114
|
+
LDGR-specific construction is not BIP-352-compatible and remains experimental.
|
|
115
|
+
Wallet discovery is available on both ledgers, while reusable-code display,
|
|
116
|
+
first-party sending, and private-output spending remain gated pending testnet-first
|
|
117
|
+
operational testing and external cryptographic review.
|
|
118
|
+
|
|
38
119
|
```ts
|
|
39
120
|
import { createLdgrClient } from "@ldgr/client";
|
|
40
121
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import { type
|
|
1
|
+
import { type LdgrTransferResult, type SendTransferInput } from "./transfer";
|
|
2
|
+
import { type Ldgr402Requirement } from "@ldgr/protocol";
|
|
3
|
+
import { type LdgrAccountTransactionPage, type LdgrClientOptions, type LdgrAsset, type Ldgr402Verification, type LdgrTransaction, type PaymentExpectation, type PaymentVerification, type SignedSubmission, type SilentPaymentSendInput, type SilentPaymentScanPage, type WalletPublicKeyRecord, type WalletPublicKeyRegistration } from "./types";
|
|
3
4
|
export declare function createLdgrClient(options: LdgrClientOptions): {
|
|
4
5
|
ledger: {
|
|
5
6
|
get: () => Promise<{
|
|
@@ -25,23 +26,8 @@ export declare function createLdgrClient(options: LdgrClientOptions): {
|
|
|
25
26
|
readonly asset: string;
|
|
26
27
|
readonly nextNonce: string;
|
|
27
28
|
}>;
|
|
28
|
-
getWalletPublicKey: (address: string) => Promise<
|
|
29
|
-
|
|
30
|
-
readonly publicKey: string;
|
|
31
|
-
}>;
|
|
32
|
-
registerWalletPublicKey: (address: string, registration: {
|
|
33
|
-
readonly version: 1;
|
|
34
|
-
readonly action: "wallet_key_register";
|
|
35
|
-
readonly chain: "ldgr-v1";
|
|
36
|
-
readonly ledgerId: LedgerId;
|
|
37
|
-
readonly from: string;
|
|
38
|
-
readonly publicKey: string;
|
|
39
|
-
readonly timestamp: string;
|
|
40
|
-
readonly signature: string;
|
|
41
|
-
}) => Promise<{
|
|
42
|
-
readonly address: string;
|
|
43
|
-
readonly publicKey: string;
|
|
44
|
-
}>;
|
|
29
|
+
getWalletPublicKey: (address: string) => Promise<WalletPublicKeyRecord>;
|
|
30
|
+
registerWalletPublicKey: (address: string, registration: WalletPublicKeyRegistration) => Promise<WalletPublicKeyRecord>;
|
|
45
31
|
listTransactions(address: string, options?: {
|
|
46
32
|
readonly before?: string;
|
|
47
33
|
readonly asset?: string;
|
|
@@ -93,12 +79,22 @@ export declare function createLdgrClient(options: LdgrClientOptions): {
|
|
|
93
79
|
readonly nowMs?: number;
|
|
94
80
|
}) => Promise<Ldgr402Verification>;
|
|
95
81
|
};
|
|
82
|
+
silentPayments: {
|
|
83
|
+
scan: (after?: string, limit?: number) => Promise<SilentPaymentScanPage>;
|
|
84
|
+
send: (input: SilentPaymentSendInput) => Promise<LdgrTransferResult>;
|
|
85
|
+
retry(submission: SignedSubmission): Promise<LdgrTransferResult>;
|
|
86
|
+
};
|
|
87
|
+
transfers: {
|
|
88
|
+
send: (input: SendTransferInput) => Promise<LdgrTransferResult>;
|
|
89
|
+
};
|
|
96
90
|
submitSigned: <T extends {
|
|
97
91
|
readonly transactionHash?: string;
|
|
98
92
|
}>(path: string, signed: SignedSubmission) => Promise<T>;
|
|
99
93
|
};
|
|
100
|
-
export { LdgrClientError } from "./types";
|
|
94
|
+
export { LdgrClientError, SilentPaymentSubmissionUnknownError } from "./types";
|
|
101
95
|
export { decodeMerchantLdgr402Payment, parseMerchantLdgr402Requirement } from "./ldgr402";
|
|
102
|
-
export type { LdgrAccountTransaction, LdgrAccountTransactionPage, Ldgr402Requirement, Ldgr402Verification, LdgrAsset, LdgrClientOptions, LdgrNetwork, LdgrTransaction, PaymentExpectation, PaymentVerification, SignedSubmission } from "./types";
|
|
103
|
-
export { createWebCryptoSigner, generateLocalWallet, loadLocalWallet } from "./signer";
|
|
104
|
-
export type { LdgrSigner, LocalLdgrWallet } from "./signer";
|
|
96
|
+
export type { LdgrAccountTransaction, LdgrAccountTransactionPage, Ldgr402Requirement, Ldgr402Verification, LdgrAsset, LdgrClientOptions, LdgrNetwork, LdgrTransaction, PaymentExpectation, PaymentVerification, SignedSubmission, SilentPaymentScanPage, SilentPaymentScanRecord, SilentPaymentSendInput, WalletPublicKeyRecord, WalletPublicKeyRegistration } from "./types";
|
|
97
|
+
export { createWebCryptoSigner, deriveLocalSilentPaymentIdentity, generateLocalWallet, loadLocalWallet } from "./signer";
|
|
98
|
+
export type { LdgrSigner, LocalLdgrWallet, LocalSilentPaymentIdentity } from "./signer";
|
|
99
|
+
export { buildTransferSubmission } from "./transfer";
|
|
100
|
+
export type { BuildTransferSubmissionInput, LdgrTransferResult, SendTransferInput } from "./transfer";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
import { resolveLdgrNetwork } from "@ldgr/protocol";
|
|
3
3
|
|
|
4
4
|
// src/types.ts
|
|
5
|
+
class SilentPaymentSubmissionUnknownError extends Error {
|
|
6
|
+
submission;
|
|
7
|
+
constructor(submission, cause) {
|
|
8
|
+
super("Could not confirm silent payment submission; retry this exact signed submission", { cause });
|
|
9
|
+
this.name = "SilentPaymentSubmissionUnknownError";
|
|
10
|
+
this.submission = submission;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
class LdgrClientError extends Error {
|
|
6
15
|
status;
|
|
7
16
|
code;
|
|
@@ -77,31 +86,10 @@ function queryPath(path, query) {
|
|
|
77
86
|
return encoded ? `${path}?${encoded}` : path;
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
// src/index.ts
|
|
81
|
-
import {
|
|
82
|
-
decodeLdgr402Payment as decodeLdgr402Payment3,
|
|
83
|
-
parseLdgr402Requirement as parseLdgr402Requirement3,
|
|
84
|
-
resolveLdgrNetwork as resolveLdgrNetwork2,
|
|
85
|
-
resolvePublicAssetCode
|
|
86
|
-
} from "@ldgr/protocol";
|
|
87
|
-
// src/ldgr402.ts
|
|
88
|
-
import {
|
|
89
|
-
decodeLdgr402Payment,
|
|
90
|
-
parseLdgr402Requirement
|
|
91
|
-
} from "@ldgr/protocol";
|
|
92
|
-
import { decodeLdgr402Payment as decodeLdgr402Payment2, encodeLdgr402Payment, parseLdgr402Payment, parseLdgr402Requirement as parseLdgr402Requirement2 } from "@ldgr/protocol";
|
|
93
|
-
function parseMerchantLdgr402Requirement(input, options = {}) {
|
|
94
|
-
return parseLdgr402Requirement(input, {
|
|
95
|
-
nowMs: options.nowMs,
|
|
96
|
-
allowExpired: true
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
function decodeMerchantLdgr402Payment(headerValue) {
|
|
100
|
-
return decodeLdgr402Payment(headerValue);
|
|
101
|
-
}
|
|
102
89
|
// src/signer.ts
|
|
103
90
|
import {
|
|
104
91
|
deriveAddress,
|
|
92
|
+
deriveSilentPaymentIdentity,
|
|
105
93
|
decodeEd25519RecoveryKey,
|
|
106
94
|
encodeEd25519RecoveryKey,
|
|
107
95
|
exportEd25519PublicKey,
|
|
@@ -115,6 +103,16 @@ function encodeBase64Url(bytes) {
|
|
|
115
103
|
binary += String.fromCharCode(byte);
|
|
116
104
|
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
117
105
|
}
|
|
106
|
+
function deriveLocalSilentPaymentIdentity(input) {
|
|
107
|
+
const { privateSeed } = decodeEd25519RecoveryKey(input.recoveryKey);
|
|
108
|
+
const identity = deriveSilentPaymentIdentity(privateSeed, input.ledgerId);
|
|
109
|
+
return {
|
|
110
|
+
paymentCode: identity.paymentCode,
|
|
111
|
+
senderDhPublicKey: encodeBase64Url(identity.senderDhPublicKey),
|
|
112
|
+
receiverScanPublicKey: encodeBase64Url(identity.receiverScanPublicKey),
|
|
113
|
+
spendPublicKey: encodeBase64Url(identity.spendPublicKey)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
118
116
|
async function createWebCryptoSigner(input) {
|
|
119
117
|
const publicKey = encodeBase64Url(await exportEd25519PublicKey(input.publicKey));
|
|
120
118
|
const address = await deriveAddress(input.publicKey, input.ledgerId);
|
|
@@ -146,7 +144,82 @@ async function generateLocalWallet(input) {
|
|
|
146
144
|
async function loadLocalWallet(input) {
|
|
147
145
|
const { privateSeed } = decodeEd25519RecoveryKey(input.recoveryKey);
|
|
148
146
|
const keyPair = await importEd25519PrivateSeed(privateSeed);
|
|
149
|
-
return createWebCryptoSigner({ ...input, ...keyPair });
|
|
147
|
+
return { ...await createWebCryptoSigner({ ...input, ...keyPair }), recoveryKey: input.recoveryKey };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/transfer.ts
|
|
151
|
+
import {
|
|
152
|
+
canonicalizeLedgerAddress,
|
|
153
|
+
assertEncryptedMessageEnvelope,
|
|
154
|
+
formatAtomicAmount,
|
|
155
|
+
parseDecimalAmount
|
|
156
|
+
} from "@ldgr/protocol";
|
|
157
|
+
async function buildTransferSubmission(input) {
|
|
158
|
+
const recipient = canonicalizeLedgerAddress(input.to, input.wallet.ledgerId);
|
|
159
|
+
const amount = parseDecimalAmount(input.amount.trim(), input.assetDefinition);
|
|
160
|
+
if (amount < 0n || amount === 0n && (input.asset !== "dng" || input.encryptedMessage === undefined)) {
|
|
161
|
+
throw new RangeError("Amount must be greater than zero unless sending an encrypted Ledra message");
|
|
162
|
+
}
|
|
163
|
+
if (input.encryptedMessage !== undefined)
|
|
164
|
+
assertEncryptedMessageEnvelope(input.encryptedMessage);
|
|
165
|
+
if (!/^(0|[1-9][0-9]*)$/.test(input.nextNonce)) {
|
|
166
|
+
throw new TypeError("Ledger returned an invalid next nonce");
|
|
167
|
+
}
|
|
168
|
+
const memo = input.memo?.trim();
|
|
169
|
+
const payload = {
|
|
170
|
+
version: 1,
|
|
171
|
+
action: "transfer",
|
|
172
|
+
chain: "ldgr-v1",
|
|
173
|
+
ledgerId: input.wallet.ledgerId,
|
|
174
|
+
asset: input.asset,
|
|
175
|
+
from: input.wallet.address,
|
|
176
|
+
to: recipient,
|
|
177
|
+
amount,
|
|
178
|
+
nonce: BigInt(input.nextNonce),
|
|
179
|
+
timestamp: BigInt((input.now ?? Date.now)()),
|
|
180
|
+
publicKey: input.wallet.publicKey,
|
|
181
|
+
...memo ? { memo } : {},
|
|
182
|
+
...input.metadata === undefined ? {} : { metadata: input.metadata },
|
|
183
|
+
...input.encryptedMessage === undefined ? {} : { encryptedMessage: input.encryptedMessage }
|
|
184
|
+
};
|
|
185
|
+
const signature = await input.wallet.sign(payload);
|
|
186
|
+
return {
|
|
187
|
+
payload: {
|
|
188
|
+
...payload,
|
|
189
|
+
amount: formatAtomicAmount(amount, input.assetDefinition),
|
|
190
|
+
nonce: payload.nonce.toString(),
|
|
191
|
+
timestamp: payload.timestamp.toString()
|
|
192
|
+
},
|
|
193
|
+
signature
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/index.ts
|
|
198
|
+
import {
|
|
199
|
+
decodeLdgr402Payment as decodeLdgr402Payment3,
|
|
200
|
+
decodeEd25519RecoveryKey as decodeEd25519RecoveryKey2,
|
|
201
|
+
decodeSilentPaymentCode,
|
|
202
|
+
deriveSilentPaymentIdentity as deriveSilentPaymentIdentity2,
|
|
203
|
+
deriveSilentPaymentSenderOutput,
|
|
204
|
+
nativeAssetDefinitionForLedger,
|
|
205
|
+
parseLdgr402Requirement as parseLdgr402Requirement3,
|
|
206
|
+
resolveLdgrNetwork as resolveLdgrNetwork2,
|
|
207
|
+
resolvePublicAssetCode
|
|
208
|
+
} from "@ldgr/protocol";
|
|
209
|
+
// src/ldgr402.ts
|
|
210
|
+
import {
|
|
211
|
+
decodeLdgr402Payment,
|
|
212
|
+
parseLdgr402Requirement
|
|
213
|
+
} from "@ldgr/protocol";
|
|
214
|
+
import { decodeLdgr402Payment as decodeLdgr402Payment2, encodeLdgr402Payment, parseLdgr402Payment, parseLdgr402Requirement as parseLdgr402Requirement2 } from "@ldgr/protocol";
|
|
215
|
+
function parseMerchantLdgr402Requirement(input, options = {}) {
|
|
216
|
+
return parseLdgr402Requirement(input, {
|
|
217
|
+
nowMs: options.nowMs,
|
|
218
|
+
allowExpired: true
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function decodeMerchantLdgr402Payment(headerValue) {
|
|
222
|
+
return decodeLdgr402Payment(headerValue);
|
|
150
223
|
}
|
|
151
224
|
|
|
152
225
|
// src/index.ts
|
|
@@ -174,6 +247,18 @@ function paymentReference(metadata) {
|
|
|
174
247
|
function transactionFields(transaction) {
|
|
175
248
|
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
249
|
}
|
|
250
|
+
function isNonceMismatch(error) {
|
|
251
|
+
return error instanceof LdgrClientError && error.code === "NONCE_MISMATCH";
|
|
252
|
+
}
|
|
253
|
+
function transferFailure(error) {
|
|
254
|
+
if (!(error instanceof LdgrClientError) || error.status !== 400 && error.status !== 409)
|
|
255
|
+
return;
|
|
256
|
+
return {
|
|
257
|
+
status: "failed",
|
|
258
|
+
error: error.message,
|
|
259
|
+
...error.code === undefined ? {} : { code: error.code }
|
|
260
|
+
};
|
|
261
|
+
}
|
|
177
262
|
function createLdgrClient(options) {
|
|
178
263
|
const transport = createTransport(options);
|
|
179
264
|
const ledgerId = options.network === undefined ? undefined : resolveLdgrNetwork2(options.network);
|
|
@@ -224,6 +309,149 @@ function createLdgrClient(options) {
|
|
|
224
309
|
reference: requirement.challenge
|
|
225
310
|
});
|
|
226
311
|
};
|
|
312
|
+
const sendTransfer = async (input) => {
|
|
313
|
+
if (ledgerId !== undefined && ledgerId !== input.wallet.ledgerId) {
|
|
314
|
+
throw new TypeError("signer ledgerId does not match client network");
|
|
315
|
+
}
|
|
316
|
+
const requestedAsset = input.asset.trim();
|
|
317
|
+
if (!requestedAsset)
|
|
318
|
+
throw new TypeError("asset is required");
|
|
319
|
+
const asset = resolvePublicAssetCode(requestedAsset, input.wallet.ledgerId);
|
|
320
|
+
const assetDefinition = asset === "dng" ? nativeAssetDefinitionForLedger(input.wallet.ledgerId) : await transport.get(`/api/v1/assets/${encodePathSegment(asset)}`);
|
|
321
|
+
const submit = async () => {
|
|
322
|
+
const nonce = await transport.get(`/api/v1/nonces/${encodePathSegment(input.wallet.address)}/${encodePathSegment(asset)}`);
|
|
323
|
+
const submission = await buildTransferSubmission({
|
|
324
|
+
...input,
|
|
325
|
+
asset,
|
|
326
|
+
assetDefinition,
|
|
327
|
+
nextNonce: nonce.nextNonce
|
|
328
|
+
});
|
|
329
|
+
const transaction = await transport.post("/api/v1/transfers", {
|
|
330
|
+
...submission,
|
|
331
|
+
submittedAtMs: Date.now()
|
|
332
|
+
});
|
|
333
|
+
if (typeof transaction.transactionHash !== "string" || typeof transaction.fee !== "string" || typeof transaction.feeAsset !== "string") {
|
|
334
|
+
throw new TypeError("LDGR transfer response is missing transaction hash or fee");
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
status: "settled",
|
|
338
|
+
transactionHash: transaction.transactionHash,
|
|
339
|
+
fee: transaction.fee,
|
|
340
|
+
feeAsset: transaction.feeAsset,
|
|
341
|
+
transaction
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
try {
|
|
345
|
+
return await submit();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (isNonceMismatch(error)) {
|
|
348
|
+
try {
|
|
349
|
+
return await submit();
|
|
350
|
+
} catch (retryError) {
|
|
351
|
+
const failure2 = transferFailure(retryError);
|
|
352
|
+
if (failure2 !== undefined)
|
|
353
|
+
return failure2;
|
|
354
|
+
throw retryError;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const failure = transferFailure(error);
|
|
358
|
+
if (failure !== undefined)
|
|
359
|
+
return failure;
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const sendSilentPayment = async (input) => {
|
|
364
|
+
if (ledgerId !== undefined && ledgerId !== input.wallet.ledgerId) {
|
|
365
|
+
throw new TypeError("signer ledgerId does not match client network");
|
|
366
|
+
}
|
|
367
|
+
const requestedAsset = input.asset.trim();
|
|
368
|
+
if (!requestedAsset)
|
|
369
|
+
throw new TypeError("asset is required");
|
|
370
|
+
const asset = resolvePublicAssetCode(requestedAsset, input.wallet.ledgerId);
|
|
371
|
+
decodeSilentPaymentCode(input.paymentCode, input.wallet.ledgerId);
|
|
372
|
+
const assetDefinition = asset === "dng" ? nativeAssetDefinitionForLedger(input.wallet.ledgerId) : await transport.get(`/api/v1/assets/${encodePathSegment(asset)}`);
|
|
373
|
+
const senderIdentity = deriveSilentPaymentIdentity2(decodeEd25519RecoveryKey2(input.wallet.recoveryKey).privateSeed, input.wallet.ledgerId);
|
|
374
|
+
const registrationPayload = {
|
|
375
|
+
version: 1,
|
|
376
|
+
action: "wallet_key_register",
|
|
377
|
+
chain: "ldgr-v1",
|
|
378
|
+
ledgerId: input.wallet.ledgerId,
|
|
379
|
+
from: input.wallet.address,
|
|
380
|
+
publicKey: input.wallet.publicKey,
|
|
381
|
+
senderDhPublicKey: deriveLocalSilentPaymentIdentity(input.wallet).senderDhPublicKey,
|
|
382
|
+
timestamp: String((input.now ?? Date.now)())
|
|
383
|
+
};
|
|
384
|
+
await transport.post(`/api/v1/wallets/${encodePathSegment(input.wallet.address)}/public-key`, { ...registrationPayload, signature: await input.wallet.sign(registrationPayload) });
|
|
385
|
+
const submit = async () => {
|
|
386
|
+
const nonce = await transport.get(`/api/v1/nonces/${encodePathSegment(input.wallet.address)}/${encodePathSegment(asset)}`);
|
|
387
|
+
if (!/^(0|[1-9][0-9]*)$/.test(nonce.nextNonce))
|
|
388
|
+
throw new TypeError("Ledger returned an invalid next nonce");
|
|
389
|
+
const output = deriveSilentPaymentSenderOutput({
|
|
390
|
+
senderDhPrivateKey: senderIdentity.senderDhPrivateKey,
|
|
391
|
+
paymentCode: input.paymentCode,
|
|
392
|
+
chain: "ldgr-v1",
|
|
393
|
+
ledgerId: input.wallet.ledgerId,
|
|
394
|
+
senderAddress: input.wallet.address,
|
|
395
|
+
action: "transfer",
|
|
396
|
+
asset,
|
|
397
|
+
nonce: BigInt(nonce.nextNonce),
|
|
398
|
+
outputIndex: 0
|
|
399
|
+
});
|
|
400
|
+
const submission = await buildTransferSubmission({
|
|
401
|
+
wallet: input.wallet,
|
|
402
|
+
asset,
|
|
403
|
+
assetDefinition,
|
|
404
|
+
amount: input.amount,
|
|
405
|
+
to: output.address,
|
|
406
|
+
nextNonce: nonce.nextNonce,
|
|
407
|
+
...input.memo === undefined ? {} : { memo: input.memo },
|
|
408
|
+
...input.metadata === undefined ? {} : { metadata: input.metadata },
|
|
409
|
+
...input.now === undefined ? {} : { now: input.now }
|
|
410
|
+
});
|
|
411
|
+
const feeSponsor = input.buildFeeSponsor === undefined ? undefined : await input.buildFeeSponsor(submission);
|
|
412
|
+
const requestBody = {
|
|
413
|
+
...submission,
|
|
414
|
+
...feeSponsor === undefined ? {} : { feeSponsor },
|
|
415
|
+
submittedAtMs: Date.now()
|
|
416
|
+
};
|
|
417
|
+
let transaction;
|
|
418
|
+
try {
|
|
419
|
+
transaction = await transport.post("/api/v1/transfers", requestBody);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (error instanceof LdgrClientError)
|
|
422
|
+
throw error;
|
|
423
|
+
try {
|
|
424
|
+
transaction = await transport.post("/api/v1/transfers", requestBody);
|
|
425
|
+
} catch (retryError) {
|
|
426
|
+
if (retryError instanceof LdgrClientError)
|
|
427
|
+
throw retryError;
|
|
428
|
+
throw new SilentPaymentSubmissionUnknownError(requestBody, retryError);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (typeof transaction.transactionHash !== "string" || typeof transaction.fee !== "string" || typeof transaction.feeAsset !== "string") {
|
|
432
|
+
throw new TypeError("LDGR transfer response is missing transaction hash or fee");
|
|
433
|
+
}
|
|
434
|
+
return { status: "settled", transactionHash: transaction.transactionHash, fee: transaction.fee, feeAsset: transaction.feeAsset, transaction };
|
|
435
|
+
};
|
|
436
|
+
try {
|
|
437
|
+
return await submit();
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (isNonceMismatch(error)) {
|
|
440
|
+
try {
|
|
441
|
+
return await submit();
|
|
442
|
+
} catch (retryError) {
|
|
443
|
+
const failure2 = transferFailure(retryError);
|
|
444
|
+
if (failure2 !== undefined)
|
|
445
|
+
return failure2;
|
|
446
|
+
throw retryError;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const failure = transferFailure(error);
|
|
450
|
+
if (failure !== undefined)
|
|
451
|
+
return failure;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
227
455
|
return {
|
|
228
456
|
ledger: {
|
|
229
457
|
get: () => transport.get("/api/v1/ledger"),
|
|
@@ -293,14 +521,38 @@ function createLdgrClient(options) {
|
|
|
293
521
|
listOwned: (address, options2) => transport.get(queryPath(`/api/v1/usernames/owned-by/${encodePathSegment(address)}`, options2 ?? {}))
|
|
294
522
|
},
|
|
295
523
|
payments: { verifyTransfer, verifyLdgr402 },
|
|
524
|
+
silentPayments: {
|
|
525
|
+
scan: (after = "0", limit) => transport.get(queryPath("/api/v1/silent-payments/scan", { after, limit })),
|
|
526
|
+
send: sendSilentPayment,
|
|
527
|
+
async retry(submission) {
|
|
528
|
+
try {
|
|
529
|
+
const transaction = await transport.post("/api/v1/transfers", submission);
|
|
530
|
+
if (typeof transaction.transactionHash !== "string" || typeof transaction.fee !== "string" || typeof transaction.feeAsset !== "string") {
|
|
531
|
+
throw new TypeError("LDGR transfer response is missing transaction hash or fee");
|
|
532
|
+
}
|
|
533
|
+
return { status: "settled", transactionHash: transaction.transactionHash, fee: transaction.fee, feeAsset: transaction.feeAsset, transaction };
|
|
534
|
+
} catch (error) {
|
|
535
|
+
const failure = transferFailure(error);
|
|
536
|
+
if (failure !== undefined)
|
|
537
|
+
return failure;
|
|
538
|
+
if (error instanceof LdgrClientError)
|
|
539
|
+
throw error;
|
|
540
|
+
throw new SilentPaymentSubmissionUnknownError(submission, error);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
transfers: { send: sendTransfer },
|
|
296
545
|
submitSigned: (path, signed) => transport.post(path, { ...signed, submittedAtMs: Date.now() })
|
|
297
546
|
};
|
|
298
547
|
}
|
|
299
548
|
export {
|
|
300
549
|
LdgrClientError,
|
|
550
|
+
SilentPaymentSubmissionUnknownError,
|
|
551
|
+
buildTransferSubmission,
|
|
301
552
|
createLdgrClient,
|
|
302
553
|
createWebCryptoSigner,
|
|
303
554
|
decodeMerchantLdgr402Payment,
|
|
555
|
+
deriveLocalSilentPaymentIdentity,
|
|
304
556
|
generateLocalWallet,
|
|
305
557
|
loadLocalWallet,
|
|
306
558
|
parseMerchantLdgr402Requirement
|
package/dist/signer.d.ts
CHANGED
|
@@ -9,6 +9,16 @@ export interface LocalLdgrWallet extends LdgrSigner {
|
|
|
9
9
|
/** Store this only in the caller's chosen secret manager; it is never sent to LDGR. */
|
|
10
10
|
readonly recoveryKey: string;
|
|
11
11
|
}
|
|
12
|
+
export interface LocalSilentPaymentIdentity {
|
|
13
|
+
readonly paymentCode: string;
|
|
14
|
+
readonly senderDhPublicKey: string;
|
|
15
|
+
readonly receiverScanPublicKey: string;
|
|
16
|
+
readonly spendPublicKey: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function deriveLocalSilentPaymentIdentity(input: {
|
|
19
|
+
readonly recoveryKey: string;
|
|
20
|
+
readonly ledgerId: LedgerId;
|
|
21
|
+
}): LocalSilentPaymentIdentity;
|
|
12
22
|
export declare function createWebCryptoSigner(input: {
|
|
13
23
|
readonly ledgerId: LedgerId;
|
|
14
24
|
readonly privateKey: CryptoKey;
|
|
@@ -21,4 +31,4 @@ export declare function generateLocalWallet(input: {
|
|
|
21
31
|
export declare function loadLocalWallet(input: {
|
|
22
32
|
readonly recoveryKey: string;
|
|
23
33
|
readonly ledgerId: LedgerId;
|
|
24
|
-
}): Promise<
|
|
34
|
+
}): Promise<LocalLdgrWallet>;
|
|
@@ -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AssetBridgePolicy, LdgrNetwork } from "@ldgr/protocol";
|
|
2
|
+
import type { LocalLdgrWallet } from "./signer";
|
|
2
3
|
export type LedgerId = 0 | 1;
|
|
3
4
|
export type { LdgrNetwork } from "@ldgr/protocol";
|
|
4
5
|
export type LdgrTransaction = {
|
|
@@ -7,6 +8,8 @@ export type LdgrTransaction = {
|
|
|
7
8
|
readonly to?: string;
|
|
8
9
|
readonly asset?: string;
|
|
9
10
|
readonly amount?: string;
|
|
11
|
+
readonly fee?: string;
|
|
12
|
+
readonly feeAsset?: string;
|
|
10
13
|
readonly metadata?: unknown;
|
|
11
14
|
readonly sequence?: string;
|
|
12
15
|
readonly transactionHash: string;
|
|
@@ -73,6 +76,58 @@ export type SignedSubmission = {
|
|
|
73
76
|
readonly feeSponsor?: unknown;
|
|
74
77
|
readonly submittedAtMs?: number;
|
|
75
78
|
};
|
|
79
|
+
export type WalletPublicKeyRegistration = {
|
|
80
|
+
readonly version: 1;
|
|
81
|
+
readonly action: "wallet_key_register";
|
|
82
|
+
readonly chain: "ldgr-v1";
|
|
83
|
+
readonly ledgerId: LedgerId;
|
|
84
|
+
readonly from: string;
|
|
85
|
+
readonly publicKey: string;
|
|
86
|
+
/** Domain-separated X25519 public key used only by silent-payment senders. */
|
|
87
|
+
readonly senderDhPublicKey?: string;
|
|
88
|
+
readonly timestamp: string;
|
|
89
|
+
readonly signature: string;
|
|
90
|
+
};
|
|
91
|
+
export type WalletPublicKeyRecord = {
|
|
92
|
+
readonly address: string;
|
|
93
|
+
readonly publicKey: string;
|
|
94
|
+
readonly senderDhPublicKey?: string;
|
|
95
|
+
};
|
|
96
|
+
export type SilentPaymentScanRecord = {
|
|
97
|
+
readonly sequence: string;
|
|
98
|
+
readonly transactionHash: string;
|
|
99
|
+
readonly from: string;
|
|
100
|
+
readonly senderDhPublicKey: string;
|
|
101
|
+
readonly action: "transfer" | "transfer_batch";
|
|
102
|
+
readonly asset: string;
|
|
103
|
+
readonly nonce: string;
|
|
104
|
+
readonly outputs: readonly {
|
|
105
|
+
readonly to: string;
|
|
106
|
+
readonly amountAtomic: string;
|
|
107
|
+
}[];
|
|
108
|
+
};
|
|
109
|
+
export type SilentPaymentScanPage = {
|
|
110
|
+
readonly records: readonly SilentPaymentScanRecord[];
|
|
111
|
+
/** Raw committed-sequence checkpoint, including pages with no matching records. */
|
|
112
|
+
readonly nextAfter: string;
|
|
113
|
+
readonly moreAvailable: boolean;
|
|
114
|
+
};
|
|
115
|
+
export type SilentPaymentSendInput = {
|
|
116
|
+
readonly wallet: LocalLdgrWallet;
|
|
117
|
+
readonly paymentCode: string;
|
|
118
|
+
/** Public native id (`t0`/`t1`) or wire asset code. */
|
|
119
|
+
readonly asset: string;
|
|
120
|
+
readonly amount: string;
|
|
121
|
+
readonly memo?: string;
|
|
122
|
+
readonly metadata?: import("@ldgr/protocol").JsonValue;
|
|
123
|
+
readonly now?: () => number;
|
|
124
|
+
/** Recomputed for every signed attempt because it binds the exact payload. */
|
|
125
|
+
readonly buildFeeSponsor?: (submission: Pick<SignedSubmission, "payload" | "signature">) => Promise<unknown>;
|
|
126
|
+
};
|
|
127
|
+
export declare class SilentPaymentSubmissionUnknownError extends Error {
|
|
128
|
+
readonly submission: SignedSubmission;
|
|
129
|
+
constructor(submission: SignedSubmission, cause?: unknown);
|
|
130
|
+
}
|
|
76
131
|
export type LdgrClientOptions = {
|
|
77
132
|
/** `testnet` / `0` or `mainnet` / `1`; required to use public native ids `t0` / `t1`. */
|
|
78
133
|
readonly network?: LdgrNetwork;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ldgr/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.
|
|
15
|
+
"dependencies": { "@ldgr/protocol": "^0.5.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",
|