@bsv/message-box-client 1.4.1 → 1.4.2

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.
@@ -868,6 +868,143 @@ export class MessageBoxClient {
868
868
  throw new Error(`Failed to send message: ${errorMessage}`);
869
869
  }
870
870
  }
871
+ /**
872
+ * Multi-recipient sender. Uses the multi-quote route to:
873
+ * - identify blocked recipients
874
+ * - compute per-recipient payment
875
+ * Then sends to the allowed recipients with payment attached.
876
+ */
877
+ async sendMesagetoRecepients(params, overrideHost) {
878
+ await this.assertInitialized();
879
+ const { recipients, messageBox, body, skipEncryption } = params;
880
+ if (!Array.isArray(recipients) || recipients.length === 0) {
881
+ throw new Error('You must provide at least one recipient!');
882
+ }
883
+ if (!messageBox || messageBox.trim() === '') {
884
+ throw new Error('You must provide a messageBox to send this message into!');
885
+ }
886
+ if (body == null || (typeof body === 'string' && body.trim().length === 0)) {
887
+ throw new Error('Every message must have a body!');
888
+ }
889
+ // 1) Multi-quote for all recipients
890
+ const quoteResponse = await this.getMessageBoxQuote({
891
+ recipient: recipients,
892
+ messageBox
893
+ }, overrideHost);
894
+ const quotesByRecipient = Array.isArray(quoteResponse?.quotesByRecipient)
895
+ ? quoteResponse.quotesByRecipient : [];
896
+ const blocked = (quoteResponse?.blockedRecipients ?? []);
897
+ const totals = quoteResponse?.totals;
898
+ // 2) Filter allowed recipients
899
+ const allowedRecipients = recipients.filter(r => !blocked.includes(r));
900
+ if (allowedRecipients.length === 0) {
901
+ return {
902
+ status: 'error',
903
+ description: `All ${recipients.length} recipients are blocked.`,
904
+ sent: [],
905
+ blocked,
906
+ failed: recipients.map(r => ({ recipient: r, error: 'blocked' })),
907
+ totals
908
+ };
909
+ }
910
+ // 3) Map recipient -> fees
911
+ const perRecipientQuotes = new Map();
912
+ for (const q of quotesByRecipient) {
913
+ perRecipientQuotes.set(q.recipient, { recipientFee: q.recipientFee, deliveryFee: q.deliveryFee });
914
+ }
915
+ // 4) One delivery agent only (batch goes to one server)
916
+ const { deliveryAgentIdentityKeyByHost } = quoteResponse;
917
+ if (!deliveryAgentIdentityKeyByHost || Object.keys(deliveryAgentIdentityKeyByHost).length === 0) {
918
+ throw new Error('Missing delivery agent identity keys in quote response.');
919
+ }
920
+ if (Object.keys(deliveryAgentIdentityKeyByHost).length > 1 && !overrideHost) {
921
+ // To keep the single-POST invariant, we require all recipients to share a host
922
+ throw new Error('Recipients resolve to multiple hosts. Use overrideHost to force a single server or split by host.');
923
+ }
924
+ // pick the host to POST to
925
+ const finalHost = (overrideHost ?? await this.resolveHostForRecipient(allowedRecipients[0])).replace(/\/+$/, '');
926
+ const singleDeliveryKey = deliveryAgentIdentityKeyByHost[finalHost]
927
+ ?? Object.values(deliveryAgentIdentityKeyByHost)[0];
928
+ if (!singleDeliveryKey) {
929
+ throw new Error('Could not determine server delivery agent identity key.');
930
+ }
931
+ // 5) Identity key (sender)
932
+ if (!this.myIdentityKey) {
933
+ const keyResult = await this.walletClient.getPublicKey({ identityKey: true }, this.originator);
934
+ this.myIdentityKey = keyResult.publicKey;
935
+ }
936
+ // 6) Build per-recipient messageIds (HMAC), same order as allowedRecipients
937
+ const messageIds = [];
938
+ for (const r of allowedRecipients) {
939
+ const hmac = await this.walletClient.createHmac({
940
+ data: Array.from(new TextEncoder().encode(JSON.stringify(body))),
941
+ protocolID: [1, 'messagebox'],
942
+ keyID: '1',
943
+ counterparty: r
944
+ }, this.originator);
945
+ const mid = Array.from(hmac.hmac).map(b => b.toString(16).padStart(2, '0')).join('');
946
+ messageIds.push(mid);
947
+ }
948
+ // 7) Body: for batch route the server expects a single shared body
949
+ // NOTE: If you need per-recipient encryption, we must change the server payload shape.
950
+ let finalBody;
951
+ if (skipEncryption === true) {
952
+ finalBody = typeof body === 'string' ? body : JSON.stringify(body);
953
+ }
954
+ else {
955
+ // safest for now: send plaintext; the recipients can decrypt payload fields client-side if needed
956
+ finalBody = typeof body === 'string' ? body : JSON.stringify(body);
957
+ }
958
+ // 8) ONE batch payment with server output at index 0
959
+ const paymentData = await this.createMessagePaymentBatch(allowedRecipients, perRecipientQuotes, singleDeliveryKey);
960
+ // 9) Single POST to /sendMessage with recipients[] + messageId[]
961
+ const requestBody = {
962
+ message: {
963
+ recipients: allowedRecipients,
964
+ messageBox,
965
+ messageId: messageIds, // aligned by index with recipients
966
+ body: finalBody
967
+ },
968
+ payment: paymentData
969
+ };
970
+ Logger.log('[MB CLIENT] Sending HTTP request to:', `${finalHost}/sendMessage`);
971
+ Logger.log('[MB CLIENT] Request Body (batch):', JSON.stringify({ ...requestBody, payment: { ...paymentData, tx: '<omitted>' } }, null, 2));
972
+ try {
973
+ const response = await this.authFetch.fetch(`${finalHost}/sendMessage`, {
974
+ method: 'POST',
975
+ headers: { 'Content-Type': 'application/json' },
976
+ body: JSON.stringify(requestBody)
977
+ });
978
+ const parsed = await response.json().catch(() => ({}));
979
+ if (!response.ok || parsed.status !== 'success') {
980
+ const msg = !response.ok ? `HTTP ${response.status} - ${response.statusText}` : (parsed.description ?? 'Unknown server error');
981
+ throw new Error(msg);
982
+ }
983
+ // server returns { results: [{ recipient, messageId }] }
984
+ const sent = Array.isArray(parsed.results) ? parsed.results : [];
985
+ const failed = []; // handled server-side now
986
+ const status = sent.length === allowedRecipients.length ? 'success'
987
+ : sent.length > 0 ? 'partial'
988
+ : 'error';
989
+ const description = status === 'success'
990
+ ? `Sent to ${sent.length} recipients.`
991
+ : status === 'partial'
992
+ ? `Sent to ${sent.length} recipients; ${allowedRecipients.length - sent.length} failed; ${blocked.length} blocked.`
993
+ : `Failed to send to ${allowedRecipients.length} allowed recipients. ${blocked.length} blocked.`;
994
+ return { status, description, sent, blocked, failed, totals };
995
+ }
996
+ catch (err) {
997
+ const msg = err instanceof Error ? err.message : 'Unknown error';
998
+ return {
999
+ status: 'error',
1000
+ description: `Batch send failed: ${msg}`,
1001
+ sent: [],
1002
+ blocked,
1003
+ failed: allowedRecipients.map(r => ({ recipient: r, error: msg })),
1004
+ totals
1005
+ };
1006
+ }
1007
+ }
871
1008
  /**
872
1009
  * @method anointHost
873
1010
  * @async
@@ -1519,31 +1656,144 @@ export class MessageBoxClient {
1519
1656
  * })
1520
1657
  */
1521
1658
  async getMessageBoxQuote(params, overrideHost) {
1522
- const finalHost = overrideHost ?? await this.resolveHostForRecipient(params.recipient);
1523
- const queryParams = new URLSearchParams({
1524
- recipient: params.recipient,
1525
- messageBox: params.messageBox
1526
- });
1527
- Logger.log('[MB CLIENT] Getting messageBox quote...');
1528
- const response = await this.authFetch.fetch(`${finalHost}/permissions/quote?${queryParams.toString()}`, {
1529
- method: 'GET'
1530
- });
1531
- if (!response.ok) {
1532
- const errorData = await response.json().catch(() => ({}));
1533
- throw new Error(`Failed to get quote: HTTP ${response.status} - ${String(errorData.description) ?? response.statusText}`);
1534
- }
1535
- const { status, description, quote } = await response.json();
1536
- if (status === 'error') {
1537
- throw new Error(description ?? 'Failed to get quote');
1538
- }
1539
- const deliveryAgentIdentityKey = response.headers.get('x-bsv-auth-identity-key');
1540
- if (deliveryAgentIdentityKey == null) {
1541
- throw new Error('Failed to get quote: Delivery agent did not provide their identity key');
1659
+ // ---------- SINGLE RECIPIENT (back-compat) ----------
1660
+ if (!Array.isArray(params.recipient)) {
1661
+ const finalHost = overrideHost ?? await this.resolveHostForRecipient(params.recipient);
1662
+ const queryParams = new URLSearchParams({
1663
+ recipient: params.recipient,
1664
+ messageBox: params.messageBox
1665
+ });
1666
+ Logger.log('[MB CLIENT] Getting messageBox quote (single)...');
1667
+ console.log("HELP IM QUOTING", `${finalHost}/permissions/quote?${queryParams.toString()}`);
1668
+ const response = await this.authFetch.fetch(`${finalHost}/permissions/quote?${queryParams.toString()}`, { method: 'GET' });
1669
+ console.log("server response from getquote]", response);
1670
+ if (!response.ok) {
1671
+ const errorData = await response.json().catch(() => ({}));
1672
+ throw new Error(`Failed to get quote: HTTP ${response.status} - ${String(errorData.description) ?? response.statusText}`);
1673
+ }
1674
+ const { status, description, quote } = await response.json();
1675
+ if (status === 'error') {
1676
+ throw new Error(description ?? 'Failed to get quote');
1677
+ }
1678
+ const deliveryAgentIdentityKey = response.headers.get('x-bsv-auth-identity-key');
1679
+ console.log("deliveryAgentIdentityKey", deliveryAgentIdentityKey);
1680
+ if (deliveryAgentIdentityKey == null) {
1681
+ throw new Error('Failed to get quote: Delivery agent did not provide their identity key');
1682
+ }
1683
+ return {
1684
+ recipientFee: quote.recipientFee,
1685
+ deliveryFee: quote.deliveryFee,
1686
+ deliveryAgentIdentityKey
1687
+ };
1542
1688
  }
1689
+ // ---------- MULTI RECIPIENTS ----------
1690
+ const recipients = params.recipient;
1691
+ if (recipients.length === 0) {
1692
+ throw new Error('At least one recipient is required.');
1693
+ }
1694
+ Logger.log('[MB CLIENT] Getting messageBox quotes (multi)...');
1695
+ console.log("[MB CLIENT] Getting messageBox quotes (multi)...");
1696
+ // Resolve host per recipient (unless caller forces overrideHost)
1697
+ // Group recipients by host so we call each overlay once.
1698
+ const hostGroups = new Map();
1699
+ for (const r of recipients) {
1700
+ const host = overrideHost ?? await this.resolveHostForRecipient(r);
1701
+ const list = hostGroups.get(host);
1702
+ if (list)
1703
+ list.push(r);
1704
+ else
1705
+ hostGroups.set(host, [r]);
1706
+ }
1707
+ const deliveryAgentIdentityKeyByHost = {};
1708
+ const quotesByRecipient = [];
1709
+ const blockedRecipients = [];
1710
+ let totalDeliveryFees = 0;
1711
+ let totalRecipientFees = 0;
1712
+ // Helper to fetch one host group
1713
+ const fetchGroup = async (host, groupRecipients) => {
1714
+ const qp = new URLSearchParams();
1715
+ for (const r of groupRecipients)
1716
+ qp.append('recipient', r);
1717
+ qp.set('messageBox', params.messageBox);
1718
+ const url = `${host}/permissions/quote?${qp.toString()}`;
1719
+ Logger.log('[MB CLIENT] Multi-quote GET:', url);
1720
+ const resp = await this.authFetch.fetch(url, { method: 'GET' });
1721
+ if (!resp.ok) {
1722
+ const errorData = await resp.json().catch(() => ({}));
1723
+ throw new Error(`Failed to get quote (host ${host}): HTTP ${resp.status} - ${String(errorData.description) ?? resp.statusText}`);
1724
+ }
1725
+ const deliveryAgentKey = resp.headers.get('x-bsv-auth-identity-key');
1726
+ if (!deliveryAgentKey) {
1727
+ throw new Error(`Failed to get quote (host ${host}): missing delivery agent identity key`);
1728
+ }
1729
+ deliveryAgentIdentityKeyByHost[host] = deliveryAgentKey;
1730
+ const payload = await resp.json();
1731
+ // Server supports both shapes. For multi we expect:
1732
+ // { quotesByRecipient, totals, blockedRecipients }
1733
+ if (Array.isArray(payload?.quotesByRecipient)) {
1734
+ // merge quotes
1735
+ for (const q of payload.quotesByRecipient) {
1736
+ quotesByRecipient.push({
1737
+ recipient: q.recipient,
1738
+ messageBox: q.messageBox,
1739
+ deliveryFee: q.deliveryFee,
1740
+ recipientFee: q.recipientFee,
1741
+ status: q.status
1742
+ });
1743
+ // aggregate client-side totals as well (in case we hit multiple hosts)
1744
+ totalDeliveryFees += q.deliveryFee;
1745
+ if (q.recipientFee === -1) {
1746
+ if (!blockedRecipients.includes(q.recipient))
1747
+ blockedRecipients.push(q.recipient);
1748
+ }
1749
+ else {
1750
+ totalRecipientFees += q.recipientFee;
1751
+ }
1752
+ }
1753
+ // Also merge server totals if present (they are per-host); we already aggregated above,
1754
+ // so we don’t need to use payload.totals except for sanity/logging.
1755
+ if (Array.isArray(payload?.blockedRecipients)) {
1756
+ for (const br of payload.blockedRecipients) {
1757
+ if (!blockedRecipients.includes(br))
1758
+ blockedRecipients.push(br);
1759
+ }
1760
+ }
1761
+ }
1762
+ else if (payload?.quote) {
1763
+ // Defensive: if an overlay still returns single-quote shape for multi (shouldn’t),
1764
+ // we map it to each recipient in the group uniformly.
1765
+ for (const r of groupRecipients) {
1766
+ const { deliveryFee, recipientFee } = payload.quote;
1767
+ const status = recipientFee === -1 ? 'blocked' : recipientFee === 0 ? 'always_allow' : 'payment_required';
1768
+ quotesByRecipient.push({
1769
+ recipient: r,
1770
+ messageBox: params.messageBox,
1771
+ deliveryFee,
1772
+ recipientFee,
1773
+ status
1774
+ });
1775
+ totalDeliveryFees += deliveryFee;
1776
+ if (recipientFee === -1)
1777
+ blockedRecipients.push(r);
1778
+ else
1779
+ totalRecipientFees += recipientFee;
1780
+ }
1781
+ }
1782
+ else {
1783
+ throw new Error(`Unexpected quote response shape from host ${host}`);
1784
+ }
1785
+ };
1786
+ // Run all host groups (in parallel, but you can limit if needed)
1787
+ await Promise.all(Array.from(hostGroups.entries()).map(([host, group]) => fetchGroup(host, group)));
1543
1788
  return {
1544
- recipientFee: quote.recipientFee,
1545
- deliveryFee: quote.deliveryFee,
1546
- deliveryAgentIdentityKey
1789
+ quotesByRecipient,
1790
+ totals: {
1791
+ deliveryFees: totalDeliveryFees,
1792
+ recipientFees: totalRecipientFees,
1793
+ totalForPayableRecipients: totalDeliveryFees + totalRecipientFees
1794
+ },
1795
+ blockedRecipients,
1796
+ deliveryAgentIdentityKeyByHost
1547
1797
  };
1548
1798
  }
1549
1799
  /**
@@ -1699,13 +1949,20 @@ export class MessageBoxClient {
1699
1949
  */
1700
1950
  async sendNotification(recipient, body, overrideHost) {
1701
1951
  await this.assertInitialized();
1702
- // Use sendMessage with permission checking enabled
1703
- // This eliminates duplication of quote fetching and payment logic
1704
- return await this.sendMessage({
1705
- recipient,
1952
+ // Single recipient keep original flow
1953
+ if (!Array.isArray(recipient)) {
1954
+ return await this.sendMessage({
1955
+ recipient,
1956
+ messageBox: 'notifications',
1957
+ body,
1958
+ checkPermissions: true
1959
+ }, overrideHost);
1960
+ }
1961
+ // Multiple recipients → new flow
1962
+ return await this.sendMesagetoRecepients({
1963
+ recipients: recipient,
1706
1964
  messageBox: 'notifications',
1707
- body,
1708
- checkPermissions: true
1965
+ body
1709
1966
  }, overrideHost);
1710
1967
  }
1711
1968
  /**
@@ -1850,6 +2107,7 @@ export class MessageBoxClient {
1850
2107
  const derivationPrefix = Utils.toBase64(Random(32));
1851
2108
  const derivationSuffix = Utils.toBase64(Random(32));
1852
2109
  // Get host's derived public key
2110
+ console.log('delivery agent:', quote.deliveryAgentIdentityKey);
1853
2111
  const { publicKey: derivedKeyResult } = await this.walletClient.getPublicKey({
1854
2112
  protocolID: [2, '3241645161d8'],
1855
2113
  keyID: `${derivationPrefix} ${derivationSuffix}`,
@@ -1930,5 +2188,87 @@ export class MessageBoxClient {
1930
2188
  // labels
1931
2189
  };
1932
2190
  }
2191
+ async createMessagePaymentBatch(recipients, perRecipientQuotes,
2192
+ // server (delivery agent) identity key to pay the delivery fee to
2193
+ serverIdentityKey, description = 'MessageBox delivery payment (batch)') {
2194
+ const outputs = [];
2195
+ const createActionOutputs = [];
2196
+ // figure out the per-request delivery fee (take it from any quoted recipient)
2197
+ const deliveryFeeOnce = recipients.reduce((acc, r) => {
2198
+ const q = perRecipientQuotes.get(r);
2199
+ return q ? (acc ?? q.deliveryFee) : acc;
2200
+ }, undefined) ?? 0;
2201
+ const senderIdentityKey = await this.getIdentityKey();
2202
+ let outputIndex = 0;
2203
+ // index 0: server delivery fee (if any)
2204
+ if (deliveryFeeOnce > 0) {
2205
+ const derivationPrefix = Utils.toBase64(Random(32));
2206
+ const derivationSuffix = Utils.toBase64(Random(32));
2207
+ const { publicKey: agentDerived } = await this.walletClient.getPublicKey({
2208
+ protocolID: [2, '3241645161d8'],
2209
+ keyID: `${derivationPrefix} ${derivationSuffix}`,
2210
+ counterparty: serverIdentityKey
2211
+ }, this.originator);
2212
+ const lockingScript = new P2PKH().lock(PublicKey.fromString(agentDerived).toAddress()).toHex();
2213
+ createActionOutputs.push({
2214
+ satoshis: deliveryFeeOnce,
2215
+ lockingScript,
2216
+ outputDescription: 'MessageBox server delivery fee (batch)',
2217
+ customInstructions: JSON.stringify({
2218
+ derivationPrefix,
2219
+ derivationSuffix,
2220
+ recipientIdentityKey: serverIdentityKey
2221
+ })
2222
+ });
2223
+ outputs.push({
2224
+ outputIndex: outputIndex++,
2225
+ protocol: 'wallet payment',
2226
+ paymentRemittance: { derivationPrefix, derivationSuffix, senderIdentityKey }
2227
+ });
2228
+ }
2229
+ // recipient outputs start at index 1 (or 0 if no delivery fee)
2230
+ const anyoneWallet = new ProtoWallet('anyone');
2231
+ const anyoneIdKey = (await anyoneWallet.getPublicKey({ identityKey: true })).publicKey;
2232
+ for (const r of recipients) {
2233
+ const q = perRecipientQuotes.get(r);
2234
+ if (!q || q.recipientFee <= 0)
2235
+ continue;
2236
+ const derivationPrefix = Utils.toBase64(Random(32));
2237
+ const derivationSuffix = Utils.toBase64(Random(32));
2238
+ const { publicKey: recipientDerived } = await anyoneWallet.getPublicKey({
2239
+ protocolID: [2, '3241645161d8'],
2240
+ keyID: `${derivationPrefix} ${derivationSuffix}`,
2241
+ counterparty: r
2242
+ });
2243
+ const lockingScript = new P2PKH().lock(PublicKey.fromString(recipientDerived).toAddress()).toHex();
2244
+ createActionOutputs.push({
2245
+ satoshis: q.recipientFee,
2246
+ lockingScript,
2247
+ outputDescription: `Recipient message fee (${r.slice(0, 8)}…)`,
2248
+ customInstructions: JSON.stringify({
2249
+ derivationPrefix,
2250
+ derivationSuffix,
2251
+ recipientIdentityKey: r
2252
+ })
2253
+ });
2254
+ outputs.push({
2255
+ outputIndex: outputIndex++,
2256
+ protocol: 'wallet payment',
2257
+ paymentRemittance: {
2258
+ derivationPrefix,
2259
+ derivationSuffix,
2260
+ senderIdentityKey: anyoneIdKey
2261
+ }
2262
+ });
2263
+ }
2264
+ const { tx } = await this.walletClient.createAction({
2265
+ description,
2266
+ outputs: createActionOutputs,
2267
+ options: { randomizeOutputs: false, acceptDelayedBroadcast: false }
2268
+ }, this.originator);
2269
+ if (!tx)
2270
+ throw new Error('Failed to create payment transaction');
2271
+ return { tx, outputs, description };
2272
+ }
1933
2273
  }
1934
2274
  //# sourceMappingURL=MessageBoxClient.js.map