@sundaeswap/sprinkles 0.1.1 → 0.2.1

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.
@@ -6,7 +6,7 @@ import {
6
6
  HotWallet,
7
7
  type Wallet,
8
8
  } from "@blaze-cardano/sdk";
9
- import { CborSet, VkeyWitness } from "@blaze-cardano/core";
9
+ import { CborSet, VkeyWitness, blake2b_256, TxCBOR } from "@blaze-cardano/core";
10
10
  import { confirm, input, password, search, select } from "@inquirer/prompts";
11
11
  import {
12
12
  Kind,
@@ -16,6 +16,7 @@ import {
16
16
  type TObject,
17
17
  type TSchema,
18
18
  type TString,
19
+ type TTuple,
19
20
  type TUnion,
20
21
  Type,
21
22
  type TArray,
@@ -44,7 +45,7 @@ const isObject = (t: TSchema): t is TObject => t[Kind] === "Object";
44
45
  const isRef = (t: TSchema): t is TRef => t[Kind] === "Ref";
45
46
  const isString = (t: TSchema): t is TString => t[Kind] === "String";
46
47
  const isThis = (t: TSchema): t is TThis => t[Kind] === "This";
47
- // const isTuple = (t: TSchema): t is TTuple => t[Kind] === "Tuple";
48
+ const isTuple = (t: TSchema): t is TTuple => t[Kind] === "Tuple";
48
49
  const isUnion = (t: TSchema): t is TUnion => t[Kind] === "Union";
49
50
  // const isAny = (t: TSchema): t is TAny => t[Kind] === "Any";
50
51
 
@@ -72,6 +73,16 @@ export interface IProfileEntry {
72
73
  meta: IProfileMeta;
73
74
  }
74
75
 
76
+ export interface TxDialogResult {
77
+ action: "submitted" | "signed" | "cancelled";
78
+ txId?: string; // present only if action === 'submitted'
79
+ tx: Core.Transaction; // the (potentially signed) transaction
80
+ }
81
+
82
+ export interface TxDialogOptions {
83
+ beforeSign?: () => Promise<void>;
84
+ }
85
+
75
86
  export const NetworkSchema = Type.Union([
76
87
  Type.Literal("mainnet"),
77
88
  Type.Literal("preview"),
@@ -803,152 +814,380 @@ export class Sprinkle<S extends TSchema> {
803
814
  this.saveProfile();
804
815
  }
805
816
 
817
+ // --- TxDialog Helpers ---
818
+
819
+ /**
820
+ * Get the payment key hash from a HotWallet's first address
821
+ */
822
+ private async getWalletPaymentKeyHash(
823
+ wallet: HotWallet,
824
+ ): Promise<string | null> {
825
+ try {
826
+ const addresses = await wallet.getUsedAddresses();
827
+ const address = addresses[0];
828
+ if (!address) return null;
829
+ const paymentCredential = address.asBase()?.getPaymentCredential();
830
+ return paymentCredential?.hash?.toString() ?? null;
831
+ } catch {
832
+ return null;
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Count the number of vkey signatures in a transaction's witness set
838
+ */
839
+ private countSignatures(tx: Core.Transaction): number {
840
+ const vkeys = tx.witnessSet().vkeys();
841
+ return vkeys ? vkeys.size() : 0;
842
+ }
843
+
844
+ /**
845
+ * Check if a specific public key has already signed the transaction
846
+ * Compares by vkey (public key bytes)
847
+ */
848
+ private hasVkeySigned(tx: Core.Transaction, vkeyHex: string): boolean {
849
+ const vkeys = tx.witnessSet().vkeys();
850
+ if (!vkeys) return false;
851
+ const vkeyArray = vkeys.toCore();
852
+ return vkeyArray.some(([vkey]) => vkey === vkeyHex);
853
+ }
854
+
855
+ /**
856
+ * Get the list of required signer key hashes from the transaction body
857
+ */
858
+ private getRequiredSigners(tx: Core.Transaction): string[] {
859
+ const requiredSigners = tx.body().requiredSigners();
860
+ if (!requiredSigners) return [];
861
+ return Array.from(requiredSigners.values()).map((s) => s.toString());
862
+ }
863
+
864
+ /**
865
+ * Compute the transaction body hash for display
866
+ */
867
+ private getTxBodyHash(tx: Core.Transaction): string {
868
+ const bodyCbor = tx.body().toCbor();
869
+ return blake2b_256(bodyCbor);
870
+ }
871
+
872
+ /**
873
+ * Format a hash for display: first 8 chars + ... + last 8 chars
874
+ */
875
+ private formatHash(hash: string): string {
876
+ if (hash.length <= 20) return hash;
877
+ return `${hash.slice(0, 8)}...${hash.slice(-8)}`;
878
+ }
879
+
880
+ /**
881
+ * Merge signatures from source transaction into target transaction.
882
+ * Prevents duplicate signatures by comparing vkey (public key).
883
+ * Returns the count of newly added signatures.
884
+ */
885
+ private mergeSignatures(
886
+ target: Core.Transaction,
887
+ source: Core.Transaction,
888
+ ): number {
889
+ const targetWs = target.witnessSet();
890
+ const sourceWs = source.witnessSet();
891
+
892
+ const targetVkeys = targetWs.vkeys()?.toCore() ?? [];
893
+ const sourceVkeys = sourceWs.vkeys()?.toCore() ?? [];
894
+
895
+ // Find vkeys in source that aren't in target (by comparing public key)
896
+ const existingPubKeys = new Set(targetVkeys.map(([vkey]) => vkey));
897
+ const newVkeys = sourceVkeys.filter(
898
+ ([vkey]) => !existingPubKeys.has(vkey),
899
+ );
900
+
901
+ if (newVkeys.length === 0) {
902
+ return 0;
903
+ }
904
+
905
+ // Merge the new vkeys into target
906
+ targetWs.setVkeys(
907
+ CborSet.fromCore([...targetVkeys, ...newVkeys], VkeyWitness.fromCore),
908
+ );
909
+ target.setWitnessSet(targetWs);
910
+
911
+ return newVkeys.length;
912
+ }
913
+
806
914
  async TxDialog<P extends Provider, W extends Wallet>(
807
915
  blaze: Blaze<P, W>,
808
916
  tx: Core.Transaction,
809
- opts?: { beforeSign?: () => Promise<void> },
810
- ): Promise<void> {
811
- const txCbor = tx.toCbor();
917
+ opts?: TxDialogOptions,
918
+ ): Promise<TxDialogResult> {
919
+ let currentTx = tx;
812
920
  let expanded = false;
921
+ let hasSignedThisSession = false;
922
+
923
+ // Check if wallet can sign (is HotWallet)
924
+ const isHotWallet = blaze.wallet instanceof HotWallet;
813
925
 
814
- const showDialog = async (): Promise<void> => {
926
+ // Get wallet's vkeys for detecting if already signed
927
+ let walletVkeys: string[] = [];
928
+ if (isHotWallet) {
929
+ try {
930
+ // Sign a dummy to get the wallet's public keys
931
+ // We'll use this to check if wallet has already signed
932
+ const wallet = blaze.wallet as unknown as HotWallet;
933
+ const addresses = await wallet.getUsedAddresses();
934
+ const address = addresses[0];
935
+ if (address) {
936
+ // We can't easily get vkeys without signing, so we'll track after first sign
937
+ }
938
+ } catch {
939
+ // Ignore errors in setup
940
+ }
941
+ }
942
+
943
+ while (true) {
944
+ // Display transaction status
945
+ const txHash = this.getTxBodyHash(currentTx);
946
+ const sigCount = this.countSignatures(currentTx);
947
+ const requiredSigners = this.getRequiredSigners(currentTx);
948
+
949
+ console.log("");
950
+ console.log(`Transaction: ${this.formatHash(txHash)}`);
951
+ if (requiredSigners.length > 0) {
952
+ console.log(`Signatures: ${sigCount} of ${requiredSigners.length} required`);
953
+ console.log("Required signers:");
954
+ for (const signer of requiredSigners) {
955
+ console.log(` - ${this.formatHash(signer)}`);
956
+ }
957
+ } else {
958
+ console.log(`Signatures: ${sigCount}`);
959
+ }
960
+
961
+ // Display CBOR
962
+ const txCbor = currentTx.toCbor();
815
963
  if (expanded) {
816
964
  console.log("Transaction CBOR:", txCbor);
817
965
  } else {
818
966
  console.log("Transaction CBOR:", `${String(txCbor).slice(0, 50)}...`);
819
967
  }
820
968
 
821
- const menuItems: TMenuItem<S>[] = [];
969
+ // Build dynamic menu choices
970
+ const choices: { name: string; value: string }[] = [];
822
971
 
972
+ // "Sign with this wallet" - only if HotWallet and hasn't signed this session
973
+ if (isHotWallet && !hasSignedThisSession) {
974
+ choices.push({ name: "Sign with this wallet", value: "sign" });
975
+ }
976
+
977
+ // CBOR options
823
978
  if (!expanded) {
824
- menuItems.push({
825
- title: "Expand CBOR",
826
- action: async () => {
827
- expanded = true;
828
- await showDialog();
829
- },
830
- });
979
+ choices.push({ name: "Expand CBOR", value: "expand" });
831
980
  }
981
+ choices.push({ name: "Copy CBOR to clipboard", value: "copy" });
982
+ choices.push({ name: "Import signatures from CBOR", value: "import" });
832
983
 
833
- menuItems.push({
834
- title: "Copy CBOR to clipboard",
835
- action: async () => {
836
- try {
837
- const { default: clipboard } = await import("clipboardy");
838
- clipboard.writeSync(String(txCbor));
839
- console.log("Transaction CBOR copied to clipboard.");
840
- } catch (e) {
841
- console.log("Failed to copy to clipboard, expanding instead.");
842
- expanded = true;
843
- await showDialog();
844
- }
845
- },
984
+ // Submit and cancel
985
+ choices.push({ name: "Submit transaction", value: "submit" });
986
+ choices.push({ name: "Cancel", value: "cancel" });
987
+
988
+ const selection = await select({
989
+ message: "Select an option:",
990
+ choices,
846
991
  });
847
992
 
848
- if (blaze.wallet instanceof HotWallet) {
849
- menuItems.push({
850
- title: "Sign and submit transaction",
851
- action: async () => {
852
- if (opts?.beforeSign) {
853
- await opts.beforeSign();
854
- }
993
+ // Handle selection
994
+ if (selection === "sign") {
995
+ if (opts?.beforeSign) {
996
+ await opts.beforeSign();
997
+ }
855
998
 
856
- // Detect if stake key signature is required
857
- let needsStakeKey = false;
858
- try {
859
- const wallet = blaze.wallet as unknown as HotWallet;
860
- const addresses = await wallet.getUsedAddresses();
861
- const userAddress = addresses[0];
862
- if (userAddress) {
863
- const stakeCredential = userAddress
864
- .asBase()
865
- ?.getStakeCredential();
866
- const stakeKeyHash = stakeCredential?.hash?.toString();
867
-
868
- if (stakeKeyHash) {
869
- const requiredSigners = tx.body().requiredSigners();
870
- if (requiredSigners) {
871
- const signerArray = Array.from(requiredSigners.values());
872
- needsStakeKey = signerArray.some(
873
- (signer) => signer.toString() === stakeKeyHash,
874
- );
875
- }
876
-
877
- const certs = tx.body().certs();
878
- const hasCertificates = certs && certs.size() > 0;
879
- const withdrawals = tx.body().withdrawals();
880
- const hasWithdrawals = withdrawals && withdrawals.size > 0;
881
-
882
- if (hasCertificates || hasWithdrawals) {
883
- needsStakeKey = true;
884
- }
885
- }
999
+ // Detect if stake key signature is required
1000
+ let needsStakeKey = false;
1001
+ try {
1002
+ const wallet = blaze.wallet as unknown as HotWallet;
1003
+ const addresses = await wallet.getUsedAddresses();
1004
+ const userAddress = addresses[0];
1005
+ if (userAddress) {
1006
+ const stakeCredential = userAddress.asBase()?.getStakeCredential();
1007
+ const stakeKeyHash = stakeCredential?.hash?.toString();
1008
+
1009
+ if (stakeKeyHash) {
1010
+ const reqSigners = currentTx.body().requiredSigners();
1011
+ if (reqSigners) {
1012
+ const signerArray = Array.from(reqSigners.values());
1013
+ needsStakeKey = signerArray.some(
1014
+ (signer) => signer.toString() === stakeKeyHash,
1015
+ );
886
1016
  }
887
1017
 
888
- if (needsStakeKey) {
889
- console.log("Transaction requires stake key signature.");
890
- } else {
891
- console.log("Transaction requires payment key signature only.");
1018
+ const certs = currentTx.body().certs();
1019
+ const hasCertificates = certs && certs.size() > 0;
1020
+ const withdrawals = currentTx.body().withdrawals();
1021
+ const hasWithdrawals = withdrawals && withdrawals.size > 0;
1022
+
1023
+ if (hasCertificates || hasWithdrawals) {
1024
+ needsStakeKey = true;
892
1025
  }
893
- } catch (error) {
894
- console.warn(
895
- "Could not determine stake key requirement, signing with payment key only.",
896
- );
897
- console.warn(`Error: ${(error as Error).message}`);
898
1026
  }
1027
+ }
899
1028
 
900
- let signedTx;
901
-
902
- if (needsStakeKey) {
903
- const signed = await (
904
- blaze.wallet as unknown as HotWallet
905
- ).signTransaction(tx, true, true);
906
- const ws = tx.witnessSet();
907
- const vkeys = ws.vkeys()?.toCore() ?? [];
1029
+ if (needsStakeKey) {
1030
+ console.log("Transaction requires stake key signature.");
1031
+ } else {
1032
+ console.log("Transaction requires payment key signature only.");
1033
+ }
1034
+ } catch (error) {
1035
+ console.warn(
1036
+ "Could not determine stake key requirement, signing with payment key only.",
1037
+ );
1038
+ console.warn(`Error: ${(error as Error).message}`);
1039
+ }
908
1040
 
909
- const signedKeys = signed.vkeys();
910
- if (!signedKeys) {
911
- throw new Error(
912
- "signTransaction: no signed keys in wallet witness response",
913
- );
914
- }
1041
+ try {
1042
+ if (needsStakeKey) {
1043
+ const wallet = blaze.wallet as unknown as HotWallet;
1044
+ const signed = await wallet.signTransaction(currentTx, true, true);
1045
+ const ws = currentTx.witnessSet();
1046
+ const existingVkeys = ws.vkeys()?.toCore() ?? [];
1047
+
1048
+ const signedKeys = signed.vkeys();
1049
+ if (!signedKeys) {
1050
+ throw new Error(
1051
+ "signTransaction: no signed keys in wallet witness response",
1052
+ );
1053
+ }
915
1054
 
916
- if (
917
- signedKeys
918
- .toCore()
919
- .some(([vkey]) => vkeys.some(([key2]) => vkey === key2))
920
- ) {
921
- throw new Error(
922
- "signTransaction: some keys were already signed",
923
- );
924
- }
1055
+ // Check for duplicates before adding
1056
+ const newSignedKeys = signedKeys.toCore();
1057
+ const existingPubKeys = new Set(existingVkeys.map(([vkey]) => vkey));
1058
+ const uniqueNewKeys = newSignedKeys.filter(
1059
+ ([vkey]) => !existingPubKeys.has(vkey),
1060
+ );
925
1061
 
1062
+ if (uniqueNewKeys.length === 0) {
1063
+ console.log("Wallet has already signed this transaction.");
1064
+ } else {
926
1065
  ws.setVkeys(
927
1066
  CborSet.fromCore(
928
- [...signedKeys.toCore(), ...vkeys],
1067
+ [...existingVkeys, ...uniqueNewKeys],
929
1068
  VkeyWitness.fromCore,
930
1069
  ),
931
1070
  );
932
- tx.setWitnessSet(ws);
933
- signedTx = tx;
1071
+ currentTx.setWitnessSet(ws);
1072
+ console.log(`Added ${uniqueNewKeys.length} signature(s).`);
1073
+ hasSignedThisSession = true;
1074
+ }
1075
+ } else {
1076
+ const signedTx = await blaze.signTransaction(currentTx);
1077
+ // Merge signatures from signed tx into current tx
1078
+ const added = this.mergeSignatures(currentTx, signedTx);
1079
+ if (added > 0) {
1080
+ console.log(`Added ${added} signature(s).`);
1081
+ hasSignedThisSession = true;
934
1082
  } else {
935
- signedTx = await blaze.signTransaction(tx);
1083
+ console.log("Wallet has already signed this transaction.");
936
1084
  }
1085
+ }
1086
+ } catch (error) {
1087
+ console.error(`Signing failed: ${(error as Error).message}`);
1088
+ }
1089
+ // Continue loop after signing
1090
+ continue;
1091
+ }
937
1092
 
938
- const txId = await blaze.submitTransaction(signedTx);
939
- console.log(`Transaction submitted: ${txId}`);
940
- },
1093
+ if (selection === "expand") {
1094
+ expanded = true;
1095
+ continue;
1096
+ }
1097
+
1098
+ if (selection === "copy") {
1099
+ try {
1100
+ const { default: clipboard } = await import("clipboardy");
1101
+ clipboard.writeSync(String(currentTx.toCbor()));
1102
+ console.log("Transaction CBOR copied to clipboard.");
1103
+ } catch {
1104
+ console.log("Failed to copy to clipboard, expanding instead.");
1105
+ expanded = true;
1106
+ }
1107
+ continue;
1108
+ }
1109
+
1110
+ if (selection === "import") {
1111
+ const cborInput = await input({
1112
+ message: "Paste transaction CBOR (hex):",
941
1113
  });
1114
+
1115
+ if (!cborInput || cborInput.trim() === "") {
1116
+ console.log("No CBOR provided.");
1117
+ continue;
1118
+ }
1119
+
1120
+ try {
1121
+ const importedTx = Core.Transaction.fromCbor(
1122
+ TxCBOR(cborInput.trim()),
1123
+ );
1124
+
1125
+ // Validate body hash matches
1126
+ const currentHash = this.getTxBodyHash(currentTx);
1127
+ const importedHash = this.getTxBodyHash(importedTx);
1128
+
1129
+ if (currentHash !== importedHash) {
1130
+ const proceed = await confirm({
1131
+ message: `Warning: Imported transaction has different body hash.\nCurrent: ${this.formatHash(currentHash)}\nImported: ${this.formatHash(importedHash)}\nProceed anyway?`,
1132
+ default: false,
1133
+ });
1134
+ if (!proceed) {
1135
+ console.log("Import cancelled.");
1136
+ continue;
1137
+ }
1138
+ }
1139
+
1140
+ // Merge signatures
1141
+ const added = this.mergeSignatures(currentTx, importedTx);
1142
+ const sourceVkeys = importedTx.witnessSet().vkeys();
1143
+ const sourceCount = sourceVkeys ? sourceVkeys.size() : 0;
1144
+ const skipped = sourceCount - added;
1145
+
1146
+ if (added > 0) {
1147
+ console.log(`Added ${added} new signature(s).`);
1148
+ }
1149
+ if (skipped > 0) {
1150
+ console.log(`Skipped ${skipped} duplicate signature(s).`);
1151
+ }
1152
+ if (added === 0 && skipped === 0) {
1153
+ console.log("No signatures found in imported transaction.");
1154
+ }
1155
+ } catch (error) {
1156
+ console.error(`Failed to import CBOR: ${(error as Error).message}`);
1157
+ }
1158
+ continue;
942
1159
  }
943
1160
 
944
- const txMenu: IMenu<S> = {
945
- title: "Transaction Menu",
946
- items: menuItems,
947
- };
948
- await this._showMenu(txMenu, false);
949
- };
1161
+ if (selection === "submit") {
1162
+ const sigCount = this.countSignatures(currentTx);
1163
+ if (sigCount === 0) {
1164
+ const proceed = await confirm({
1165
+ message: "Warning: Transaction has no signatures. Submit anyway?",
1166
+ default: false,
1167
+ });
1168
+ if (!proceed) {
1169
+ continue;
1170
+ }
1171
+ }
1172
+
1173
+ try {
1174
+ const txId = await blaze.submitTransaction(currentTx);
1175
+ console.log(`Transaction submitted: ${txId}`);
1176
+ return { action: "submitted", txId, tx: currentTx };
1177
+ } catch (error) {
1178
+ console.error(`Submit failed: ${(error as Error).message}`);
1179
+ // Continue loop to allow retry or other actions
1180
+ continue;
1181
+ }
1182
+ }
950
1183
 
951
- await showDialog();
1184
+ if (selection === "cancel") {
1185
+ if (hasSignedThisSession) {
1186
+ return { action: "signed", tx: currentTx };
1187
+ }
1188
+ return { action: "cancelled", tx: currentTx };
1189
+ }
1190
+ }
952
1191
  }
953
1192
 
954
1193
  async EditStruct<U extends TSchema>(
@@ -1163,6 +1402,22 @@ export class Sprinkle<S extends TSchema> {
1163
1402
  return arr as TExact<U>;
1164
1403
  }
1165
1404
 
1405
+ if (isTuple(type)) {
1406
+ const items = type.items ?? [];
1407
+ const result: unknown[] = [];
1408
+ for (let i = 0; i < items.length; i++) {
1409
+ const itemType = items[i] as U;
1410
+ const value = await this._fillInStruct(
1411
+ itemType,
1412
+ path.concat([`[${i}]`]),
1413
+ defs,
1414
+ def ? ((def as unknown[])[i] as TExact<U>) : undefined,
1415
+ );
1416
+ result.push(value);
1417
+ }
1418
+ return result as TExact<U>;
1419
+ }
1420
+
1166
1421
  throw new Error(
1167
1422
  `Unable to fill in struct for type at path ${path.join(".")}`,
1168
1423
  );