@openzeppelin/miden-multisig-client 0.14.6 → 0.14.9-rc.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 +114 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/multisig/config.d.ts +8 -0
- package/dist/multisig/config.d.ts.map +1 -0
- package/dist/multisig/config.js +8 -0
- package/dist/multisig/config.js.map +1 -0
- package/dist/multisig/consumeNotesErrors.d.ts +48 -0
- package/dist/multisig/consumeNotesErrors.d.ts.map +1 -0
- package/dist/multisig/consumeNotesErrors.js +58 -0
- package/dist/multisig/consumeNotesErrors.js.map +1 -0
- package/dist/multisig/consumeNotesErrors.test.d.ts +2 -0
- package/dist/multisig/consumeNotesErrors.test.d.ts.map +1 -0
- package/dist/multisig/consumeNotesErrors.test.js +28 -0
- package/dist/multisig/consumeNotesErrors.test.js.map +1 -0
- package/dist/multisig/proposal/execution.js +3 -2
- package/dist/multisig/proposal/execution.js.map +1 -1
- package/dist/multisig.d.ts +28 -5
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +235 -8
- package/dist/multisig.js.map +1 -1
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +18 -5
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/proposal/metadata.test.d.ts +2 -0
- package/dist/proposal/metadata.test.d.ts.map +1 -0
- package/dist/proposal/metadata.test.js +87 -0
- package/dist/proposal/metadata.test.js.map +1 -0
- package/dist/transaction/consumeNotes.d.ts +13 -1
- package/dist/transaction/consumeNotes.d.ts.map +1 -1
- package/dist/transaction/consumeNotes.js +30 -13
- package/dist/transaction/consumeNotes.js.map +1 -1
- package/dist/types/proposal.d.ts +26 -5
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js +10 -1
- package/dist/types/proposal.js.map +1 -1
- package/dist/types/proposal.test.d.ts +2 -0
- package/dist/types/proposal.test.d.ts.map +1 -0
- package/dist/types/proposal.test.js +56 -0
- package/dist/types/proposal.test.js.map +1 -0
- package/dist/utils/encoding.d.ts +8 -0
- package/dist/utils/encoding.d.ts.map +1 -1
- package/dist/utils/encoding.js +8 -0
- package/dist/utils/encoding.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +24 -0
- package/src/multisig/config.ts +7 -0
- package/src/multisig/consumeNotesErrors.test.ts +43 -0
- package/src/multisig/consumeNotesErrors.ts +74 -0
- package/src/multisig.ts +341 -12
- package/src/proposal/metadata.test.ts +99 -0
- package/src/proposal/metadata.ts +19 -6
- package/src/transaction/consumeNotes.ts +40 -17
- package/src/types/proposal.test.ts +68 -0
- package/src/types/proposal.ts +42 -9
- package/src/utils/encoding.ts +13 -0
- package/src/multisig/proposal/execution.ts +0 -385
- package/src/multisig/proposal/parser.ts +0 -191
package/src/multisig.ts
CHANGED
|
@@ -13,13 +13,13 @@ import type {
|
|
|
13
13
|
NoteAsset,
|
|
14
14
|
Proposal,
|
|
15
15
|
ProposalMetadata,
|
|
16
|
+
ProposalSignatureEntry,
|
|
16
17
|
ProposalType,
|
|
17
18
|
} from './types.js';
|
|
18
19
|
import type { ProcedureName } from './procedures.js';
|
|
19
20
|
import type {
|
|
20
21
|
MidenClient,
|
|
21
22
|
TransactionProver,
|
|
22
|
-
TransactionRequest,
|
|
23
23
|
WasmWebClient,
|
|
24
24
|
} from '@miden-sdk/miden-sdk';
|
|
25
25
|
import {
|
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
AdviceMap,
|
|
29
29
|
Endpoint,
|
|
30
30
|
FeltArray,
|
|
31
|
+
Note,
|
|
31
32
|
RpcClient,
|
|
32
33
|
Signature,
|
|
34
|
+
TransactionRequest,
|
|
33
35
|
TransactionSummary,
|
|
34
36
|
Word,
|
|
35
37
|
} from '@miden-sdk/miden-sdk';
|
|
@@ -41,6 +43,19 @@ import {
|
|
|
41
43
|
buildConsumeNotesTransactionRequest,
|
|
42
44
|
buildP2idTransactionRequest,
|
|
43
45
|
} from './transaction.js';
|
|
46
|
+
import { buildConsumeNotesTransactionRequestFromNotes } from './transaction/consumeNotes.js';
|
|
47
|
+
import {
|
|
48
|
+
CONSUME_NOTES_METADATA_VERSION_V2,
|
|
49
|
+
MAX_CONSUME_NOTES_METADATA_BYTES,
|
|
50
|
+
} from './types/proposal.js';
|
|
51
|
+
import { LEGACY_CONSUME_NOTES_ENABLED } from './multisig/config.js';
|
|
52
|
+
import {
|
|
53
|
+
ConsumeNotesMetadataOversizeError,
|
|
54
|
+
LegacyConsumeNotesNoteMissingError,
|
|
55
|
+
NoteBindingMismatchError,
|
|
56
|
+
UnsupportedMetadataVersionError,
|
|
57
|
+
} from './multisig/consumeNotesErrors.js';
|
|
58
|
+
import { noteFromBase64, noteToBase64 } from './utils/encoding.js';
|
|
44
59
|
import {
|
|
45
60
|
base64ToUint8Array,
|
|
46
61
|
uint8ArrayToBase64,
|
|
@@ -83,6 +98,32 @@ export interface AccountStateVerificationResult {
|
|
|
83
98
|
/**
|
|
84
99
|
* Represents a multisig account with GUARDIAN integration.
|
|
85
100
|
*/
|
|
101
|
+
const BUILTIN_PROPOSAL_TYPES = new Set<string>([
|
|
102
|
+
'add_signer',
|
|
103
|
+
'remove_signer',
|
|
104
|
+
'change_threshold',
|
|
105
|
+
'update_procedure_threshold',
|
|
106
|
+
'switch_guardian',
|
|
107
|
+
'consume_notes',
|
|
108
|
+
'p2id',
|
|
109
|
+
// Reserved: the SDK's internal bucket name for unmodeled types. A producer
|
|
110
|
+
// must not use it as a custom label, or it would collide with the bucket.
|
|
111
|
+
'custom',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Deserialize producer-supplied transaction request bytes, wrapping any failure
|
|
116
|
+
* in a stable message that mirrors the Rust SDK's `deserialize_transaction_request`.
|
|
117
|
+
*/
|
|
118
|
+
function deserializeTransactionRequest(bytes: Uint8Array): TransactionRequest {
|
|
119
|
+
try {
|
|
120
|
+
return TransactionRequest.deserialize(bytes);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
123
|
+
throw new Error(`failed to decode transaction request: ${detail}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
86
127
|
export class Multisig {
|
|
87
128
|
account: Account;
|
|
88
129
|
threshold: number;
|
|
@@ -728,7 +769,19 @@ export class Multisig {
|
|
|
728
769
|
throw new Error('At least one note ID is required');
|
|
729
770
|
}
|
|
730
771
|
|
|
731
|
-
|
|
772
|
+
// Fetch notes locally (proposer has them per FR-012); embed for v2 verification.
|
|
773
|
+
const rawClient = await getRawMidenClient(webClient);
|
|
774
|
+
const fetchedNotes: Note[] = [];
|
|
775
|
+
for (const noteIdHex of noteIds) {
|
|
776
|
+
const inputNoteRecord = await rawClient.getInputNote(noteIdHex);
|
|
777
|
+
if (!inputNoteRecord) {
|
|
778
|
+
throw new LegacyConsumeNotesNoteMissingError(noteIdHex);
|
|
779
|
+
}
|
|
780
|
+
fetchedNotes.push(inputNoteRecord.toNote());
|
|
781
|
+
}
|
|
782
|
+
const embeddedNotes = fetchedNotes.map((n) => noteToBase64(n));
|
|
783
|
+
|
|
784
|
+
const { request, salt } = buildConsumeNotesTransactionRequestFromNotes(fetchedNotes);
|
|
732
785
|
|
|
733
786
|
const summary = await executeForSummary(webClient, this._accountId, request);
|
|
734
787
|
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
@@ -737,11 +790,23 @@ export class Multisig {
|
|
|
737
790
|
const metadata: ProposalMetadata = {
|
|
738
791
|
proposalType: 'consume_notes',
|
|
739
792
|
noteIds,
|
|
793
|
+
metadataVersion: CONSUME_NOTES_METADATA_VERSION_V2,
|
|
794
|
+
notes: embeddedNotes,
|
|
740
795
|
saltHex: salt.toHex(),
|
|
741
796
|
requiredSignatures: this.getEffectiveThreshold('consume_notes'),
|
|
742
797
|
description: `Consume ${noteIds.length} note(s)`,
|
|
743
798
|
};
|
|
744
799
|
|
|
800
|
+
// FR-011: enforce metadata size cap on the wire-encoded form (what GUARDIAN
|
|
801
|
+
// actually persists), matching the Rust side which measures
|
|
802
|
+
// `ProposalMetadataPayload`. Sizing the local in-memory `metadata` would
|
|
803
|
+
// miss codec divergence.
|
|
804
|
+
const encoded = ProposalMetadataCodec.toGuardian(metadata);
|
|
805
|
+
const metadataSize = new TextEncoder().encode(JSON.stringify(encoded)).length;
|
|
806
|
+
if (metadataSize > MAX_CONSUME_NOTES_METADATA_BYTES) {
|
|
807
|
+
throw new ConsumeNotesMetadataOversizeError(MAX_CONSUME_NOTES_METADATA_BYTES, metadataSize);
|
|
808
|
+
}
|
|
809
|
+
|
|
745
810
|
return this.createProposal(proposalNonce, summaryBase64, metadata);
|
|
746
811
|
}
|
|
747
812
|
|
|
@@ -939,6 +1004,220 @@ export class Multisig {
|
|
|
939
1004
|
proposal.status = 'finalized';
|
|
940
1005
|
}
|
|
941
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Submit an integration-built transaction (advice already injected). Mirrors
|
|
1009
|
+
* the Rust `submit_transaction`; used by the custom proposal producer flow
|
|
1010
|
+
* after `prepareCustomExecution` rebuilds its request with the returned advice.
|
|
1011
|
+
*/
|
|
1012
|
+
async submitTransaction(request: TransactionRequest): Promise<void> {
|
|
1013
|
+
await this.midenClient.transactions.submit(AccountId.fromHex(this._accountId), request);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Create a proposal from a producer-built transaction the SDK does not model
|
|
1018
|
+
* (issue #266 producer API). `transactionRequestBytes` is a serialized TransactionRequest;
|
|
1019
|
+
* `proposalType` is a free-form, non-empty label that must not collide with a
|
|
1020
|
+
* built-in type. The integration keeps its own recipe to execute later via
|
|
1021
|
+
* `prepareCustomExecution`.
|
|
1022
|
+
*/
|
|
1023
|
+
async createCustomProposal(
|
|
1024
|
+
transactionRequestBytes: Uint8Array,
|
|
1025
|
+
proposalType: string,
|
|
1026
|
+
nonce?: number,
|
|
1027
|
+
): Promise<Proposal> {
|
|
1028
|
+
const label = proposalType.trim().toLowerCase();
|
|
1029
|
+
if (label.length === 0) {
|
|
1030
|
+
throw new Error('proposalType must not be empty');
|
|
1031
|
+
}
|
|
1032
|
+
if (!/^[a-z0-9_]+$/.test(label)) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
`proposalType '${label}' must be lowercase snake_case ([a-z0-9_]): no spaces, hyphens, or other characters`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
if (BUILTIN_PROPOSAL_TYPES.has(label)) {
|
|
1038
|
+
throw new Error(
|
|
1039
|
+
`'${label}' is a built-in proposal type; use the typed proposal API instead`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const webClient = await this.getRawClient();
|
|
1044
|
+
const request = deserializeTransactionRequest(transactionRequestBytes);
|
|
1045
|
+
const summary = await executeForSummary(webClient, this._accountId, request);
|
|
1046
|
+
const summaryBase64 = uint8ArrayToBase64(summary.serialize());
|
|
1047
|
+
const proposalNonce = nonce ?? Date.now();
|
|
1048
|
+
|
|
1049
|
+
const metadata: ProposalMetadata = {
|
|
1050
|
+
proposalType: 'custom',
|
|
1051
|
+
description: '',
|
|
1052
|
+
rawProposalType: label,
|
|
1053
|
+
requiredSignatures: this.getEffectiveThreshold('custom'),
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
return this.createProposal(proposalNonce, summaryBase64, metadata);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Assemble the validated execution advice (cosigner signatures + GUARDIAN
|
|
1061
|
+
* acknowledgment) for a ready custom proposal, so an integration can rebuild
|
|
1062
|
+
* its transaction with its own recipe and submit (issue #266 producer API).
|
|
1063
|
+
*
|
|
1064
|
+
* `transactionRequestBytes` is the serialized transaction request; it is used only to verify
|
|
1065
|
+
* (binding check) that it reproduces the signed commitment, before the
|
|
1066
|
+
* acknowledgment is requested. Returns the advice the integration folds into
|
|
1067
|
+
* its rebuilt transaction (`builder.extendAdviceMap(advice)`).
|
|
1068
|
+
*/
|
|
1069
|
+
async prepareCustomExecution(
|
|
1070
|
+
proposalId: string,
|
|
1071
|
+
transactionRequestBytes: Uint8Array,
|
|
1072
|
+
): Promise<AdviceMap> {
|
|
1073
|
+
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
1074
|
+
const delta = await this.guardian.getDeltaProposal(this._accountId, normalizedProposalId);
|
|
1075
|
+
const existing = this.getLocalProposal(proposalId);
|
|
1076
|
+
const proposal = this.proposalFactory().fromDelta(
|
|
1077
|
+
delta,
|
|
1078
|
+
normalizedProposalId,
|
|
1079
|
+
existing?.metadata,
|
|
1080
|
+
existing?.signatures ?? [],
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
if (proposal.metadata.proposalType !== 'custom') {
|
|
1084
|
+
throw new Error(
|
|
1085
|
+
'prepareCustomExecution is only for custom proposals; use executeProposal for built-in types',
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const effectiveThreshold = this.getEffectiveThreshold('custom');
|
|
1090
|
+
const signaturesForExecution = new ProposalSignatures(
|
|
1091
|
+
proposal.signatures,
|
|
1092
|
+
this.signerCommitments,
|
|
1093
|
+
`Invalid proposal signatures for ${proposalId}`,
|
|
1094
|
+
).entries();
|
|
1095
|
+
if (signaturesForExecution.length < effectiveThreshold) {
|
|
1096
|
+
throw new Error(
|
|
1097
|
+
`Proposal is not ready for execution: have ${signaturesForExecution.length} of ${effectiveThreshold} required signatures.`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const txSummary = TransactionSummary.deserialize(
|
|
1102
|
+
base64ToUint8Array(delta.deltaPayload.txSummary.data),
|
|
1103
|
+
);
|
|
1104
|
+
const signedCommitmentHex = normalizeHexWord(txSummary.toCommitment().toHex());
|
|
1105
|
+
|
|
1106
|
+
const bindingRequest = deserializeTransactionRequest(transactionRequestBytes);
|
|
1107
|
+
|
|
1108
|
+
const webClient = await this.getRawClient();
|
|
1109
|
+
const derived = await executeForSummary(webClient, this._accountId, bindingRequest);
|
|
1110
|
+
const derivedCommitmentHex = normalizeHexWord(derived.toCommitment().toHex());
|
|
1111
|
+
if (derivedCommitmentHex !== signedCommitmentHex) {
|
|
1112
|
+
throw new Error(
|
|
1113
|
+
`Custom proposal binding mismatch: expected ${signedCommitmentHex}, got ${derivedCommitmentHex}`,
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return this.assembleCustomAdvice(
|
|
1118
|
+
proposalId,
|
|
1119
|
+
signaturesForExecution,
|
|
1120
|
+
signedCommitmentHex,
|
|
1121
|
+
delta,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private async assembleCustomAdvice(
|
|
1126
|
+
proposalId: string,
|
|
1127
|
+
signaturesForExecution: ProposalSignatureEntry[],
|
|
1128
|
+
normalizedTxCommitmentHex: string,
|
|
1129
|
+
delta: DeltaObject,
|
|
1130
|
+
): Promise<AdviceMap> {
|
|
1131
|
+
const normalizedSignerCommitments = new Set(
|
|
1132
|
+
this.signerCommitments.map((commitment) => normalizeHexWord(commitment)),
|
|
1133
|
+
);
|
|
1134
|
+
const adviceMap = new AdviceMap();
|
|
1135
|
+
const adviceMapKeys = new Set<string>();
|
|
1136
|
+
const createTxCommitmentWord = (): Word => Word.fromHex(normalizedTxCommitmentHex);
|
|
1137
|
+
|
|
1138
|
+
for (const cosignerSig of signaturesForExecution) {
|
|
1139
|
+
let signerCommitmentHex = normalizeHexWord(cosignerSig.signerId);
|
|
1140
|
+
const ecdsaPublicKey =
|
|
1141
|
+
cosignerSig.signature.scheme === 'ecdsa' ? cosignerSig.signature.publicKey : undefined;
|
|
1142
|
+
|
|
1143
|
+
if (cosignerSig.signature.scheme === 'ecdsa') {
|
|
1144
|
+
if (!ecdsaPublicKey) {
|
|
1145
|
+
throw new Error(
|
|
1146
|
+
`ECDSA proposal signature for ${signerCommitmentHex} is missing publicKey`,
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const derivedCommitment = tryComputeEcdsaCommitmentHex(ecdsaPublicKey);
|
|
1150
|
+
if (derivedCommitment && derivedCommitment !== signerCommitmentHex) {
|
|
1151
|
+
if (!normalizedSignerCommitments.has(derivedCommitment)) {
|
|
1152
|
+
throw new Error(
|
|
1153
|
+
`ECDSA public key commitment mismatch: derived commitment ${derivedCommitment} is not in signerCommitments.`,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
signerCommitmentHex = derivedCommitment;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const signerCommitment = Word.fromHex(signerCommitmentHex);
|
|
1161
|
+
const sigBytes = signatureHexToBytes(
|
|
1162
|
+
cosignerSig.signature.signature,
|
|
1163
|
+
cosignerSig.signature.scheme,
|
|
1164
|
+
);
|
|
1165
|
+
const signature = Signature.deserialize(sigBytes);
|
|
1166
|
+
const { key, values } = buildSignatureAdviceEntry(
|
|
1167
|
+
signerCommitment,
|
|
1168
|
+
createTxCommitmentWord(),
|
|
1169
|
+
signature,
|
|
1170
|
+
ecdsaPublicKey,
|
|
1171
|
+
cosignerSig.signature.scheme === 'ecdsa' ? cosignerSig.signature.signature : undefined,
|
|
1172
|
+
);
|
|
1173
|
+
const keyHex = normalizeHexWord(key.toHex());
|
|
1174
|
+
if (adviceMapKeys.has(keyHex)) {
|
|
1175
|
+
throw new Error(`Duplicate advice-map key detected for proposal ${proposalId}`);
|
|
1176
|
+
}
|
|
1177
|
+
adviceMapKeys.add(keyHex);
|
|
1178
|
+
adviceMap.insert(key, new FeltArray(values));
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const executionDelta = { ...delta, deltaPayload: delta.deltaPayload.txSummary };
|
|
1182
|
+
const pushResult = await this.guardian.pushDelta(executionDelta);
|
|
1183
|
+
const ackSigHex = pushResult.ackSig;
|
|
1184
|
+
if (!ackSigHex) {
|
|
1185
|
+
throw new Error('GUARDIAN did not return acknowledgment signature');
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const guardianCommitment = Word.fromHex(normalizeHexWord(this.guardianCommitment));
|
|
1189
|
+
const ackScheme = (pushResult.ackScheme as 'ecdsa' | 'falcon') || this.signer.scheme;
|
|
1190
|
+
const ackPubkey = pushResult.ackPubkey || this.guardianPublicKey;
|
|
1191
|
+
if (ackScheme === 'ecdsa' && !ackPubkey) {
|
|
1192
|
+
throw new Error('GUARDIAN acknowledgment is missing ECDSA public key');
|
|
1193
|
+
}
|
|
1194
|
+
if (ackScheme === 'ecdsa' && ackPubkey) {
|
|
1195
|
+
const derivedCommitment = tryComputeEcdsaCommitmentHex(ackPubkey);
|
|
1196
|
+
if (derivedCommitment && derivedCommitment !== normalizeHexWord(this.guardianCommitment)) {
|
|
1197
|
+
throw new Error('GUARDIAN public key commitment mismatch');
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const ackSigBytes = signatureHexToBytes(ackSigHex, ackScheme);
|
|
1201
|
+
const ackSignature = Signature.deserialize(ackSigBytes);
|
|
1202
|
+
const { key: ackKey, values: ackValues } = buildSignatureAdviceEntry(
|
|
1203
|
+
guardianCommitment,
|
|
1204
|
+
createTxCommitmentWord(),
|
|
1205
|
+
ackSignature,
|
|
1206
|
+
ackScheme === 'ecdsa' ? ackPubkey : undefined,
|
|
1207
|
+
ackScheme === 'ecdsa' ? ackSigHex : undefined,
|
|
1208
|
+
);
|
|
1209
|
+
const ackKeyHex = normalizeHexWord(ackKey.toHex());
|
|
1210
|
+
if (adviceMapKeys.has(ackKeyHex)) {
|
|
1211
|
+
throw new Error(
|
|
1212
|
+
`Duplicate advice-map key detected for GUARDIAN acknowledgment in proposal ${proposalId}`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
adviceMapKeys.add(ackKeyHex);
|
|
1216
|
+
adviceMap.insert(ackKey, new FeltArray(ackValues));
|
|
1217
|
+
|
|
1218
|
+
return adviceMap;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
942
1221
|
private getLocalProposal(proposalId: string): Proposal | undefined {
|
|
943
1222
|
const normalizedProposalId = normalizeHexWord(proposalId);
|
|
944
1223
|
return this.proposals.get(proposalId) ?? this.proposals.get(normalizedProposalId);
|
|
@@ -956,6 +1235,15 @@ export class Multisig {
|
|
|
956
1235
|
await this.verifyProposalMetadataBinding(proposal);
|
|
957
1236
|
|
|
958
1237
|
const metadata = proposal.metadata;
|
|
1238
|
+
// Reject custom proposals before any advice assembly or GUARDIAN ack push:
|
|
1239
|
+
// the SDK cannot rebuild an opaque custom transaction, and the rejection
|
|
1240
|
+
// must stay side-effect free (mirrors the Rust early guard in execute_proposal).
|
|
1241
|
+
if (metadata.proposalType === 'custom') {
|
|
1242
|
+
throw new Error(
|
|
1243
|
+
'Cannot execute a custom proposal via executeProposal; use prepareCustomExecution to ' +
|
|
1244
|
+
'get the cosigner + GUARDIAN advice, then submitTransaction with your rebuilt request (issue #266).',
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
959
1247
|
const effectiveThreshold = this.getEffectiveThreshold(metadata.proposalType);
|
|
960
1248
|
const signatureContext = `Invalid proposal signatures for ${proposalId}`;
|
|
961
1249
|
const signaturesForExecution = new ProposalSignatures(
|
|
@@ -1261,8 +1549,11 @@ export class Multisig {
|
|
|
1261
1549
|
|
|
1262
1550
|
private async verifyProposalMetadataBinding(proposal: Proposal): Promise<string> {
|
|
1263
1551
|
const txSummaryCommitment = this.ensureProposalCommitmentMatchesSummary(proposal);
|
|
1264
|
-
if (proposal.metadata.proposalType === '
|
|
1265
|
-
|
|
1552
|
+
if (proposal.metadata.proposalType === 'custom') {
|
|
1553
|
+
// Custom proposals (issue #266) have no per-type reconstruction recipe;
|
|
1554
|
+
// the id ↔ tx_summary commitment match above is the only available
|
|
1555
|
+
// integrity guarantee for an opaque proposal.
|
|
1556
|
+
return txSummaryCommitment;
|
|
1266
1557
|
}
|
|
1267
1558
|
|
|
1268
1559
|
const summary = TransactionSummary.deserialize(base64ToUint8Array(proposal.txSummary));
|
|
@@ -1319,12 +1610,48 @@ export class Multisig {
|
|
|
1319
1610
|
return request;
|
|
1320
1611
|
}
|
|
1321
1612
|
case 'consume_notes': {
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1613
|
+
// v1/v2 dispatch for issue #229 / FR-009.
|
|
1614
|
+
const version = metadata.metadataVersion;
|
|
1615
|
+
if (version === CONSUME_NOTES_METADATA_VERSION_V2) {
|
|
1616
|
+
const embedded = metadata.notes ?? [];
|
|
1617
|
+
if (embedded.length !== metadata.noteIds.length) {
|
|
1618
|
+
throw new NoteBindingMismatchError(
|
|
1619
|
+
`consume_notes v2: notes.length=${embedded.length} does not match noteIds.length=${metadata.noteIds.length}`,
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
const decoded: Note[] = [];
|
|
1623
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
1624
|
+
const note = noteFromBase64(embedded[i], Note);
|
|
1625
|
+
// Normalize both sides; matches the file's other hex comparisons.
|
|
1626
|
+
const embeddedId = normalizeHexWord(note.id().toString());
|
|
1627
|
+
const declaredId = normalizeHexWord(metadata.noteIds[i]);
|
|
1628
|
+
if (embeddedId !== declaredId) {
|
|
1629
|
+
throw new NoteBindingMismatchError(
|
|
1630
|
+
`consume_notes v2: notes[${i}] id ${embeddedId} != noteIds[${i}] ${declaredId}`,
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
decoded.push(note);
|
|
1634
|
+
}
|
|
1635
|
+
const { request } = buildConsumeNotesTransactionRequestFromNotes(decoded, {
|
|
1636
|
+
salt,
|
|
1637
|
+
signatureAdviceMap,
|
|
1638
|
+
});
|
|
1639
|
+
return request;
|
|
1640
|
+
}
|
|
1641
|
+
if (version === undefined || version === 1) {
|
|
1642
|
+
if (!LEGACY_CONSUME_NOTES_ENABLED) {
|
|
1643
|
+
// Preserve explicit `1` vs absent so the error tells the
|
|
1644
|
+
// operator which legacy shape was rejected.
|
|
1645
|
+
throw new UnsupportedMetadataVersionError(version);
|
|
1646
|
+
}
|
|
1647
|
+
const { request } = await buildConsumeNotesTransactionRequest(
|
|
1648
|
+
webClient,
|
|
1649
|
+
metadata.noteIds,
|
|
1650
|
+
{ salt, signatureAdviceMap },
|
|
1651
|
+
);
|
|
1652
|
+
return request;
|
|
1653
|
+
}
|
|
1654
|
+
throw new UnsupportedMetadataVersionError(version);
|
|
1328
1655
|
}
|
|
1329
1656
|
case 'p2id': {
|
|
1330
1657
|
const { request } = buildP2idTransactionRequest(
|
|
@@ -1336,8 +1663,10 @@ export class Multisig {
|
|
|
1336
1663
|
);
|
|
1337
1664
|
return request;
|
|
1338
1665
|
}
|
|
1339
|
-
case '
|
|
1340
|
-
throw new Error(
|
|
1666
|
+
case 'custom':
|
|
1667
|
+
throw new Error(
|
|
1668
|
+
`Cannot build a transaction for a custom proposal type: ${metadata.rawProposalType ?? 'custom'}`,
|
|
1669
|
+
);
|
|
1341
1670
|
}
|
|
1342
1671
|
}
|
|
1343
1672
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ProposalMetadata as GuardianProposalMetadata } from '@openzeppelin/guardian-client';
|
|
3
|
+
import { ProposalMetadataCodec } from './metadata.js';
|
|
4
|
+
import type {
|
|
5
|
+
ConsumeNotesProposalMetadata,
|
|
6
|
+
CustomProposalMetadata,
|
|
7
|
+
} from '../types/proposal.js';
|
|
8
|
+
|
|
9
|
+
describe('ProposalMetadataCodec consume_notes v2 round-trip (issue #229)', () => {
|
|
10
|
+
it('toGuardian threads metadataVersion and notes to the wire', () => {
|
|
11
|
+
const md: ConsumeNotesProposalMetadata = {
|
|
12
|
+
proposalType: 'consume_notes',
|
|
13
|
+
description: 'consume one note',
|
|
14
|
+
noteIds: ['0xabc'],
|
|
15
|
+
metadataVersion: 2,
|
|
16
|
+
notes: ['YmFzZTY0Tm90ZQ=='],
|
|
17
|
+
};
|
|
18
|
+
const wire = ProposalMetadataCodec.toGuardian(md);
|
|
19
|
+
expect(wire.noteIds).toEqual(['0xabc']);
|
|
20
|
+
expect(wire.consumeNotesMetadataVersion).toBe(2);
|
|
21
|
+
expect(wire.consumeNotesNotes).toEqual(['YmFzZTY0Tm90ZQ==']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('fromGuardian reconstructs the v2 fields', () => {
|
|
25
|
+
const wire: GuardianProposalMetadata = {
|
|
26
|
+
proposalType: 'consume_notes',
|
|
27
|
+
noteIds: ['0xabc'],
|
|
28
|
+
consumeNotesMetadataVersion: 2,
|
|
29
|
+
consumeNotesNotes: ['YmFzZTY0Tm90ZQ=='],
|
|
30
|
+
};
|
|
31
|
+
const md = ProposalMetadataCodec.fromGuardian(wire) as ConsumeNotesProposalMetadata;
|
|
32
|
+
expect(md.proposalType).toBe('consume_notes');
|
|
33
|
+
expect(md.metadataVersion).toBe(2);
|
|
34
|
+
expect(md.notes).toEqual(['YmFzZTY0Tm90ZQ==']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('round-trips a v1 (legacy) proposal without spurious v2 fields', () => {
|
|
38
|
+
const md: ConsumeNotesProposalMetadata = {
|
|
39
|
+
proposalType: 'consume_notes',
|
|
40
|
+
description: 'legacy',
|
|
41
|
+
noteIds: ['0xabc'],
|
|
42
|
+
};
|
|
43
|
+
const wire = ProposalMetadataCodec.toGuardian(md);
|
|
44
|
+
expect(wire.consumeNotesMetadataVersion).toBeUndefined();
|
|
45
|
+
expect(wire.consumeNotesNotes).toBeUndefined();
|
|
46
|
+
const back = ProposalMetadataCodec.fromGuardian(wire) as ConsumeNotesProposalMetadata;
|
|
47
|
+
expect(back.metadataVersion).toBeUndefined();
|
|
48
|
+
expect(back.notes).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('ProposalMetadataCodec custom proposal types (issue #266)', () => {
|
|
53
|
+
it('fromGuardian collapses an unmodeled type to the custom bucket, keeping the raw label', () => {
|
|
54
|
+
const wire: GuardianProposalMetadata = {
|
|
55
|
+
proposalType: 'b2agg',
|
|
56
|
+
description: 'agglayer bridge note',
|
|
57
|
+
};
|
|
58
|
+
const md = ProposalMetadataCodec.fromGuardian(wire) as CustomProposalMetadata;
|
|
59
|
+
expect(md.proposalType).toBe('custom');
|
|
60
|
+
expect(md.rawProposalType).toBe('b2agg');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('toGuardian round-trips the raw label, not the custom bucket', () => {
|
|
64
|
+
const md: CustomProposalMetadata = {
|
|
65
|
+
proposalType: 'custom',
|
|
66
|
+
description: 'agglayer bridge note',
|
|
67
|
+
rawProposalType: 'b2agg',
|
|
68
|
+
};
|
|
69
|
+
const wire = ProposalMetadataCodec.toGuardian(md);
|
|
70
|
+
expect(wire.proposalType).toBe('b2agg');
|
|
71
|
+
|
|
72
|
+
const back = ProposalMetadataCodec.fromGuardian(wire) as CustomProposalMetadata;
|
|
73
|
+
expect(back.proposalType).toBe('custom');
|
|
74
|
+
expect(back.rawProposalType).toBe('b2agg');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('validate accepts a custom proposal', () => {
|
|
78
|
+
const md: CustomProposalMetadata = {
|
|
79
|
+
proposalType: 'custom',
|
|
80
|
+
description: 'opaque',
|
|
81
|
+
rawProposalType: 'b2agg',
|
|
82
|
+
};
|
|
83
|
+
expect(ProposalMetadataCodec.validate(md)).toBe(md);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('round-trips update_procedure_threshold through the codec', () => {
|
|
87
|
+
const wire: GuardianProposalMetadata = {
|
|
88
|
+
proposalType: 'update_procedure_threshold',
|
|
89
|
+
targetProcedure: 'send_asset',
|
|
90
|
+
targetThreshold: 2,
|
|
91
|
+
};
|
|
92
|
+
const md = ProposalMetadataCodec.fromGuardian(wire);
|
|
93
|
+
expect(md.proposalType).toBe('update_procedure_threshold');
|
|
94
|
+
const back = ProposalMetadataCodec.toGuardian(md);
|
|
95
|
+
expect(back.proposalType).toBe('update_procedure_threshold');
|
|
96
|
+
expect(back.targetProcedure).toBe('send_asset');
|
|
97
|
+
expect(back.targetThreshold).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/proposal/metadata.ts
CHANGED
|
@@ -16,6 +16,8 @@ export class ProposalMetadataCodec {
|
|
|
16
16
|
return {
|
|
17
17
|
...base,
|
|
18
18
|
noteIds: metadata.noteIds,
|
|
19
|
+
consumeNotesMetadataVersion: metadata.metadataVersion,
|
|
20
|
+
consumeNotesNotes: metadata.notes,
|
|
19
21
|
};
|
|
20
22
|
case 'p2id':
|
|
21
23
|
return {
|
|
@@ -46,8 +48,9 @@ export class ProposalMetadataCodec {
|
|
|
46
48
|
targetThreshold: metadata.targetThreshold,
|
|
47
49
|
signerCommitments: metadata.targetSignerCommitments,
|
|
48
50
|
};
|
|
49
|
-
case '
|
|
50
|
-
|
|
51
|
+
case 'custom':
|
|
52
|
+
// Round-trip the original server label rather than the 'custom' bucket.
|
|
53
|
+
return { ...base, proposalType: metadata.rawProposalType };
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -82,6 +85,8 @@ export class ProposalMetadataCodec {
|
|
|
82
85
|
...base,
|
|
83
86
|
proposalType: 'consume_notes',
|
|
84
87
|
noteIds: guardian.noteIds,
|
|
88
|
+
metadataVersion: guardian.consumeNotesMetadataVersion as 1 | 2 | undefined,
|
|
89
|
+
notes: guardian.consumeNotesNotes,
|
|
85
90
|
};
|
|
86
91
|
case 'switch_guardian':
|
|
87
92
|
if (!guardian.newGuardianPubkey || !guardian.newGuardianEndpoint) {
|
|
@@ -116,12 +121,18 @@ export class ProposalMetadataCodec {
|
|
|
116
121
|
}
|
|
117
122
|
return {
|
|
118
123
|
...base,
|
|
119
|
-
proposalType: guardian.proposalType,
|
|
124
|
+
proposalType: guardian.proposalType as 'add_signer' | 'remove_signer' | 'change_threshold',
|
|
120
125
|
targetThreshold: guardian.targetThreshold,
|
|
121
126
|
targetSignerCommitments: guardian.signerCommitments,
|
|
122
127
|
};
|
|
123
128
|
default:
|
|
124
|
-
|
|
129
|
+
// Any proposal type the SDK does not model collapses to the 'custom'
|
|
130
|
+
// bucket while preserving the original label (issue #266).
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
proposalType: 'custom',
|
|
134
|
+
rawProposalType: guardian.proposalType,
|
|
135
|
+
};
|
|
125
136
|
}
|
|
126
137
|
}
|
|
127
138
|
|
|
@@ -158,8 +169,10 @@ export class ProposalMetadataCodec {
|
|
|
158
169
|
throw new Error('p2id proposal metadata is incomplete');
|
|
159
170
|
}
|
|
160
171
|
return metadata;
|
|
161
|
-
case '
|
|
162
|
-
|
|
172
|
+
case 'custom':
|
|
173
|
+
// Custom proposals are opaque to the SDK; nothing to validate beyond
|
|
174
|
+
// the base fields. They can be listed/signed/exported, not built here.
|
|
175
|
+
return metadata;
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
MidenClient,
|
|
3
|
+
Note,
|
|
3
4
|
TransactionRequest,
|
|
4
5
|
WasmWebClient,
|
|
5
6
|
Word,
|
|
@@ -10,34 +11,30 @@ import {
|
|
|
10
11
|
TransactionRequestBuilder,
|
|
11
12
|
Word as WordType,
|
|
12
13
|
} from '@miden-sdk/miden-sdk';
|
|
14
|
+
import { LegacyConsumeNotesNoteMissingError } from '../multisig/consumeNotesErrors.js';
|
|
13
15
|
import { getRawMidenClient } from '../raw-client.js';
|
|
14
|
-
import { randomWord } from '../utils/random.js';
|
|
15
16
|
import { normalizeHexWord } from '../utils/encoding.js';
|
|
17
|
+
import { randomWord } from '../utils/random.js';
|
|
16
18
|
import type { SignatureOptions } from './options.js';
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Build a consume-notes request from loaded `Note` objects (no local-store
|
|
22
|
+
* read). v2 verification path for issue #229.
|
|
23
|
+
*/
|
|
24
|
+
export function buildConsumeNotesTransactionRequestFromNotes(
|
|
25
|
+
notes: Note[],
|
|
21
26
|
options: SignatureOptions = {},
|
|
22
|
-
):
|
|
23
|
-
if (
|
|
24
|
-
throw new Error('At least one note
|
|
27
|
+
): { request: TransactionRequest; salt: Word } {
|
|
28
|
+
if (notes.length === 0) {
|
|
29
|
+
throw new Error('At least one note is required');
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
const rawClient = await getRawMidenClient(client, options.midenRpcEndpoint);
|
|
28
32
|
const noteAndArgsArray = new NoteAndArgsArray();
|
|
29
|
-
for (const
|
|
30
|
-
|
|
31
|
-
if (!inputNoteRecord) {
|
|
32
|
-
throw new Error(`Note not found in local store: ${noteIdHex}`);
|
|
33
|
-
}
|
|
34
|
-
const note = inputNoteRecord.toNote();
|
|
35
|
-
const noteAndArgs = new NoteAndArgs(note, null);
|
|
36
|
-
noteAndArgsArray.push(noteAndArgs);
|
|
33
|
+
for (const note of notes) {
|
|
34
|
+
noteAndArgsArray.push(new NoteAndArgs(note, null));
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
const authSaltHex = options.salt ? options.salt.toHex() : randomWord().toHex();
|
|
40
|
-
|
|
41
38
|
const authSaltForBuilder = WordType.fromHex(normalizeHexWord(authSaltHex));
|
|
42
39
|
|
|
43
40
|
let txBuilder = new TransactionRequestBuilder();
|
|
@@ -55,3 +52,29 @@ export async function buildConsumeNotesTransactionRequest(
|
|
|
55
52
|
salt: authSaltForReturn,
|
|
56
53
|
};
|
|
57
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Legacy/creation adapter: fetches notes from the local store and delegates
|
|
58
|
+
* to the from-notes variant. v2 verification MUST NOT call this.
|
|
59
|
+
*/
|
|
60
|
+
export async function buildConsumeNotesTransactionRequest(
|
|
61
|
+
client: MidenClient | WasmWebClient,
|
|
62
|
+
noteIds: string[],
|
|
63
|
+
options: SignatureOptions = {},
|
|
64
|
+
): Promise<{ request: TransactionRequest; salt: Word }> {
|
|
65
|
+
if (noteIds.length === 0) {
|
|
66
|
+
throw new Error('At least one note ID is required');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const rawClient = await getRawMidenClient(client, options.midenRpcEndpoint);
|
|
70
|
+
const notes: Note[] = [];
|
|
71
|
+
for (const noteIdHex of noteIds) {
|
|
72
|
+
const inputNoteRecord = await rawClient.getInputNote(noteIdHex);
|
|
73
|
+
if (!inputNoteRecord) {
|
|
74
|
+
throw new LegacyConsumeNotesNoteMissingError(noteIdHex);
|
|
75
|
+
}
|
|
76
|
+
notes.push(inputNoteRecord.toNote());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return buildConsumeNotesTransactionRequestFromNotes(notes, options);
|
|
80
|
+
}
|