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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ because they are versioned in lockstep:

### Security

## [0.4.0-preview.3] — 2026-07-19

### Fixed

- Generated exercise wrappers (`ContractId<T>.<Choice>Async`) can express `readAs`
again. When a choice's controllers resolve statically the emitter had replaced the
`SubmitterInfo`-accepting overload (shipped through 0.3.0-preview.1) with a single
ergonomic `Party` overload, so a submitter could no longer supply `readAs` parties.
A choice whose created contracts are visible to an observer but not to the submitter
then projected no created contracts for the submitter and surfaced as
`ExerciseOutcome.None` — a committed success that read back as a failure. The emitter
now emits the `SubmitterInfo` overload alongside the named-`Party` overload for every
create-bearing choice, restoring `readAs` on the generated surface while keeping the
single-`Party` ergonomics.

## [0.4.0-preview.2] — 2026-07-18

### Added
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<PackageIcon>icon.png</PackageIcon>

<!-- Versioning -->
<Version>0.4.0-preview.2</Version>
<Version>0.4.0-preview.3</Version>
<AssemblyVersion>0.4.0.0</AssemblyVersion>
<FileVersion>0.4.0.0</FileVersion>

Expand Down
47 changes: 47 additions & 0 deletions samples/QuickstartExample/Generated/Quickstart/Iou.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,53 @@ public static async Task<ExerciseOutcome<TransferResult>> TransferAsync(
return outcome.ProjectCommitted(tx => TransferResult.FromCreatedContracts(tx.CreatedContracts));
}

/// <summary>
/// Exercises the Transfer choice with an explicit <see cref="SubmitterInfo"/> and projects the resulting transaction's created contracts to a typed <see cref="TransferResult"/>.
/// Companion to the named-<c>Party</c> overload for the case where the submitter must
/// read contracts it does not act as — the choice's created contracts are visible to an
/// observer but not to the submitter, so the caller supplies the <c>readAs</c> parties.
/// </summary>
/// <param name="contractId">The contract on which to exercise the choice.</param>
/// <param name="client">The ledger client.</param>
/// <param name="argument">The choice argument.</param>
/// <param name="submitter">The submitter party set (<c>actAs</c> + optional <c>readAs</c>).</param>
/// <param name="workflowId">Optional workflow id; passed through to the ledger when supplied. No default — workflow IDs are correlation keys, and a per-choice default would bucket every submission of the same choice under one ID.</param>
/// <param name="commandId">Optional command id for deduplication; a fresh id is minted only when omitted. Pass the same id across a retry of a lost-but-accepted submission so the ledger deduplicates the resubmission instead of re-executing the choice.</param>
/// <param name="timeout">Optional per-call deadline, enforced server-side; the default <c>null</c> applies no deadline. An overrun surfaces as an <c>InfraError</c> outcome.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<ExerciseOutcome<TransferResult>> TransferAsync(
this ContractId<Iou> contractId,
ILedgerWriter client,
Iou.Transfer argument,
SubmitterInfo submitter,
string? workflowId = null,
CommandId? commandId = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(contractId);
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(argument);

var command = new ExerciseCommand(
Iou.TemplateId,
contractId,
new ChoiceName("Transfer"),
argument.ToRecord());

var submission = CommandsSubmission.Single(command)
.WithSubmitter(submitter)
.WithCommandId(commandId ?? new CommandId(Guid.NewGuid().ToString()));
if (!string.IsNullOrEmpty(workflowId))
{
submission = submission.WithWorkflowId(new WorkflowId(workflowId));
}

var outcome = await client.TrySubmitAndWaitForTransactionAsync(submission, timeout: timeout, cancellationToken: cancellationToken).ConfigureAwait(false);

return outcome.ProjectCommitted(tx => TransferResult.FromCreatedContracts(tx.CreatedContracts));
}

/// <summary>
/// Exercises the Transfer choice on a fetched <see cref="Iou"/> contract,
/// reading every controller and observer party off the contract payload so the
Expand Down
100 changes: 97 additions & 3 deletions src/Daml.Codegen.CSharp/CodeGen/ChoiceEmitter.ContractIdExercisers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ internal void WriteChoiceAsyncExercisersClass(

if (controllers.Source == DamlPartySource.Static && controllers.Parties.Count > 0)
{
indent.AppendLine();
WriteSubmitterInfoChoiceAsyncExerciser(
indent, choice, templateClassName, dataTypes);

indent.AppendLine();
WriteSingleContractChoiceAsyncExerciser(
indent, choice, templateClassName, dataTypes, controllers, effectiveReadAs);
Expand Down Expand Up @@ -312,6 +316,99 @@ private void WriteSingleChoiceAsyncExerciser(
}
}

WriteExerciserCommandDispatchAndProject(indent, choice, templateClassName, dataTypes);

indent.Dedent();
indent.AppendLine("}");
}

/// <summary>
/// Emits the readAs-capable <c>&lt;Choice&gt;Async</c> overload on
/// <c>ContractId&lt;TemplateName&gt;</c> that takes an explicit
/// <c>SubmitterInfo</c> instead of named <c>Party</c> parameters.
/// Companion to the ergonomic named-<c>Party</c> overload for choices whose
/// created contracts are visible to an observer but not to the submitter —
/// the caller supplies <c>readAs</c> parties the payload cannot derive.
/// Emitted alongside the named-<c>Party</c> overload whenever controllers are
/// statically resolvable; the dynamic-controller case already surfaces a
/// <c>SubmitterInfo</c> parameter on its sole overload.
/// </summary>
private void WriteSubmitterInfoChoiceAsyncExerciser(
IndentWriter indent,
DamlChoice choice,
string templateClassName,
IReadOnlyDictionary<string, DamlDataType> dataTypes)
{
var choiceName = SanitizeIdentifier(choice.Name);
var resultName = $"{choiceName}Result";
var (argTypeName, _, _, isNestedTemplateArg) = GetChoiceArgumentInfo(choice, dataTypes);
var hasArg = argTypeName != "DamlUnit";

if (options.GenerateXmlDocs)
{
indent.AppendLine("/// <summary>");
indent.AppendLine($"/// Exercises the {choice.Name} choice with an explicit <see cref=\"SubmitterInfo\"/> and projects the resulting transaction's created contracts to a typed <see cref=\"{resultName}\"/>.");
indent.AppendLine("/// Companion to the named-<c>Party</c> overload for the case where the submitter must");
indent.AppendLine("/// read contracts it does not act as — the choice's created contracts are visible to an");
indent.AppendLine("/// observer but not to the submitter, so the caller supplies the <c>readAs</c> parties.");
indent.AppendLine("/// </summary>");
indent.AppendLine("/// <param name=\"contractId\">The contract on which to exercise the choice.</param>");
indent.AppendLine("/// <param name=\"client\">The ledger client.</param>");
if (hasArg)
{
indent.AppendLine("/// <param name=\"argument\">The choice argument.</param>");
}
indent.AppendLine("/// <param name=\"submitter\">The submitter party set (<c>actAs</c> + optional <c>readAs</c>).</param>");
indent.AppendLine("/// <param name=\"workflowId\">Optional workflow id; passed through to the ledger when supplied. No default — workflow IDs are correlation keys, and a per-choice default would bucket every submission of the same choice under one ID.</param>");
indent.AppendLine("/// <param name=\"commandId\">Optional command id for deduplication; a fresh id is minted only when omitted. Pass the same id across a retry of a lost-but-accepted submission so the ledger deduplicates the resubmission instead of re-executing the choice.</param>");
indent.AppendLine("/// <param name=\"timeout\">Optional per-call deadline, enforced server-side; the default <c>null</c> applies no deadline. An overrun surfaces as an <c>InfraError</c> outcome.</param>");
indent.AppendLine("/// <param name=\"cancellationToken\">Cancellation token.</param>");
}

indent.AppendLine($"public static async Task<{context.Qualifier.Qualify(RuntimeTypeNames.ExerciseOutcome, context.RootNamespace)}<{resultName}>> {choiceName}Async(");
indent.Indent();
indent.AppendLine($"this {context.Qualifier.Qualify(RuntimeTypeNames.ContractId, context.RootNamespace)}<{templateClassName}> contractId,");
indent.AppendLine($"{context.Qualifier.Qualify(RuntimeTypeNames.ILedgerWriter, context.RootNamespace)} client,");
if (hasArg)
{
var argParamType = isNestedTemplateArg
? $"{templateClassName}.{argTypeName}"
: argTypeName;
indent.AppendLine($"{argParamType} argument,");
}
indent.AppendLine($"{context.Qualifier.Qualify(RuntimeTypeNames.SubmitterInfo, context.RootNamespace)} submitter,");
indent.AppendLine("string? workflowId = null,");
indent.AppendLine($"{context.Qualifier.Qualify(RuntimeTypeNames.CommandId, context.RootNamespace)}? commandId = null,");
indent.AppendLine("TimeSpan? timeout = null,");
indent.AppendLine("CancellationToken cancellationToken = default)");
indent.Dedent();
indent.AppendLine("{");
indent.Indent();

indent.AppendLine("ArgumentNullException.ThrowIfNull(contractId);");
indent.AppendLine("ArgumentNullException.ThrowIfNull(client);");
if (hasArg)
{
indent.AppendLine("ArgumentNullException.ThrowIfNull(argument);");
}

WriteExerciserCommandDispatchAndProject(indent, choice, templateClassName, dataTypes);

indent.Dedent();
indent.AppendLine("}");
}

private void WriteExerciserCommandDispatchAndProject(
IndentWriter indent,
DamlChoice choice,
string templateClassName,
IReadOnlyDictionary<string, DamlDataType> dataTypes)
{
var choiceName = SanitizeIdentifier(choice.Name);
var resultName = $"{choiceName}Result";
var (argTypeName, _, _, _) = GetChoiceArgumentInfo(choice, dataTypes);
var hasArg = argTypeName != "DamlUnit";

indent.AppendLine();
var argExpr = hasArg ? "argument.ToRecord()" : $"{context.Qualifier.Qualify(RuntimeTypeNames.DamlUnit, context.RootNamespace)}.Instance";
indent.AppendLine($"var command = new {context.Qualifier.Qualify(RuntimeTypeNames.ExerciseCommand, context.RootNamespace)}(");
Expand All @@ -338,9 +435,6 @@ private void WriteSingleChoiceAsyncExerciser(
indent.AppendLine("var outcome = await client.TrySubmitAndWaitForTransactionAsync(submission, timeout: timeout, cancellationToken: cancellationToken).ConfigureAwait(false);");
indent.AppendLine();
indent.AppendLine($"return outcome.ProjectCommitted(tx => {resultName}.FromCreatedContracts(tx.CreatedContracts));");

indent.Dedent();
indent.AppendLine("}");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,53 @@ public static async Task<ExerciseOutcome<RelabelResult>> RelabelAsync(
return outcome.ProjectCommitted(tx => RelabelResult.FromCreatedContracts(tx.CreatedContracts));
}

/// <summary>
/// Exercises the Relabel choice with an explicit <see cref="SubmitterInfo"/> and projects the resulting transaction's created contracts to a typed <see cref="RelabelResult"/>.
/// Companion to the named-<c>Party</c> overload for the case where the submitter must
/// read contracts it does not act as — the choice's created contracts are visible to an
/// observer but not to the submitter, so the caller supplies the <c>readAs</c> parties.
/// </summary>
/// <param name="contractId">The contract on which to exercise the choice.</param>
/// <param name="client">The ledger client.</param>
/// <param name="argument">The choice argument.</param>
/// <param name="submitter">The submitter party set (<c>actAs</c> + optional <c>readAs</c>).</param>
/// <param name="workflowId">Optional workflow id; passed through to the ledger when supplied. No default — workflow IDs are correlation keys, and a per-choice default would bucket every submission of the same choice under one ID.</param>
/// <param name="commandId">Optional command id for deduplication; a fresh id is minted only when omitted. Pass the same id across a retry of a lost-but-accepted submission so the ledger deduplicates the resubmission instead of re-executing the choice.</param>
/// <param name="timeout">Optional per-call deadline, enforced server-side; the default <c>null</c> applies no deadline. An overrun surfaces as an <c>InfraError</c> outcome.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<ExerciseOutcome<RelabelResult>> RelabelAsync(
this ContractId<RichRecord> contractId,
ILedgerWriter client,
RichRecord.Relabel argument,
SubmitterInfo submitter,
string? workflowId = null,
CommandId? commandId = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(contractId);
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(argument);

var command = new ExerciseCommand(
RichRecord.TemplateId,
contractId,
new ChoiceName("Relabel"),
argument.ToRecord());

var submission = CommandsSubmission.Single(command)
.WithSubmitter(submitter)
.WithCommandId(commandId ?? new CommandId(Guid.NewGuid().ToString()));
if (!string.IsNullOrEmpty(workflowId))
{
submission = submission.WithWorkflowId(new WorkflowId(workflowId));
}

var outcome = await client.TrySubmitAndWaitForTransactionAsync(submission, timeout: timeout, cancellationToken: cancellationToken).ConfigureAwait(false);

return outcome.ProjectCommitted(tx => RelabelResult.FromCreatedContracts(tx.CreatedContracts));
}

/// <summary>
/// Exercises the Relabel choice on a fetched <see cref="RichRecord"/> contract,
/// reading every controller and observer party off the contract payload so the
Expand Down
34 changes: 31 additions & 3 deletions tests/Daml.Codegen.CSharp.Tests/NamedSubmitterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,12 +281,13 @@ [new DamlPartyPayloadField("counterparty")]),
var offer = files.First(f => f.RelativePath.EndsWith("Offer.cs", StringComparison.Ordinal)).Content;

// The choice has a single Party-typed controller (counterparty). The
// wrapper signature carries one named Party parameter — no string actAs,
// no SubmitterInfo fallback.
// ergonomic wrapper carries one named Party parameter — no string actAs.
// A readAs-capable SubmitterInfo overload is emitted alongside it, so a
// submitter that must read contracts it does not act as stays expressible.
offer.Should().Contain("public static async Task<ExerciseOutcome<AcceptResult>> AcceptAsync(");
offer.Should().Contain("Party counterparty,");
offer.Should().NotContain("string actAs,");
offer.Should().NotContain("SubmitterInfo submitter,");
offer.Should().Contain("SubmitterInfo submitter,");

// Every emitted controller Party parameter carries a matching XML doc
// <param> tag, or a doc-generating consumer project fails with CS1573.
Expand Down Expand Up @@ -831,6 +832,33 @@ public void Generate_choice_async_with_no_observers_emits_no_readAs_contribution
content.Should().Contain(".WithSubmitter(submitter)");
}

[Fact]
public void Generate_choice_async_with_static_controllers_also_emits_readAs_capable_submitter_overload()
{
// The ergonomic named-Party overload is an addition, not a replacement.
// A choice whose created contracts are visible to an observer but not the
// submitter can only be exercised when the caller supplies a full
// SubmitterInfo (actAs + readAs). The static-controller wrapper must emit
// both a named-Party overload and a SubmitterInfo overload on the
// ContractId<T> receiver.
var module = MakeAgreementWithObservers(
signatories: DamlPartyAnalysis.Static([new DamlPartyPayloadField("platform")]),
templateObservers: DamlPartyAnalysis.Static([]),
choiceControllers: DamlPartyAnalysis.Static([new DamlPartyPayloadField("platform")]),
choiceObservers: DamlPartyAnalysis.Static([]));

var files = CreateGenerator().Generate(CreateDar(module));
var content = files.First(f => f.RelativePath.EndsWith("Agreement.cs", StringComparison.Ordinal)).Content;

content.Should().Contain("Party platform,");
content.Should().Contain("SubmitterInfo submitter,");
content.Should().Contain(".WithSubmitter(submitter)");

var contractIdOverloads =
content.Split("public static async Task<ExerciseOutcome<RenewResult>> RenewAsync(").Length - 1;
contractIdOverloads.Should().Be(2);
}

[Fact]
public void Generate_choice_async_with_observer_subset_of_controllers_does_not_duplicate_readAs()
{
Expand Down
Loading
Loading