Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 9 additions & 20 deletions clients/cli/src/commands/transaction/submit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,13 @@ pub(super) struct SubmitCommand {
signers: Vec<String>,

/// Signer source for a signer the executor uses directly: a keypair file, usb:// URL,
/// prompt:// URL, or the ASK keyword. Repeat for each signer. Each is a required signer on
/// the authorization message, so it signs that message after a signing summary. It also signs
/// the relay transaction, which forwards its signer privilege to the executor. The fee payer
/// is always a relay transaction signer.
/// prompt:// URL, or the ASK keyword. Repeat for each signer. Each signs the relay
/// transaction after a signing summary, which forwards its signer privilege to the executor.
/// The fee payer is always a relay transaction signer.
#[clap(long, value_parser = keypair_source_parser())]
relay_signer: Vec<SignerSource>,

/// Hide the signing summary shown when a relay signer signs the authorization message.
/// Hide the signing summary shown when a forwarded signer signs the relay transaction.
/// Confirmation prompts and errors are still shown.
#[clap(long)]
quiet: bool,
Expand Down Expand Up @@ -92,7 +91,7 @@ pub(super) async fn run(
}
// Relay signers on the authorization message have their signer privilege forwarded to the
// executor, so they review it like `transaction sign`. A fee payer that is not on the
// authorization message only signs the relay transaction.
// authorization message only pays for the relay transaction.
let (authorization_signers, relay_only_signers): (Vec<_>, Vec<_>) = relay_signers
.into_iter()
.partition(|(address, _)| required_signers.contains(address));
Expand Down Expand Up @@ -169,22 +168,12 @@ pub(super) async fn run(
confirm_signing(&authorization_signers, command.yes)?;
}

let message_bytes = authorization_message.serialize();
// Forwarded signers without an authority signature approve through their relay transaction
// signature instead, which Submit accepts in place of an authorization message signature.
let signatures = required_signers
.iter()
.map(|address| {
if let Some(signature) = authority_signatures.get(address) {
return Ok(*signature);
}
let (_, signer) = authorization_signers
.iter()
.find(|(relay, _)| relay == address)
.context("missing relay signer")?;
signer
.try_sign_message(&message_bytes)
.with_context(|| format!("failed to sign authorization message with {address}"))
})
.collect::<Result<Vec<_>>>()?;
.map(|address| authority_signatures.get(address).copied())
.collect();

let mut instruction = spl_ed25519_signer_client::instruction::submit(
signatures,
Expand Down
3 changes: 3 additions & 0 deletions clients/js/src/generated/errors/ed25519Signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const ED25519_SIGNER_ERROR__DISALLOWED_EXECUTOR_INSTRUCTION = 0x4; // 4
export const ED25519_SIGNER_ERROR__INVALID_SIGNATURE_COUNT = 0x5; // 5
export const ED25519_SIGNER_ERROR__UNSUPPORTED_MESSAGE_VERSION = 0x6; // 6
export const ED25519_SIGNER_ERROR__UNSUPPORTED_TRANSACTION_CONFIG = 0x7; // 7
export const ED25519_SIGNER_ERROR__MISSING_SIGNATURE = 0x8; // 8

export type Ed25519SignerError =
| typeof ED25519_SIGNER_ERROR__ACCOUNT_KEY_MISMATCH
Expand All @@ -30,6 +31,7 @@ export type Ed25519SignerError =
| typeof ED25519_SIGNER_ERROR__INVALID_MESSAGE
| typeof ED25519_SIGNER_ERROR__INVALID_SIGNATURE
| typeof ED25519_SIGNER_ERROR__INVALID_SIGNATURE_COUNT
| typeof ED25519_SIGNER_ERROR__MISSING_SIGNATURE
| typeof ED25519_SIGNER_ERROR__UNSUPPORTED_MESSAGE_VERSION
| typeof ED25519_SIGNER_ERROR__UNSUPPORTED_TRANSACTION_CONFIG;

Expand All @@ -42,6 +44,7 @@ if (process.env['NODE_ENV'] !== 'production') {
[ED25519_SIGNER_ERROR__INVALID_MESSAGE]: `The authorization message failed sanitization`,
[ED25519_SIGNER_ERROR__INVALID_SIGNATURE]: `An authority signature failed verification against the authorization message`,
[ED25519_SIGNER_ERROR__INVALID_SIGNATURE_COUNT]: `The authority signature count does not match the authorization message.`,
[ED25519_SIGNER_ERROR__MISSING_SIGNATURE]: `A signer required by the authorization message has no signature and does not sign the relay transaction`,
[ED25519_SIGNER_ERROR__UNSUPPORTED_MESSAGE_VERSION]: `The authorization message is not a v1 message`,
[ED25519_SIGNER_ERROR__UNSUPPORTED_TRANSACTION_CONFIG]: `The authorization message sets transaction config fields`,
};
Expand Down
21 changes: 17 additions & 4 deletions clients/js/src/generated/instructions/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
getArrayEncoder,
getBytesDecoder,
getBytesEncoder,
getOptionDecoder,
getOptionEncoder,
getStructDecoder,
getStructEncoder,
getU8Decoder,
Expand All @@ -27,6 +29,8 @@ import {
type Instruction,
type InstructionWithAccounts,
type InstructionWithData,
type Option,
type OptionOrNullable,
type ReadonlyUint8Array,
} from '@solana/kit';
import { resolveAuthorizationMessageAccounts } from '../../hooked';
Expand All @@ -45,17 +49,23 @@ export type SubmitInstruction<

export type SubmitInstructionData = {
discriminator: number;
signatures: Array<ReadonlyUint8Array>;
signatures: Array<Option<ReadonlyUint8Array>>;
message: ReadonlyUint8Array;
};

export type SubmitInstructionDataArgs = { signatures: Array<ReadonlyUint8Array>; message: ReadonlyUint8Array };
export type SubmitInstructionDataArgs = {
signatures: Array<OptionOrNullable<ReadonlyUint8Array>>;
message: ReadonlyUint8Array;
};

export function getSubmitInstructionDataEncoder(): Encoder<SubmitInstructionDataArgs> {
return transformEncoder(
getStructEncoder([
['discriminator', getU8Encoder()],
['signatures', getArrayEncoder(fixEncoderSize(getBytesEncoder(), 64), { size: getU8Encoder() })],
[
'signatures',
getArrayEncoder(getOptionEncoder(fixEncoderSize(getBytesEncoder(), 64)), { size: getU8Encoder() }),
],
['message', getBytesEncoder()],
]),
value => ({ ...value, discriminator: SUBMIT_DISCRIMINATOR }),
Expand All @@ -65,7 +75,10 @@ export function getSubmitInstructionDataEncoder(): Encoder<SubmitInstructionData
export function getSubmitInstructionDataDecoder(): Decoder<SubmitInstructionData> {
return getStructDecoder([
['discriminator', getU8Decoder()],
['signatures', getArrayDecoder(fixDecoderSize(getBytesDecoder(), 64), { size: getU8Decoder() })],
[
'signatures',
getArrayDecoder(getOptionDecoder(fixDecoderSize(getBytesDecoder(), 64)), { size: getU8Decoder() }),
],
['message', getBytesDecoder()],
]);
}
Expand Down
9 changes: 8 additions & 1 deletion clients/js/src/hooked/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ export const resolveExecutionMessageAccounts = (scope: MessageAccountsResolverSc
/**
* Resolves the remaining `Submit` accounts from the authorization message's static account list.
* Account order and writable privileges match the authorization message, while signer privileges
* are removed because the authorization message's signers do not sign the relay transaction.
* are removed because authorities sign the authorization message, not the relay transaction.
*
* A signer the executor uses directly can sign the relay transaction instead of the authorization
* message. To do so, the caller passes `null` as that signer's entry in `signatures`, then applies
* `upgradeRoleToSigner` to its account in the `Submit` instruction and signs the relay transaction
* with it. Submit forwards that signer's privilege to the executor but never promotes its
* `ProgrammaticSigner` PDA.
*
* Throws for a message that is not v1, which the signer program rejects.
*
* Mirrors `signer/client/src/instruction.rs`.
Expand Down
23 changes: 19 additions & 4 deletions idl.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,19 @@
"type": {
"kind": "arrayTypeNode",
"item": {
"kind": "fixedSizeTypeNode",
"size": 64,
"type": {
"kind": "bytesTypeNode"
"kind": "optionTypeNode",
"fixed": false,
"item": {
"kind": "fixedSizeTypeNode",
"size": 64,
"type": {
"kind": "bytesTypeNode"
}
},
"prefix": {
"kind": "numberTypeNode",
"format": "u8",
"endian": "le"
}
},
"count": {
Expand Down Expand Up @@ -183,6 +192,12 @@
"name": "unsupportedTransactionConfig",
"code": 7,
"message": "The authorization message sets transaction config fields"
},
{
"kind": "errorNode",
"name": "missingSignature",
"code": 8,
"message": "A signer required by the authorization message has no signature and does not sign the relay transaction"
}
]
},
Expand Down
6 changes: 5 additions & 1 deletion signer/client/src/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ use {
};

/// Builds the `Submit` instruction from authority signatures and their authorization message.
pub fn submit(signatures: Vec<Signature>, message: VersionedMessage) -> Instruction {
///
/// `signatures` has one entry per required signer on `message`. Pass `None` for a signer that
/// signs the relay transaction instead, and mark its account as a signer on the returned
/// instruction. Its `ProgrammaticSigner` PDA is not promoted.
pub fn submit(signatures: Vec<Option<Signature>>, message: VersionedMessage) -> Instruction {
let accounts = message
.static_account_keys()
.iter()
Expand Down
2 changes: 1 addition & 1 deletion signer/client/src/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ pub fn sign_and_submit<S: Signers + ?Sized>(
signatures,
message,
} = VersionedTransaction::try_new(message, signers)?;
let instruction = submit(signatures, message);
let instruction = submit(signatures.into_iter().map(Some).collect(), message);
Ok(instruction)
}
23 changes: 19 additions & 4 deletions signer/idl.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,19 @@
"type": {
"kind": "arrayTypeNode",
"item": {
"kind": "fixedSizeTypeNode",
"size": 64,
"type": {
"kind": "bytesTypeNode"
"kind": "optionTypeNode",
"fixed": false,
"item": {
"kind": "fixedSizeTypeNode",
"size": 64,
"type": {
"kind": "bytesTypeNode"
}
},
"prefix": {
"kind": "numberTypeNode",
"format": "u8",
"endian": "le"
}
},
"count": {
Expand Down Expand Up @@ -183,6 +192,12 @@
"name": "unsupportedTransactionConfig",
"code": 7,
"message": "The authorization message sets transaction config fields"
},
{
"kind": "errorNode",
"name": "missingSignature",
"code": 8,
"message": "A signer required by the authorization message has no signature and does not sign the relay transaction"
}
]
},
Expand Down
12 changes: 12 additions & 0 deletions signer/interface/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ pub enum Error {
codama(error(message = "The authorization message sets transaction config fields"))
)]
UnsupportedTransactionConfig = 7,
/// A signer required by the authorization message has no signature and does not sign the relay
/// transaction.
#[cfg_attr(
feature = "codama",
codama(
error(
message = "A signer required by the authorization message has no signature and \
does not sign the relay transaction"
)
)
)]
MissingSignature = 8,
}

impl From<Error> for ProgramError {
Expand Down
15 changes: 9 additions & 6 deletions signer/interface/src/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ pub enum Instruction {
/// 1. Verifies the message contains exactly one executor instruction.
/// 2. Verifies the executor program and instruction discriminator are present in the allow list.
/// 3. Verifies each `signatures[i]` is `account_keys[i]`'s Ed25519 signature over that message.
/// A `None` signature is accepted only if the Submit account at index `i` signs the relay
/// transaction, which commits to the whole Submit instruction. Such a signer's
/// `ProgrammaticSigner` PDA is not promoted.
/// 4. Verifies submitted account keys match the message's account keys in order.
/// 5. CPIs to the executor instruction's program using exactly the accounts referenced by the
/// executor instruction's account index list, promoting any referenced authority-derived
/// `ProgrammaticSigner` PDAs to a signer.
/// executor instruction's account index list, promoting any referenced `ProgrammaticSigner`
/// PDAs derived from a signer with a verified signature.
///
/// Trust assumptions:
/// - This program validates authority signatures, accounts, flags, and executor identity.
Expand All @@ -50,13 +53,13 @@ pub enum Instruction {
codama(display(intent = "Verify authorization message and invoke its executor"))
)]
Submit {
#[wincode(with = "containers::Vec<Signature, u8>")]
#[wincode(with = "containers::Vec<Option<Signature>, u8>")]
#[cfg_attr(
feature = "codama",
codama(type = array(fixed_size(bytes, 64), prefixed_count(number(u8)))),
codama(type = array(option(fixed_size(bytes, 64)), prefixed_count(number(u8)))),
codama(display(label = "Authority signatures"))
)]
signatures: Vec<Signature>,
signatures: Vec<Option<Signature>>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vec<Option<T>> is a pretty rough serialization format, since it's variably sized. We could do something like Vec<(u16, Signature)> to pair the index with the signature, but I'm worried that's not much better, since then we'd have to enforce some sort of ordering.

We can stick with this, but I'll be open to other options as we keep developing

#[cfg_attr(
feature = "codama",
codama(type = bytes),
Expand Down Expand Up @@ -96,7 +99,7 @@ mod tests {
#[test]
fn submit_round_trips() {
let instruction = Instruction::Submit {
signatures: vec![Signature::from([7; 64])],
signatures: vec![Some(Signature::from([7; 64])), None],
message: VersionedMessage::V1(v1::Message::default()),
};
let bytes = wincode::serialize(&instruction).unwrap();
Expand Down
Loading
Loading